all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Kefu Chai <k.chai@proxmox.com>
To: pve-devel@lists.proxmox.com
Cc: Thomas Lamprecht <t.lamprecht@proxmox.com>,
	Jonas Theisen <j.theisen@proxmox.com>
Subject: [PATCH manager v2 1/4] migrations: cephx: ask a monitor about itself through one route
Date: Mon, 19 Oct 2026 16:23:43 +0800	[thread overview]
Message-ID: <20261019082346.1781960-2-k.chai@proxmox.com> (raw)
In-Reply-To: <20261019082346.1781960-1-k.chai@proxmox.com>

The helper queries each monitor over its admin socket, which needs a
shell on the monitor's host. For a monitor on a host outside this
cluster, such as a stretch cluster's tiebreaker, these queries fail. The
helper then treats the monitor as unreachable, refuses to stage client
keys, and reports the session list as incomplete.

Record whether a daemon runs on one of our nodes. For a monitor that
does not, use 'ceph tell' as 'mon.' instead, with a short timeout.

'ceph tell' shows up in the monitor's own session list as a client named
'mon.'. Ignore 'mon.' sessions, since 'mon.' is the monitors' own key
and not a client key to refresh. Otherwise, if 'mon.' is not in the auth
database, the run would stop on a client without an auth entry.

Signed-off-by: Kefu Chai <k.chai@proxmox.com>
---
 PVE/Ceph/KeyMigration.pm            | 21 ++++----
 bin/pve-cephx-rotate-service-keys   | 75 ++++++++++++++---------------
 test/CephKeyMigrationScript_test.pl | 61 +++++++++++++++++++++--
 test/CephKeyMigration_test.pl       | 12 +++++
 4 files changed, 117 insertions(+), 52 deletions(-)

diff --git a/PVE/Ceph/KeyMigration.pm b/PVE/Ceph/KeyMigration.pm
index d0613e8b..c24c1c3b 100644
--- a/PVE/Ceph/KeyMigration.pm
+++ b/PVE/Ceph/KeyMigration.pm
@@ -561,6 +561,8 @@ sub summarize_sessions($per_mon, $expected_mons = undef) {
                 next;
             }
             next if $session->{con_type} ne 'client';
+            # skip the monitors' own key, which a 'ceph tell' sent as 'mon.' also shows up as
+            next if ($session->{entity_name} // '') eq 'mon.';
 
             my $gid = $session->{global_id};
             my $entity = $session->{entity_name};
@@ -1101,15 +1103,16 @@ sub merge_configured_daemons($daemons, $type, $configured, $existing = undef) {
             push @$ghosts, { type => $type, id => "$id", node => $configured->{$id} };
             next;
         }
-        push @$daemons,
-            {
-                type => $type,
-                id => "$id",
-                entity => $type eq 'mon' ? 'mon.' : "$type.$id",
-                node => $configured->{$id},
-                down => 1,
-                $type eq 'osd' && $existing ? ('osd-uuid' => $existing->{$id}) : (),
-            };
+        push @$daemons, {
+            type => $type,
+            id => "$id",
+            entity => $type eq 'mon' ? 'mon.' : "$type.$id",
+            node => $configured->{$id},
+            # pvestatd only reports daemons on nodes of this cluster
+            managed => 1,
+            down => 1,
+            $type eq 'osd' && $existing ? ('osd-uuid' => $existing->{$id}) : (),
+        };
     }
 
     return wantarray ? ($daemons, $ghosts) : $daemons;
diff --git a/bin/pve-cephx-rotate-service-keys b/bin/pve-cephx-rotate-service-keys
index ad425f96..84daff6d 100755
--- a/bin/pve-cephx-rotate-service-keys
+++ b/bin/pve-cephx-rotate-service-keys
@@ -175,11 +175,18 @@ my sub cluster_nodes() {
     return { map { $_ => 1 } PVE::Cluster::get_nodelist()->@* };
 }
 
