Files
pve-storage/src/PVE/CephConfig.pm
Fiona Ebner 7684225bac ceph/rbd: set 'keyring' in ceph configuration for externally managed RBD storages
For QEMU, when using '-blockdev', there is no way to specify the
keyring file like was possible with '-drive', so it has to be set in
the corresponding Ceph configuration file. As it applies to all images
on the storage, it also is the most natural place for the setting.

Signed-off-by: Fiona Ebner <f.ebner@proxmox.com>
2025-07-03 10:46:45 +02:00

573 lines
17 KiB
Perl

package PVE::CephConfig;
use strict;
use warnings;
use Net::IP;
use PVE::RESTEnvironment qw(log_warn);
use PVE::Tools qw(run_command);
use PVE::Cluster qw(cfs_register_file);
cfs_register_file('ceph.conf', \&parse_ceph_config, \&write_ceph_config);
# For more information on how the Ceph parser works and how its grammar is
# defined, see:
# https://git.proxmox.com/?p=ceph.git;a=blob;f=ceph/src/common/ConfUtils.cc;h=2f78fd02bf9e27467275752e6f3bca0c5e3946ce;hb=e9fe820e7fffd1b7cde143a9f77653b73fcec748#l144
sub parse_ceph_config {
my ($filename, $raw) = @_;
my $cfg = {};
return $cfg if !defined($raw);
# Note: According to Ceph's config grammar, a single key-value pair in a file
# (and nothing else!) is a valid config file and will be parsed by `ceph-conf`.
# We choose not to handle this case here because it doesn't seem to be used
# by Ceph at all (and otherwise doesn't really make sense anyway).
# Regexes ending with '_class' consist of only an extended character class
# each, which allows them to be interpolated into other ext. char classes.
my $re_leading_ws = qr/^\s+/;
my $re_trailing_ws = qr/\s+$/;
my $re_continue_marker = qr/\\/;
my $re_comment_class = qr/(?[ [ ; # ] ])/;
my $re_not_allowed_in_section_header_class = qr/(?[ [ \] ] + $re_comment_class ])/;
# Note: The Ceph config grammar defines keys the following way:
#
# key %= raw[+(text_char - char_("=[ ")) % +blank];
#
# The ' - char_("=[ ")' expression might lure you into thinking that keys
# may *not* contain spaces, but they can, due to the "% +blank" at the end!
#
# See: https://www.boost.org/doc/libs/1_42_0/libs/spirit/doc/html/spirit/qi/reference/operator/list.html
#
# Allowing spaces in this class and later squeezing whitespace as well as
# removing any leading and trailing whitespace from keys is just so much
# easier in our case.
my $re_not_allowed_in_keys_class = qr/(?[ [ = \[ ] + $re_comment_class ])/;
my $re_not_allowed_in_single_quoted_text_class = qr/(?[ [ ' ] + $re_comment_class ])/;
my $re_not_allowed_in_double_quoted_text_class = qr/(?[ [ " ] + $re_comment_class ])/;
my $re_text_char = qr/\\.|(?[ ! $re_comment_class ])/;
my $re_section_header_char = qr/\\.|(?[ ! $re_not_allowed_in_section_header_class ])/;
my $re_key_char = qr/\\.|(?[ ! $re_not_allowed_in_keys_class ])/;
my $re_single_quoted_text_char = qr/\\.|(?[ ! $re_not_allowed_in_single_quoted_text_class ])/;
my $re_double_quoted_text_char = qr/\\.|(?[ ! $re_not_allowed_in_double_quoted_text_class ])/;
my $re_single_quoted_value = qr/'(($re_single_quoted_text_char)*)'/;
my $re_double_quoted_value = qr/"(($re_double_quoted_text_char)*)"/;
my $re_key = qr/^(($re_key_char)+)/;
my $re_quoted_value = qr/$re_single_quoted_value|$re_double_quoted_value/;
my $re_unquoted_value = qr/(($re_text_char)*)/;
my $re_value = qr/($re_quoted_value|$re_unquoted_value)/;
my $re_kv_separator = qr/\s*(=)\s*/;
my $re_section_start = qr/\[/;
my $re_section_end = qr/\]/;
my $re_section_header = qr/$re_section_start(($re_section_header_char)+)$re_section_end/;
my $section;
my @lines = split(/\n/, $raw);
my $parse_section_header = sub {
my ($section_line) = @_;
# continued lines in section headers are allowed
while ($section_line =~ s/$re_continue_marker$//) {
$section_line .= shift(@lines);
}
my $remainder = $section_line;
$remainder =~ s/$re_section_header//;
my $parsed_header = $1;
# Un-escape comment literals
$parsed_header =~ s/\\($re_comment_class)/$1/g;
if (!$parsed_header) {
die "failed to parse section - skip: $section_line\n";
}
# preserve Ceph's behaviour and disallow anything after the section header
# that's not whitespace or a comment
$remainder =~ s/$re_leading_ws//;
$remainder =~ s/^$re_comment_class.*$//;
if ($remainder) {
die "unexpected remainder after section - skip: $section_line\n";
}
return $parsed_header;
};
my $parse_key = sub {
my ($line) = @_;
my $remainder = $line;
my $key = '';
while ($remainder =~ s/$re_key//) {
$key .= $1;
while ($key =~ s/$re_continue_marker$//) {
$remainder = shift(@lines);
}
}
$key =~ s/$re_trailing_ws//;
$key =~ s/$re_leading_ws//;
$key =~ s/\s/ /;
while ($key =~ s/\s\s/ /) { } # squeeze repeated whitespace
# Ceph treats *single* spaces in keys the same as underscores,
# but we'll just use underscores for readability
$key =~ s/ /_/g;
# Un-escape comment literals
$key =~ s/\\($re_comment_class)/$1/g;
if ($key eq '') {
die "failed to parse key from line - skip: $line\n";
}
my $had_equals = $remainder =~ s/^$re_kv_separator//;
if (!$had_equals) {
die "expected '=' after key - skip: $line\n";
}
while ($remainder =~ s/^$re_continue_marker$//) {
# Whitespace and continuations after equals sign can be arbitrary
$remainder = shift(@lines);
$remainder =~ s/$re_leading_ws//;
}
return ($key, $remainder);
};
my $parse_value = sub {
my ($line, $remainder) = @_;
my $starts_with_quote = $remainder =~ m/^['"]/;
$remainder =~ s/$re_value//;
my $value = $1 // '';
if ($value eq '') {
die "failed to parse value - skip: $line\n";
}
if ($starts_with_quote) {
# If it started with a quote, the parsed value MUST end with a quote
my $is_single_quoted = $value =~ m/$re_single_quoted_value/;
$value = $1 if $is_single_quoted;
my $is_double_quoted = !$is_single_quoted && $value =~ m/$re_double_quoted_value/;
$value = $1 if $is_double_quoted;
if (!($is_single_quoted || $is_double_quoted)) {
die "failed to parse quoted value - skip: $line\n";
}
# Optionally, *only* line continuations may *only* follow right after
while ($remainder =~ s/^$re_continue_marker$//) {
$remainder .= shift(@lines);
}
# Nothing but whitespace or a comment may follow
$remainder =~ s/$re_leading_ws//;
$remainder =~ s/^$re_comment_class.*$//;
if ($remainder) {
die "unexpected remainder after value - skip: $line\n";
}
} else {
while ($value =~ s/$re_continue_marker$//) {
my $next_line = shift(@lines);
$next_line =~ s/$re_unquoted_value//;
my $value_part = $1 // '';
$value .= $value_part;
}
$value =~ s/$re_trailing_ws//;
}
# Un-escape comment literals
$value =~ s/\\($re_comment_class)/$1/g;
return $value;
};
while (scalar(@lines)) {
my $line = shift(@lines);
$line =~ s/^\s*(?<!\\)$re_comment_class.*$//;
$line =~ s/^\s*$//;
next if !$line;
next if $line =~ m/^$re_continue_marker$/;
if ($line =~ m/$re_section_start/) {
$section = undef;
eval { $section = $parse_section_header->($line) };
if ($@) {
warn "$@\n";
}
if (defined($section)) {
$cfg->{$section} = {} if !exists($cfg->{$section});
}
next;
}
if (!defined($section)) {
warn "no section header - skip: $line\n";
next;
}
my ($key, $remainder) = eval { $parse_key->($line) };
if ($@) {
warn "$@\n";
next;
}
my $value = eval { $parse_value->($line, $remainder) };
if ($@) {
warn "$@\n";
next;
}
$cfg->{$section}->{$key} = $value;
}
return $cfg;
}
my $parse_ceph_file = sub {
my ($filename) = @_;
my $cfg = {};
return $cfg if !-f $filename;
my $content = PVE::Tools::file_get_contents($filename);
return parse_ceph_config($filename, $content);
};
sub write_ceph_config {
my ($filename, $cfg) = @_;
my $written_sections = {};
my $out = '';
my $cond_write_sec = sub {
my $re = shift;
for my $section (sort keys $cfg->%*) {
next if $section !~ m/^$re$/;
next if exists($written_sections->{$section});
$out .= "[$section]\n";
for my $key (sort keys $cfg->{$section}->%*) {
$out .= "\t$key = $cfg->{$section}->{$key}\n";
}
$out .= "\n";
$written_sections->{$section} = 1;
}
};
my @rexprs = (
qr/global/,
qr/client/,
qr/client\..*/,
qr/mds/,
qr/mds\..*/,
qr/mon/,
qr/mon\..*/,
qr/osd/,
qr/osd\..*/,
qr/mgr/,
qr/mgr\..*/,
qr/.*/,
);
for my $re (@rexprs) {
$cond_write_sec->($re);
}
# Escape comment literals that aren't escaped already
$out =~ s/(?<!\\)([;#])/\\$1/gm;
return $out;
}
my $ceph_get_key = sub {
my ($keyfile, $username) = @_;
my $key = $parse_ceph_file->($keyfile);
my $secret = $key->{"client.$username"}->{key};
return $secret;
};
my $get_host = sub {
my ($hostport) = @_;
my ($host, $port) = PVE::Tools::parse_host_and_port($hostport);
if (!defined($host)) {
return "";
}
$port = defined($port) ? ":$port" : '';
$host = "[$host]" if Net::IP::ip_is_ipv6($host);
return "${host}${port}";
};
sub get_monaddr_list {
my ($configfile) = shift;
if (!defined($configfile)) {
warn "No ceph config specified\n";
return;
}
my $config = $parse_ceph_file->($configfile);
my $monhostlist = {};
# get all ip addresses from mon_host
my $monhosts = [split(/[ ,;]+/, $config->{global}->{mon_host} // "")];
foreach my $monhost (@$monhosts) {
$monhost =~ s/^\[?v\d\://; # remove beginning of vector
$monhost =~ s|/\d+\]?||; # remove end of vector
my $host = $get_host->($monhost);
if ($host ne "") {
$monhostlist->{$host} = 1;
}
}
# then get all addrs from mon. sections
for my $section (keys %$config) {
next if $section !~ m/^mon\./;
if (my $addr = $config->{$section}->{mon_addr}) {
$monhostlist->{$addr} = 1;
}
}
return join(',', sort keys %$monhostlist);
}
sub hostlist {
my ($list_text, $separator) = @_;
my @monhostlist = PVE::Tools::split_list($list_text);
return join($separator, map { $get_host->($_) } @monhostlist);
}
my $ceph_check_keyfile = sub {
my ($filename, $type) = @_;
return if !-f $filename;
my $content = PVE::Tools::file_get_contents($filename);
eval {
die if !$content;
if ($type eq 'rbd') {
die if $content !~ /\s*\[\S+\]\s*key\s*=\s*\S+==\s*$/m;
} elsif ($type eq 'cephfs') {
die if $content !~ /\S+==\s*$/;
}
};
die "Not a proper $type authentication file: $filename\n" if $@;
return undef;
};
sub ceph_connect_option {
my ($scfg, $storeid, %options) = @_;
my $cmd_option = {};
my $keyfile = "/etc/pve/priv/ceph/${storeid}.keyring";
$keyfile = "/etc/pve/priv/ceph/${storeid}.secret" if ($scfg->{type} eq 'cephfs');
my $pveceph_managed = !defined($scfg->{monhost});
$cmd_option->{ceph_conf} = '/etc/pve/ceph.conf' if $pveceph_managed;
$ceph_check_keyfile->($keyfile, $scfg->{type});
if (-e "/etc/pve/priv/ceph/${storeid}.conf") {
# allow custom ceph configuration for external clusters
if ($pveceph_managed) {
warn
"ignoring custom ceph config for storage '$storeid', 'monhost' is not set (assuming pveceph managed cluster)!\n";
} else {
$cmd_option->{ceph_conf} = "/etc/pve/priv/ceph/${storeid}.conf";
}
} elsif (!$pveceph_managed) {
# No dedicated config for non-PVE-managed cluster, create new
# TODO PVE 10 - remove. All such storages already got a configuration upon creation or here.
ceph_create_configuration($scfg->{type}, $storeid);
}
$cmd_option->{keyring} = $keyfile if (-e $keyfile);
$cmd_option->{auth_supported} = (defined $cmd_option->{keyring}) ? 'cephx' : 'none';
$cmd_option->{userid} = $scfg->{username} ? $scfg->{username} : 'admin';
$cmd_option->{mon_host} = hostlist($scfg->{monhost}, ',') if (defined($scfg->{monhost}));
if (%options) {
foreach my $k (keys %options) {
$cmd_option->{$k} = $options{$k};
}
}
return $cmd_option;
}
sub ceph_create_keyfile {
my ($type, $storeid, $secret) = @_;
my $extension = 'keyring';
$extension = 'secret' if ($type eq 'cephfs');
my $ceph_admin_keyring = '/etc/pve/priv/ceph.client.admin.keyring';
my $ceph_storage_keyring = "/etc/pve/priv/ceph/${storeid}.$extension";
die "ceph authx keyring file for storage '$storeid' already exists!\n"
if -e $ceph_storage_keyring && !defined($secret);
if (-e $ceph_admin_keyring || defined($secret)) {
eval {
if (defined($secret)) {
mkdir '/etc/pve/priv/ceph';
chomp $secret;
PVE::Tools::file_set_contents($ceph_storage_keyring, "${secret}\n", 0400);
} elsif ($type eq 'rbd') {
mkdir '/etc/pve/priv/ceph';
PVE::Tools::file_copy($ceph_admin_keyring, $ceph_storage_keyring);
} elsif ($type eq 'cephfs') {
my $cephfs_secret = $ceph_get_key->($ceph_admin_keyring, 'admin');
mkdir '/etc/pve/priv/ceph';
chomp $cephfs_secret;
PVE::Tools::file_set_contents($ceph_storage_keyring, "${cephfs_secret}\n",
0400);
}
};
if (my $err = $@) {
unlink $ceph_storage_keyring;
die "failed to copy ceph authx $extension for storage '$storeid': $err\n";
}
} else {
warn "$ceph_admin_keyring not found, authentication is disabled.\n";
}
}
sub ceph_remove_keyfile {
my ($type, $storeid) = @_;
my $extension = 'keyring';
$extension = 'secret' if ($type eq 'cephfs');
my $ceph_storage_keyring = "/etc/pve/priv/ceph/${storeid}.$extension";
if (-f $ceph_storage_keyring) {
unlink($ceph_storage_keyring) or warn "removing keyring of storage failed: $!\n";
}
}
sub ceph_create_configuration {
my ($type, $storeid) = @_;
return if $type eq 'cephfs'; # no configuration file needed currently
my $extension = 'keyring';
$extension = 'secret' if $type eq 'cephfs';
my $ceph_storage_keyring = "/etc/pve/priv/ceph/${storeid}.$extension";
return if !-e $ceph_storage_keyring;
my $ceph_storage_config = "/etc/pve/priv/ceph/${storeid}.conf";
if (-e $ceph_storage_config) {
log_warn(
"file $ceph_storage_config already exists, check manually and ensure 'keyring'"
. " option is set to '$ceph_storage_keyring'!\n",
);
return;
}
my $ceph_config = {
global => {
keyring => $ceph_storage_keyring,
},
};
my $contents = PVE::CephConfig::write_ceph_config($ceph_storage_config, $ceph_config);
PVE::Tools::file_set_contents($ceph_storage_config, $contents, 0600);
return;
}
sub ceph_remove_configuration {
my ($storeid) = @_;
my $ceph_storage_config = "/etc/pve/priv/ceph/${storeid}.conf";
if (-f $ceph_storage_config) {
unlink $ceph_storage_config or log_warn("removing $ceph_storage_config failed - $!\n");
}
return;
}
my $ceph_version_parser = sub {
my $ceph_version = shift;
# FIXME this is the same as pve-manager PVE::Ceph::Tools get_local_version
if ($ceph_version =~ /^ceph.*\sv?(\d+(?:\.\d+)+(?:-pve\d+)?)\s+(?:\(([a-zA-Z0-9]+)\))?/) {
my ($version, $buildcommit) = ($1, $2);
my $subversions = [split(/\.|-/, $version)];
return ($subversions, $version, $buildcommit);
}
warn "Could not parse Ceph version: '$ceph_version'\n";
};
sub local_ceph_version {
my ($cache) = @_;
my $version_string = $cache;
if (!defined($version_string)) {
run_command(
'ceph --version',
outfunc => sub {
$version_string = shift;
},
);
}
return undef if !defined($version_string);
# subversion is an array ref. with the version parts from major to minor
# version is the filtered version string
my ($subversions, $version) = $ceph_version_parser->($version_string);
return wantarray ? ($subversions, $version) : $version;
}
1;