]> git.proxmox.com Git - pve-cluster.git/blob - data/PVE/API2/ClusterConfig.pm
c3f2725a5954d638e728253396d5260d511fc416
[pve-cluster.git] / data / PVE / API2 / ClusterConfig.pm
1 package PVE::API2::ClusterConfig;
2
3 use strict;
4 use warnings;
5
6 use PVE::Exception;
7 use PVE::Tools;
8 use PVE::SafeSyslog;
9 use PVE::RESTHandler;
10 use PVE::RPCEnvironment;
11 use PVE::JSONSchema qw(get_standard_option);
12 use PVE::Cluster;
13 use PVE::APIClient::LWP;
14 use PVE::Corosync;
15 use PVE::Cluster::Setup;
16
17 use IO::Socket::UNIX;
18
19 use base qw(PVE::RESTHandler);
20
21 my $clusterconf = "/etc/pve/corosync.conf";
22 my $authfile = "/etc/corosync/authkey";
23 my $local_cluster_lock = "/var/lock/pvecm.lock";
24
25 my $nodeid_desc = {
26 type => 'integer',
27 description => "Node id for this node.",
28 minimum => 1,
29 optional => 1,
30 };
31 PVE::JSONSchema::register_standard_option("corosync-nodeid", $nodeid_desc);
32
33 __PACKAGE__->register_method({
34 name => 'index',
35 path => '',
36 method => 'GET',
37 description => "Directory index.",
38 permissions => {
39 check => ['perm', '/', [ 'Sys.Audit' ]],
40 },
41 parameters => {
42 additionalProperties => 0,
43 properties => {},
44 },
45 returns => {
46 type => 'array',
47 items => {
48 type => "object",
49 properties => {},
50 },
51 links => [ { rel => 'child', href => "{name}" } ],
52 },
53 code => sub {
54 my ($param) = @_;
55
56 my $result = [
57 { name => 'nodes' },
58 { name => 'totem' },
59 { name => 'join' },
60 { name => 'qdevice' },
61 { name => 'apiversion' },
62 ];
63
64 return $result;
65 }});
66
67 __PACKAGE__->register_method ({
68 name => 'join_api_version',
69 path => 'apiversion',
70 method => 'GET',
71 description => "Return the version of the cluster join API available on this node.",
72 permissions => {
73 check => ['perm', '/', [ 'Sys.Audit' ]],
74 },
75 parameters => {
76 additionalProperties => 0,
77 properties => {},
78 },
79 returns => {
80 type => 'integer',
81 minimum => 0,
82 description => "Cluster Join API version, currently " . PVE::Cluster::Setup::JOIN_API_VERSION,
83 },
84 code => sub {
85 return PVE::Cluster::Setup::JOIN_API_VERSION;
86 }});
87
88 __PACKAGE__->register_method ({
89 name => 'create',
90 path => '',
91 method => 'POST',
92 protected => 1,
93 description => "Generate new cluster configuration. If no links given,"
94 . " default to local IP address as link0.",
95 parameters => {
96 additionalProperties => 0,
97 properties => PVE::Corosync::add_corosync_link_properties({
98 clustername => {
99 description => "The name of the cluster.",
100 type => 'string', format => 'pve-node',
101 maxLength => 15,
102 },
103 nodeid => get_standard_option('corosync-nodeid'),
104 votes => {
105 type => 'integer',
106 description => "Number of votes for this node.",
107 minimum => 1,
108 optional => 1,
109 },
110 }),
111 },
112 returns => { type => 'string' },
113 code => sub {
114 my ($param) = @_;
115
116 die "cluster config '$clusterconf' already exists\n" if -f $clusterconf;
117
118 my $rpcenv = PVE::RPCEnvironment::get();
119 my $authuser = $rpcenv->get_user();
120
121 my $code = sub {
122 STDOUT->autoflush();
123 PVE::Cluster::Setup::setup_sshd_config(1);
124 PVE::Cluster::Setup::setup_rootsshconfig();
125 PVE::Cluster::Setup::setup_ssh_keys();
126
127 PVE::Tools::run_command(['/usr/sbin/corosync-keygen', '-lk', $authfile])
128 if !-f $authfile;
129 die "no authentication key available\n" if -f !$authfile;
130
131 my $nodename = PVE::INotify::nodename();
132
133 # get the corosync basis config for the new cluster
134 my $config = PVE::Corosync::create_conf($nodename, $param);
135
136 print "Writing corosync config to /etc/pve/corosync.conf\n";
137 PVE::Corosync::atomic_write_conf($config);
138
139 my $local_ip_address = PVE::Cluster::remote_node_ip($nodename);
140 PVE::Cluster::Setup::ssh_merge_keys();
141 PVE::Cluster::Setup::gen_pve_node_files($nodename, $local_ip_address);
142 PVE::Cluster::Setup::ssh_merge_known_hosts($nodename, $local_ip_address, 1);
143
144 print "Restart corosync and cluster filesystem\n";
145 PVE::Tools::run_command('systemctl restart corosync pve-cluster');
146 };
147
148 my $worker = sub {
149 PVE::Tools::lock_file($local_cluster_lock, 10, $code);
150 die $@ if $@;
151 };
152
153 return $rpcenv->fork_worker('clustercreate', $param->{clustername}, $authuser, $worker);
154 }});
155
156 __PACKAGE__->register_method({
157 name => 'nodes',
158 path => 'nodes',
159 method => 'GET',
160 description => "Corosync node list.",
161 permissions => {
162 check => ['perm', '/', [ 'Sys.Audit' ]],
163 },
164 parameters => {
165 additionalProperties => 0,
166 properties => {},
167 },
168 returns => {
169 type => 'array',
170 items => {
171 type => "object",
172 properties => {
173 node => { type => 'string' },
174 },
175 },
176 links => [ { rel => 'child', href => "{node}" } ],
177 },
178 code => sub {
179 my ($param) = @_;
180
181
182 my $conf = PVE::Cluster::cfs_read_file('corosync.conf');
183 my $nodelist = PVE::Corosync::nodelist($conf);
184
185 return PVE::RESTHandler::hash_to_array($nodelist, 'node');
186 }});
187
188 # lock method to ensure local and cluster wide atomicity
189 # if we're a single node cluster just lock locally, we have no other cluster
190 # node which we could contend with, else also acquire a cluster wide lock
191 my $config_change_lock = sub {
192 my ($code) = @_;
193
194 PVE::Tools::lock_file($local_cluster_lock, 10, sub {
195 PVE::Cluster::cfs_update(1);
196 my $members = PVE::Cluster::get_members();
197 if (scalar(keys %$members) > 1) {
198 my $res = PVE::Cluster::cfs_lock_file('corosync.conf', 10, $code);
199
200 # cfs_lock_file only sets $@ but lock_file doesn't propagates $@ unless we die here
201 die $@ if defined($@);
202
203 return $res;
204 } else {
205 return $code->();
206 }
207 });
208 };
209
210 __PACKAGE__->register_method ({
211 name => 'addnode',
212 path => 'nodes/{node}',
213 method => 'POST',
214 protected => 1,
215 description => "Adds a node to the cluster configuration. This call is for internal use.",
216 parameters => {
217 additionalProperties => 0,
218 properties => PVE::Corosync::add_corosync_link_properties({
219 node => get_standard_option('pve-node'),
220 nodeid => get_standard_option('corosync-nodeid'),
221 votes => {
222 type => 'integer',
223 description => "Number of votes for this node",
224 minimum => 0,
225 optional => 1,
226 },
227 force => {
228 type => 'boolean',
229 description => "Do not throw error if node already exists.",
230 optional => 1,
231 },
232 new_node_ip => {
233 type => 'string',
234 description => "IP Address of node to add. Used as fallback if no links are given.",
235 format => 'ip',
236 optional => 1,
237 },
238 apiversion => {
239 type => 'integer',
240 description => 'The JOIN_API_VERSION of the new node.',
241 optional => 1,
242 },
243 }),
244 },
245 returns => {
246 type => "object",
247 properties => {
248 corosync_authkey => {
249 type => 'string',
250 },
251 corosync_conf => {
252 type => 'string',
253 },
254 warnings => {
255 type => 'array',
256 items => {
257 type => 'string',
258 },
259 },
260 },
261 },
262 code => sub {
263 my ($param) = @_;
264
265 $param->{apiversion} //= 0;
266 PVE::Cluster::Setup::assert_node_can_join_our_version($param->{apiversion});
267
268 PVE::Cluster::check_cfs_quorum();
269
270 my $vc_errors;
271 my $vc_warnings;
272
273 my $code = sub {
274 my $conf = PVE::Cluster::cfs_read_file("corosync.conf");
275 my $nodelist = PVE::Corosync::nodelist($conf);
276 my $totem_cfg = PVE::Corosync::totem_config($conf);
277
278 ($vc_errors, $vc_warnings) = PVE::Corosync::verify_conf($conf);
279 die if scalar(@$vc_errors);
280
281 my $name = $param->{node};
282
283 # ensure we do not reuse an address, that can crash the whole cluster!
284 my $check_duplicate_addr = sub {
285 my $link = shift;
286 return if !defined($link) || !defined($link->{address});
287 my $addr = $link->{address};
288
289 while (my ($k, $v) = each %$nodelist) {
290 next if $k eq $name; # allows re-adding a node if force is set
291
292 for my $linknumber (0..PVE::Corosync::MAX_LINK_INDEX) {
293 my $id = "ring${linknumber}_addr";
294 next if !defined($v->{$id});
295
296 die "corosync: address '$addr' already used on link $id by node '$k'\n"
297 if $v->{$id} eq $addr;
298 }
299 }
300 };
301
302 my $links = PVE::Corosync::extract_corosync_link_args($param);
303 foreach my $link (values %$links) {
304 $check_duplicate_addr->($link);
305 }
306
307 # Make sure that the newly added node has links compatible with the
308 # rest of the cluster. To start, extract all links that currently
309 # exist. Check any node, all have the same links here (because of
310 # verify_conf above).
311 my $node_options = (values %$nodelist)[0];
312 my $cluster_links = {};
313 foreach my $opt (keys %$node_options) {
314 my ($linktype, $linkid) = PVE::Corosync::parse_link_entry($opt);
315 next if !defined($linktype);
316 $cluster_links->{$linkid} = $node_options->{$opt};
317 }
318
319 # in case no fallback IP was passed, but instead only a single link,
320 # treat it as fallback to allow link-IDs to be matched automatically
321 # FIXME: remove in 8.0 or when joining an old node not supporting
322 # new_node_ip becomes infeasible otherwise
323 my $legacy_fallback = 0;
324 if (!$param->{new_node_ip} && scalar(%$links) == 1 && $param->{apiversion} == 0) {
325 my $passed_link_id = (keys %$links)[0];
326 my $passed_link = delete $links->{$passed_link_id};
327 $param->{new_node_ip} = $passed_link->{address};
328 $legacy_fallback = 1;
329 }
330
331 if (scalar(%$links)) {
332 # verify specified links exist and none are missing
333 for my $linknum (0..PVE::Corosync::MAX_LINK_INDEX) {
334 my $have_cluster_link = defined($cluster_links->{$linknum});
335 my $have_new_link = defined($links->{$linknum});
336
337 die "corosync: link $linknum exists in cluster config but wasn't specified for new node\n"
338 if $have_cluster_link && !$have_new_link;
339 die "corosync: link $linknum specified for new node doesn't exist in cluster config\n"
340 if !$have_cluster_link && $have_new_link;
341 }
342 } else {
343 # when called without any link parameters, fall back to
344 # new_node_ip, if all existing nodes only have a single link too
345 die "no links and no fallback ip (new_node_ip) given, cannot join cluster\n"
346 if !$param->{new_node_ip};
347
348 my $cluster_link_count = scalar(%$cluster_links);
349 if ($cluster_link_count == 1) {
350 my $linknum = (keys %$cluster_links)[0];
351 $links->{$linknum} = { address => $param->{new_node_ip} };
352 } else {
353 die "cluster has $cluster_link_count links, but only 1 given"
354 if $legacy_fallback;
355 die "no links given but multiple links found on other nodes,"
356 . " fallback only supported on single-link clusters\n";
357 }
358 }
359
360 if (defined(my $res = $nodelist->{$name})) {
361 $param->{nodeid} = $res->{nodeid} if !$param->{nodeid};
362 $param->{votes} = $res->{quorum_votes} if !defined($param->{votes});
363
364 if ($res->{quorum_votes} == $param->{votes} &&
365 $res->{nodeid} == $param->{nodeid} && $param->{force}) {
366 print "forcing overwrite of configured node '$name'\n";
367 } else {
368 die "can't add existing node '$name'\n";
369 }
370 } elsif (!$param->{nodeid}) {
371 my $nodeid = 1;
372
373 while(1) {
374 my $found = 0;
375 foreach my $v (values %$nodelist) {
376 if ($v->{nodeid} eq $nodeid) {
377 $found = 1;
378 $nodeid++;
379 last;
380 }
381 }
382 last if !$found;
383 };
384
385 $param->{nodeid} = $nodeid;
386 }
387
388 $param->{votes} = 1 if !defined($param->{votes});
389
390 PVE::Cluster::Setup::gen_local_dirs($name);
391
392 eval { PVE::Cluster::Setup::ssh_merge_keys(); };
393 warn $@ if $@;
394
395 $nodelist->{$name} = {
396 nodeid => $param->{nodeid},
397 name => $name,
398 };
399 $nodelist->{$name}->{quorum_votes} = $param->{votes} if $param->{votes};
400
401 foreach my $link (keys %$links) {
402 $nodelist->{$name}->{"ring${link}_addr"} = $links->{$link}->{address};
403 }
404
405 PVE::Cluster::log_msg('notice', 'root@pam', "adding node $name to cluster");
406
407 PVE::Corosync::update_nodelist($conf, $nodelist);
408 };
409
410 $config_change_lock->($code);
411
412 # If vc_errors is set, we died because of verify_conf.
413 # Raise this error, since it contains more information than just a
414 # single-line string.
415 if (defined($vc_errors) && scalar(@$vc_errors)) {
416 my $err_hash = {};
417 my $add_errs = sub {
418 my $type = shift;
419 my @arr = @_;
420 return if !scalar(@arr);
421
422 my %newhash = map { $type . $_ => $arr[$_] } 0..$#arr;
423 $err_hash = {
424 %$err_hash,
425 %newhash,
426 };
427 };
428
429 $add_errs->("warning", @$vc_warnings);
430 $add_errs->("error", @$vc_errors);
431
432 PVE::Exception::raise("invalid corosync.conf\n", errors => $err_hash);
433 }
434
435 die $@ if $@;
436
437 my $res = {
438 corosync_authkey => PVE::Tools::file_get_contents($authfile),
439 corosync_conf => PVE::Tools::file_get_contents($clusterconf),
440 warnings => $vc_warnings,
441 };
442
443 return $res;
444 }});
445
446
447 __PACKAGE__->register_method ({
448 name => 'delnode',
449 path => 'nodes/{node}',
450 method => 'DELETE',
451 protected => 1,
452 description => "Removes a node from the cluster configuration.",
453 parameters => {
454 additionalProperties => 0,
455 properties => {
456 node => get_standard_option('pve-node'),
457 },
458 },
459 returns => { type => 'null' },
460 code => sub {
461 my ($param) = @_;
462
463 my $local_node = PVE::INotify::nodename();
464 die "Cannot delete myself from cluster!\n" if $param->{node} eq $local_node;
465
466 PVE::Cluster::check_cfs_quorum();
467
468 my $code = sub {
469 my $conf = PVE::Cluster::cfs_read_file("corosync.conf");
470 my $nodelist = PVE::Corosync::nodelist($conf);
471
472 my $node;
473 my $nodeid;
474
475 foreach my $tmp_node (keys %$nodelist) {
476 my $d = $nodelist->{$tmp_node};
477 my $ring0_addr = $d->{ring0_addr};
478 my $ring1_addr = $d->{ring1_addr};
479 if (($tmp_node eq $param->{node}) ||
480 (defined($ring0_addr) && ($ring0_addr eq $param->{node})) ||
481 (defined($ring1_addr) && ($ring1_addr eq $param->{node}))) {
482 $node = $tmp_node;
483 $nodeid = $d->{nodeid};
484 last;
485 }
486 }
487
488 die "Node/IP: $param->{node} is not a known host of the cluster.\n"
489 if !defined($node);
490
491 PVE::Cluster::log_msg('notice', 'root@pam', "deleting node $node from cluster");
492
493 delete $nodelist->{$node};
494
495 PVE::Corosync::update_nodelist($conf, $nodelist);
496
497 PVE::Tools::run_command(['corosync-cfgtool','-k', $nodeid]) if defined($nodeid);
498 };
499
500 $config_change_lock->($code);
501 die $@ if $@;
502
503 return undef;
504 }});
505
506 __PACKAGE__->register_method ({
507 name => 'join_info',
508 path => 'join',
509 permissions => {
510 check => ['perm', '/', [ 'Sys.Audit' ]],
511 },
512 method => 'GET',
513 description => "Get information needed to join this cluster over the connected node.",
514 parameters => {
515 additionalProperties => 0,
516 properties => {
517 node => get_standard_option('pve-node', {
518 description => "The node for which the joinee gets the nodeinfo. ",
519 default => "current connected node",
520 optional => 1,
521 }),
522 },
523 },
524 returns => {
525 type => 'object',
526 additionalProperties => 0,
527 properties => {
528 nodelist => {
529 type => 'array',
530 items => {
531 type => "object",
532 additionalProperties => 1,
533 properties => {
534 name => get_standard_option('pve-node'),
535 nodeid => get_standard_option('corosync-nodeid'),
536 ring0_addr => get_standard_option('corosync-link'),
537 quorum_votes => { type => 'integer', minimum => 0 },
538 pve_addr => { type => 'string', format => 'ip' },
539 pve_fp => get_standard_option('fingerprint-sha256'),
540 },
541 },
542 },
543 preferred_node => get_standard_option('pve-node'),
544 totem => { type => 'object' },
545 config_digest => { type => 'string' },
546 },
547 },
548 code => sub {
549 my ($param) = @_;
550
551 my $nodename = $param->{node} // PVE::INotify::nodename();
552
553 PVE::Cluster::cfs_update(1);
554 my $conf = PVE::Cluster::cfs_read_file('corosync.conf');
555
556 die "node is not in a cluster, no join info available!\n"
557 if !($conf && $conf->{main});
558
559 my $totem_cfg = $conf->{main}->{totem} // {};
560 my $nodelist = $conf->{main}->{nodelist}->{node} // {};
561 my $corosync_config_digest = $conf->{digest};
562
563 die "unknown node '$nodename'\n" if ! $nodelist->{$nodename};
564
565 foreach my $name (keys %$nodelist) {
566 my $node = $nodelist->{$name};
567 $node->{pve_fp} = PVE::Cluster::get_node_fingerprint($name);
568 $node->{pve_addr} = scalar(PVE::Cluster::remote_node_ip($name));
569 }
570
571 my $res = {
572 nodelist => [ values %$nodelist ],
573 preferred_node => $nodename,
574 totem => $totem_cfg,
575 config_digest => $corosync_config_digest,
576 };
577
578 return $res;
579 }});
580
581 __PACKAGE__->register_method ({
582 name => 'join',
583 path => 'join',
584 method => 'POST',
585 protected => 1,
586 description => "Joins this node into an existing cluster. If no links are"
587 . " given, default to IP resolved by node's hostname on single"
588 . " link (fallback fails for clusters with multiple links).",
589 parameters => {
590 additionalProperties => 0,
591 properties => PVE::Corosync::add_corosync_link_properties({
592 hostname => {
593 type => 'string',
594 description => "Hostname (or IP) of an existing cluster member."
595 },
596 nodeid => get_standard_option('corosync-nodeid'),
597 votes => {
598 type => 'integer',
599 description => "Number of votes for this node",
600 minimum => 0,
601 optional => 1,
602 },
603 force => {
604 type => 'boolean',
605 description => "Do not throw error if node already exists.",
606 optional => 1,
607 },
608 fingerprint => get_standard_option('fingerprint-sha256'),
609 password => {
610 description => "Superuser (root) password of peer node.",
611 type => 'string',
612 maxLength => 128,
613 },
614 }),
615 },
616 returns => { type => 'string' },
617 code => sub {
618 my ($param) = @_;
619
620 my $rpcenv = PVE::RPCEnvironment::get();
621 my $authuser = $rpcenv->get_user();
622
623 my $worker = sub {
624 STDOUT->autoflush();
625 PVE::Tools::lock_file($local_cluster_lock, 10, \&PVE::Cluster::Setup::join, $param);
626 die $@ if $@;
627 };
628
629 return $rpcenv->fork_worker('clusterjoin', undef, $authuser, $worker);
630 }});
631
632
633 __PACKAGE__->register_method({
634 name => 'totem',
635 path => 'totem',
636 method => 'GET',
637 description => "Get corosync totem protocol settings.",
638 permissions => {
639 check => ['perm', '/', [ 'Sys.Audit' ]],
640 },
641 parameters => {
642 additionalProperties => 0,
643 properties => {},
644 },
645 returns => {
646 type => "object",
647 properties => {},
648 },
649 code => sub {
650 my ($param) = @_;
651
652
653 my $conf = PVE::Cluster::cfs_read_file('corosync.conf');
654
655 my $totem_cfg = $conf->{main}->{totem};
656
657 return $totem_cfg;
658 }});
659
660 __PACKAGE__->register_method ({
661 name => 'status',
662 path => 'qdevice',
663 method => 'GET',
664 description => 'Get QDevice status',
665 permissions => {
666 check => ['perm', '/', [ 'Sys.Audit' ]],
667 },
668 parameters => {
669 additionalProperties => 0,
670 properties => {},
671 },
672 returns => {
673 type => "object",
674 },
675 code => sub {
676 my ($param) = @_;
677
678 my $result = {};
679 my $socket_path = "/var/run/corosync-qdevice/corosync-qdevice.sock";
680 return $result if !-S $socket_path;
681
682 my $qdevice_socket = IO::Socket::UNIX->new(
683 Type => SOCK_STREAM,
684 Peer => $socket_path,
685 );
686
687 print $qdevice_socket "status verbose\n";
688 my $qdevice_keys = {
689 "Algorithm" => 1,
690 "Echo reply" => 1,
691 "Last poll call" => 1,
692 "Model" => 1,
693 "QNetd host" => 1,
694 "State" => 1,
695 "Tie-breaker" => 1,
696 };
697 while (my $line = <$qdevice_socket>) {
698 chomp $line;
699 next if $line =~ /^\s/;
700 if ($line =~ /^(.*?)\s*:\s*(.*)$/) {
701 $result->{$1} = $2 if $qdevice_keys->{$1};
702 }
703 }
704
705 return $result;
706 }});
707 #TODO: possibly add setup and remove methods
708
709
710 1;