+# 'managed': the host is a node of this cluster, so we can reach its files, units and packages.
+# Other daemons, like the tiebreaker monitor of a stretch cluster, are reachable only via Ceph.
 my sub daemon_location($type, $id, $host) {
-    return { node => undef } if !defined($host) || (!ref($host) && !length($host));
+    return { node => undef, managed => 0 } if !defined($host) || (!ref($host) && !length($host));
     die "Cannot locate '$type.$id': no valid host name was reported\n" if ref($host);
-    my $node = PVE::Ceph::Services::metadata_host_node($host, cluster_nodes());
-    return { node => $node, $node ne $host ? ('metadata-host' => $host) : () };
+    my $nodes = cluster_nodes();
+    my $node = PVE::Ceph::Services::metadata_host_node($host, $nodes);
+    return {
+        node => $node,
+        managed => $nodes->{$node} ? 1 : 0,
+        $node ne $host ? ('metadata-host' => $host) : (),
+    };
 }
 
 my sub assert_member($node, $entity) {
@@ -559,13 +566,14 @@ my sub auth_entry($rados, $entity) {
 }
 
 # the monitor identity keeps working while a client.admin rotation invalidates the default keyring
-my sub monitor_command($args) {
+my sub monitor_command($args, $timeout = undef) {
     return node_run(
         $nodename,
         [
             'ceph', '--cluster', $ccname, '--name', 'mon.', '--keyring', $pve_mon_keyring,
             @$args,
         ],
+        defined($timeout) ? (timeout => $timeout) : (),
     );
 }
 
@@ -579,19 +587,26 @@ my sub monitor_auth_entry($entity) {
     return $res->[0];
 }
 
+# Ask a monitor about itself over its admin socket, which works even when cephx does not. For a
+# monitor outside this cluster, use 'ceph tell' to reach the same socket. That needs cephx and a
+# quorum. It runs as 'mon.', so it still works while 'client.admin' is rotated.
+my sub mon_admin_run($node, $id, $words, $run_node = undef) {
+    if (defined($node) && cluster_nodes()->{$node}) {
+        my $cmd = ['ceph', 'daemon', "mon.$id", @$words];
+        return $run_node ? $run_node->($node, $cmd) : node_run($node, $cmd, entity => "mon.$id");
+    }
+
+    # do not let an unreachable monitor hold the run for the 300 second default
+    return monitor_command(['--connect-timeout', '10', 'tell', "mon.$id", @$words], 20);
+}
+
 my sub poll_monitor_authentication($monitors, $nodes, $run_node = undef) {
-    $run_node //= sub($node, $command) { node_run($node, $command, entity => $command->[2]) };
     my ($reports, $errors) = ({}, {});
     for my $mon (@$monitors) {
         my $option = 'auth_service_required';
         my $reply = eval {
-            assert_member($nodes->{$mon}, "mon.$mon");
-            decode_json(
-                $run_node->(
-                    $nodes->{$mon},
-                    ['ceph', 'daemon', "mon.$mon", 'config', 'get', $option],
-                ),
-            );
+            decode_json(mon_admin_run(
+                $nodes->{$mon}, $mon, ['config', 'get', $option], $run_node));
         };
         my $error = $@;
         $reports->{$mon}->{$option} = ref($reply) eq 'HASH' ? $reply->{$option} : undef;
@@ -789,20 +804,12 @@ for my $fsid (@fsids) {
 }
 PERL
 
-# Every monitor is asked over its admin socket, which needs no cephx and answers even while the
-# authentication layer itself is in trouble. One that does not answer marks the result incomplete,
-# so the checks built on it stay honest.
+# A monitor that does not answer marks the result incomplete.
 my sub poll_client_sessions($info) {
     my $per_mon = [];
     for my $mon ($info->{daemons}->{mon}->@*) {
         next if $mon->{down};
-        my $sessions = eval {
-            decode_json(node_run(
-                $mon->{node},
-                ['ceph', 'daemon', "mon.$mon->{id}", 'sessions'],
-                entity => "mon.$mon->{id}",
-            ));
-        };
+        my $sessions = eval { decode_json(mon_admin_run($mon->{node}, $mon->{id}, ['sessions'])) };
         push @$per_mon, { mon => $mon->{id}, sessions => $sessions };
     }
     return summarize_sessions($per_mon, $info->{monmap_mons});
@@ -811,15 +818,14 @@ my sub poll_client_sessions($info) {
 # Whether a monitor can keep two client keys valid is told by its admin socket, which needs no
 # cephx. One that answers but does not report the option is old; one that does not answer at all
 # cannot be told apart from a broken connection, and rules staging out just the same.
-my sub probe_manual_promotion($node, $id, $run_node) {
-    my $out =
-        eval { $run_node->($node, ['ceph', 'daemon', "mon.$id", 'config', 'get', $GRACE_OPTION]) };
+my sub probe_manual_promotion($node, $id, $run_node = undef) {
+    my $out = eval { mon_admin_run($node, $id, ['config', 'get', $GRACE_OPTION], $run_node) };
     if (!$@) {
         my $res = eval { decode_json($out) };
         my $value = ref($res) eq 'HASH' ? $res->{$GRACE_OPTION} : undef;
         return { reached => 1, value => defined($value) && !ref($value) ? "$value" : undef };
     }
-    my $reached = eval { $run_node->($node, ['ceph', 'daemon', "mon.$id", 'version']); 1 } ? 1 : 0;
+    my $reached = eval { mon_admin_run($node, $id, ['version'], $run_node); 1 } ? 1 : 0;
     return { reached => $reached, value => undef };
 }
 
@@ -827,11 +833,7 @@ my sub poll_manual_promotion($info) {
     my $reports = {};
     for my $mon ($info->{daemons}->{mon}->@*) {
         next if $mon->{down};
-        $reports->{ $mon->{id} } = probe_manual_promotion(
-            $mon->{node},
-            $mon->{id},
-            sub($node, $cmd) { node_run($node, $cmd, entity => "mon.$mon->{id}") },
-        );
+        $reports->{ $mon->{id} } = probe_manual_promotion($mon->{node}, $mon->{id});
     }
     return manual_promotion_support($reports, $info->{monmap_mons});
 }
@@ -882,8 +884,6 @@ my sub session_snapshot_with_retries($collect, $attempts = 10) {
 # metadata, missing quorum membership, and a failed admin-socket query therefore make the summary
 # incomplete rather than making that monitor disappear from it.
 my sub collect_current_monitor_state($rados, $run_node = undef) {
-    $run_node //= sub($node, $command) { node_run($node, $command) };
-
     my $mon_dump = $rados->mon_command({ prefix => 'mon dump', format => 'json' });
     die "could not refresh the monitor map\n"
         if ref($mon_dump) ne 'HASH'
@@ -936,13 +936,9 @@ my sub collect_current_monitor_state($rados, $run_node = undef) {
             $errors->{$id} = 'not in quorum';
         } elsif (!$nodes->{$id}) {
             $errors->{$id} = 'no hostname in monitor metadata';
-        } elsif (!eval { assert_member($nodes->{$id}, "mon.$id"); 1 }) {
-            $errors->{$id} = $@;
         } else {
-            $sessions = eval {
-                decode_json($run_node->(
-                    $nodes->{$id}, ['ceph', 'daemon', "mon.$id", 'sessions']));
-            };
+            $sessions =
+                eval { decode_json(mon_admin_run($nodes->{$id}, $id, ['sessions'], $run_node)) };
             my $error = $@;
             if ($error) {
                 $error =~ s/\s+/ /g;
@@ -6258,6 +6254,7 @@ sub key_migration_test_hooks {
         client_key_files => \&client_key_files,
         check_client_kernels => \&check_client_kernels,
         manual_promotion_with_retries => \&manual_promotion_with_retries,
+        mon_admin_run => \&mon_admin_run,
         describe_live => \&describe_live,
     };
 }
diff --git a/test/CephKeyMigrationScript_test.pl b/test/CephKeyMigrationScript_test.pl
index ef13d8dd..fc9811d7 100755
--- a/test/CephKeyMigrationScript_test.pl
+++ b/test/CephKeyMigrationScript_test.pl
@@ -6600,7 +6600,16 @@ for my $case ([0, 0], [1, 0], [0, 1], [1, 1]) {
     $rados->{host} = 'foreign.invalid';
     @commands = ();
     $info = $HOOKS->{collect_cluster_info}->($rados, { 'rotate-lockbox-keys' => 1 }, {});
-    is_deeply(\@commands, [], 'non-member gets no commands during pre-preflight collection');
+    is(
+        scalar(grep { /foreign\.invalid/ } map { join(' ', @$_) } @commands),
+        0,
+        'pre-preflight collection sends nothing to the non-member host',
+    );
+    is(
+        scalar(grep { /tell mon\.a config get/ } map { join(' ', @$_) } @commands),
+        1,
+        'the monitor outside the cluster is asked with tell instead of its socket',
+    );
     like(
         $info->{lockbox}->{'client.osd-lockbox.osd-uuid'}->{missing},
         qr/osd\.7.*foreign\.invalid.*not a node/,
@@ -6609,10 +6618,14 @@ for my $case ([0, 0], [1, 0], [0, 1], [1, 1]) {
     @targets = ();
     $monitor = $HOOKS->{collect_monitor_state}->($rados, sub { push @targets, $_[0]; return '[]' });
     is_deeply(\@targets, [], 'fresh monitor collection never queries a non-member');
-    like(
+    is(
         $monitor->{sessions}->{errors}->{a},
-        qr/mon\.a.*not a node/,
-        'monitor diagnostic names membership',
+        undef,
+        'and its sessions come over the network, so nothing is left unanswered',
+    );
+    ok(
+        $monitor->{manual_promotion}->{supported},
+        'so a client key can still be staged with a monitor outside the cluster',
     );
 
     for my $version (1, 2) {
@@ -7842,6 +7855,46 @@ for my $case ([0, 0], [1, 0], [0, 1], [1, 1]) {
     );
 }
 
+# A monitor on one of our nodes is asked over its admin socket, any other one with 'ceph tell'.
+{
+    no warnings qw(once redefine);
+    local *PVE::SSHInfo::get_ssh_info = sub { return { node => $_[0] } };
+    local *PVE::SSHInfo::ssh_info_to_command = sub { return ['ssh', $_[0]->{node}, '--'] };
+
+    my @commands;
+    local *main::run_command = sub {
+        my ($cmd, %args) = @_;
+        push @commands, join(' ', @$cmd);
+        $args{outfunc}->('answer');
+    };
+
+    my $run = sub {
+        @commands = ();
+        return $HOOKS->{mon_admin_run}->(@_);
+    };
+
+    is($run->('node-a', 'a', ['sessions']), "answer\n", 'a member answers over its admin socket');
+    is($commands[0], 'ssh node-a -- ceph daemon mon.a sessions', 'which is asked through ssh');
+
+    is($run->('witness.invalid', 'tiebreaker', ['sessions']), "answer\n", 'so does an outsider');
+    like(
+        $commands[0],
+        qr/^ceph --cluster \S+ --name mon\. --keyring \S+ --connect-timeout 10 tell mon\.tiebreaker sessions$/,
+        'asked locally with tell, as mon., and with a connect timeout instead of a long default',
+    );
+
+    $run->(undef, 'nameless', ['version']);
+    like($commands[0], qr/tell mon\.nameless version/, 'a monitor without a known host too');
+
+    my @routed;
+    $run->('node-a', 'a', ['config', 'get', 'opt'], sub { push @routed, $_[1]; return '{}' });
+    is_deeply(
+        \@routed,
+        [['ceph', 'daemon', 'mon.a', 'config', 'get', 'opt']],
+        'a caller-supplied runner keeps the member route, which the tests rely on',
+    );
+}
+
 {
     my $out = '';
     {
diff --git a/test/CephKeyMigration_test.pl b/test/CephKeyMigration_test.pl
index 79fccedf..d22ac24b 100755
--- a/test/CephKeyMigration_test.pl
+++ b/test/CephKeyMigration_test.pl
@@ -771,6 +771,18 @@ is(
         sessions => [{ con_type => 'osd', entity_name => '', global_id => [] }],
     }]);
     ok($non_client->{complete}, 'a valid non-client session remains irrelevant');
+
+    my $own_tell = summarize_sessions([{
+        mon => 'a',
+        sessions => [{
+            con_type => 'client',
+            entity_name => 'mon.',
+            global_id => 140,
+            socket_addr => { addr => '10.0.0.3:0' },
+        }],
+    }]);
+    ok($own_tell->{complete}, "a 'mon.' session leaves the picture complete");
+    is_deeply($own_tell->{clients}, {}, "a 'mon.' session is no client of a key to refresh");
 }
 
 # --- the final step is offered once nothing else is open ---------------------------------------
-- 
2.47.3





  reply	other threads:[~2026-10-19  8:23 UTC|newest]

Thread overview: 7+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-19  8:23 [PATCH manager v2 0/4] cephx: migrate keys with a monitor this cluster does not manage Kefu Chai
2026-10-19  8:23 ` Kefu Chai [this message]
2026-10-19  8:23 ` [PATCH manager v2 2/4] migrations: cephx: judge cipher support by the version a daemon runs Kefu Chai
2026-10-19  8:23 ` [PATCH manager v2 3/4] migrations: cephx: rotate the monitor key with monitors we do not manage Kefu Chai
2026-10-19  8:23 ` [PATCH manager v2 4/4] migrations: cephx: name the hosts keeping key copies we cannot write Kefu Chai
2026-10-19 14:47 ` [PATCH manager v2 0/4] cephx: migrate keys with a monitor this cluster does not manage Jonas Theisen
2026-10-19 18:23 ` applied: " Thomas Lamprecht

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261019082346.1781960-2-k.chai@proxmox.com \
    --to=k.chai@proxmox.com \
    --cc=j.theisen@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    --cc=t.lamprecht@proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal