]> git.proxmox.com Git - pve-cluster.git/blob - data/PVE/CLI/pvecm.pm
improve RRP support and use 'name' subkey as default
[pve-cluster.git] / data / PVE / CLI / pvecm.pm
1 package PVE::CLI::pvecm;
2
3 use strict;
4 use warnings;
5 use Getopt::Long;
6 use Socket;
7 use IO::File;
8 use Net::IP;
9 use File::Path;
10 use File::Basename;
11 use Data::Dumper; # fixme: remove
12 use PVE::Tools;
13 use PVE::Cluster;
14 use PVE::INotify;
15 use PVE::JSONSchema;
16 use PVE::RPCEnvironment;
17 use PVE::CLIHandler;
18
19 use base qw(PVE::CLIHandler);
20
21 $ENV{HOME} = '/root'; # for ssh-copy-id
22
23 my $basedir = "/etc/pve";
24 my $clusterconf = "$basedir/corosync.conf";
25 my $libdir = "/var/lib/pve-cluster";
26 my $backupdir = "/var/lib/pve-cluster/backup";
27 my $dbfile = "$libdir/config.db";
28 my $authfile = "/etc/corosync/authkey";
29
30 sub backup_database {
31
32 print "backup old database\n";
33
34 mkdir $backupdir;
35
36 my $ctime = time();
37 my $cmd = "echo '.dump' |";
38 $cmd .= "sqlite3 '$dbfile' |";
39 $cmd .= "gzip - >'${backupdir}/config-${ctime}.sql.gz'";
40
41 system($cmd) == 0 ||
42 die "can't backup old database: $!\n";
43
44 # purge older backup
45 my $maxfiles = 10;
46
47 my @bklist = ();
48 foreach my $fn (<$backupdir/config-*.sql.gz>) {
49 if ($fn =~ m!/config-(\d+)\.sql.gz$!) {
50 push @bklist, [$fn, $1];
51 }
52 }
53
54 @bklist = sort { $b->[1] <=> $a->[1] } @bklist;
55
56 while (scalar (@bklist) >= $maxfiles) {
57 my $d = pop @bklist;
58 print "delete old backup '$d->[0]'\n";
59 unlink $d->[0];
60 }
61 }
62
63 __PACKAGE__->register_method ({
64 name => 'keygen',
65 path => 'keygen',
66 method => 'PUT',
67 description => "Generate new cryptographic key for corosync.",
68 parameters => {
69 additionalProperties => 0,
70 properties => {
71 filename => {
72 type => 'string',
73 description => "Output file name"
74 }
75 },
76 },
77 returns => { type => 'null' },
78
79 code => sub {
80 my ($param) = @_;
81
82 my $filename = $param->{filename};
83
84 # test EUID
85 $> == 0 || die "Error: Authorization key must be generated as root user.\n";
86 my $dirname = dirname($filename);
87 my $basename = basename($filename);
88
89 die "key file '$filename' already exists\n" if -e $filename;
90
91 File::Path::make_path($dirname) if $dirname;
92
93 my $cmd = ['corosync-keygen', '-l', '-k', $filename];
94 PVE::Tools::run_command($cmd);
95
96 return undef;
97 }});
98
99 __PACKAGE__->register_method ({
100 name => 'create',
101 path => 'create',
102 method => 'PUT',
103 description => "Generate new cluster configuration.",
104 parameters => {
105 additionalProperties => 0,
106 properties => {
107 clustername => {
108 description => "The name of the cluster.",
109 type => 'string', format => 'pve-node',
110 maxLength => 15,
111 },
112 nodeid => {
113 type => 'integer',
114 description => "Node id for this node.",
115 minimum => 1,
116 optional => 1,
117 },
118 votes => {
119 type => 'integer',
120 description => "Number of votes for this node.",
121 minimum => 1,
122 optional => 1,
123 },
124 bindnet0_addr => {
125 type => 'string', format => 'ip',
126 description => "This specifies the network address the corosync ring 0".
127 " executive should bind to and defaults to the local IP address of the node.",
128 optional => 1,
129 },
130 ring0_addr => {
131 type => 'string', format => 'address',
132 description => "Hostname (or IP) of the corosync ring0 address of this node.".
133 " Defaults to the hostname of the node.",
134 optional => 1,
135 },
136 rrp_mode => {
137 type => 'string',
138 enum => ['none', 'active', 'passive'],
139 description => "This specifies the mode of redundant ring, which" .
140 " may be none, active or passive. Using multiple interfaces".
141 " only allows 'active' or 'passive'.",
142 default => 'none',
143 optional => 1,
144 },
145 bindnet1_addr => {
146 type => 'string', format => 'ip',
147 description => "This specifies the network address the corosync ring 1".
148 " executive should bind to and is optional.",
149 optional => 1,
150 },
151 ring1_addr => {
152 type => 'string', format => 'address',
153 description => "Hostname (or IP) of the corosync ring1 address, this".
154 " needs an valid bindnet1_addr.",
155 optional => 1,
156 },
157 },
158 },
159 returns => { type => 'null' },
160
161 code => sub {
162 my ($param) = @_;
163
164 -f $clusterconf && die "cluster config '$clusterconf' already exists\n";
165
166 PVE::Cluster::setup_sshd_config();
167 PVE::Cluster::setup_rootsshconfig();
168 PVE::Cluster::setup_ssh_keys();
169
170 -f $authfile || __PACKAGE__->keygen({filename => $authfile});
171
172 -f $authfile || die "no authentication key available\n";
173
174 my $clustername = $param->{clustername};
175
176 $param->{nodeid} = 1 if !$param->{nodeid};
177
178 $param->{votes} = 1 if !defined($param->{votes});
179
180 my $nodename = PVE::INotify::nodename();
181
182 my $local_ip_address = PVE::Cluster::remote_node_ip($nodename);
183
184 $param->{bindnet0_addr} = $local_ip_address
185 if !defined($param->{bindnet0_addr});
186
187 $param->{ring0_addr} = $nodename if !defined($param->{ring0_addr});
188
189 die "Param bindnet1_addr and ring1_addr are dependend, use both or none!\n"
190 if (defined($param->{bindnet1_addr}) != defined($param->{ring1_addr}));
191
192 my $bind_is_ipv6 = Net::IP::ip_is_ipv6($param->{bindnet0_addr});
193
194 # use string as here-doc format distracts more
195 my $interfaces = "interface {\n ringnumber: 0\n" .
196 " bindnetaddr: $param->{bindnet0_addr}\n }";
197
198 my $ring_addresses = "ring0_addr: $param->{ring0_addr}" ;
199
200 # allow use of multiple rings (rrp) at cluster creation time
201 if ($param->{bindnet1_addr}) {
202 die "IPv6 and IPv4 cannot be mixed, use one or the other!\n"
203 if Net::IP::ip_is_ipv6($param->{bindnet1_addr}) != $bind_is_ipv6;
204
205 die "rrp_mode 'none' is not allowed when using multiple interfaces,".
206 " use 'active' or 'passive'!\n"
207 if !$param->{rrp_mode} || $param->{rrp_mode} eq 'none';
208
209 $interfaces .= "\n interface {\n ringnumber: 1\n" .
210 " bindnetaddr: $param->{bindnet1_addr}\n }\n";
211
212 $ring_addresses .= "\n ring1_addr: $param->{ring1_addr}";
213
214 } elsif($param->{rrp_mode} && $param->{rrp_mode} ne 'none') {
215
216 warn "rrp_mode '$param->{rrp_mode}' useless when using only one".
217 " ring, using 'none' instead";
218 # corosync defaults to none if only one interface is configured
219 $param->{rrp_mode} = undef;
220
221 }
222
223 $interfaces = "rrp_mode: $param->{rrp_mode}\n " . $interfaces
224 if $param->{rrp_mode};
225
226 # No, corosync cannot deduce this on its own
227 my $ipversion = $bind_is_ipv6 ? 'ipv6' : 'ipv4';
228
229 my $config = <<_EOD;
230 totem {
231 version: 2
232 secauth: on
233 cluster_name: $clustername
234 config_version: 1
235 ip_version: $ipversion
236 $interfaces
237 }
238
239 nodelist {
240 node {
241 $ring_addresses
242 name: $nodename
243 nodeid: $param->{nodeid}
244 quorum_votes: $param->{votes}
245 }
246 }
247
248 quorum {
249 provider: corosync_votequorum
250 }
251
252 logging {
253 to_syslog: yes
254 debug: off
255 }
256 _EOD
257 ;
258 PVE::Tools::file_set_contents($clusterconf, $config);
259
260 PVE::Cluster::ssh_merge_keys();
261
262 PVE::Cluster::gen_pve_node_files($nodename, $local_ip_address);
263
264 PVE::Cluster::ssh_merge_known_hosts($nodename, $local_ip_address, 1);
265
266 PVE::Tools::run_command('systemctl restart pve-cluster'); # restart
267
268 PVE::Tools::run_command('systemctl restart corosync'); # restart
269
270 return undef;
271 }});
272
273 __PACKAGE__->register_method ({
274 name => 'addnode',
275 path => 'addnode',
276 method => 'PUT',
277 description => "Adds a node to the cluster configuration.",
278 parameters => {
279 additionalProperties => 0,
280 properties => {
281 node => PVE::JSONSchema::get_standard_option('pve-node'),
282 nodeid => {
283 type => 'integer',
284 description => "Node id for this node.",
285 minimum => 1,
286 optional => 1,
287 },
288 votes => {
289 type => 'integer',
290 description => "Number of votes for this node",
291 minimum => 0,
292 optional => 1,
293 },
294 force => {
295 type => 'boolean',
296 description => "Do not throw error if node already exists.",
297 optional => 1,
298 },
299 ring0_addr => {
300 type => 'string', format => 'address',
301 description => "Hostname (or IP) of the corosync ring0 address of this node.".
302 " Defaults to nodes hostname.",
303 optional => 1,
304 },
305 ring1_addr => {
306 type => 'string', format => 'address',
307 description => "Hostname (or IP) of the corosync ring1 address, this".
308 " needs an valid bindnet1_addr.",
309 optional => 1,
310 },
311 },
312 },
313 returns => { type => 'null' },
314
315 code => sub {
316 my ($param) = @_;
317
318 PVE::Cluster::check_cfs_quorum();
319
320 my $conf = PVE::Cluster::cfs_read_file("corosync.conf");
321
322 my $nodelist = corosync_nodelist($conf);
323
324 my $totem_cfg = corosync_totem_config($conf);
325
326 my $name = $param->{node};
327
328 $param->{ring0_addr} = $name if !$param->{ring0_addr};
329
330 die " ring1_addr needs a configured ring 1 interface!\n"
331 if $param->{ring1_addr} && !defined($totem_cfg->{interface}->{1});
332
333 if (defined(my $res = $nodelist->{$name})) {
334 $param->{nodeid} = $res->{nodeid} if !$param->{nodeid};
335 $param->{votes} = $res->{quorum_votes} if !defined($param->{votes});
336
337 if ($res->{quorum_votes} == $param->{votes} &&
338 $res->{nodeid} == $param->{nodeid}) {
339 print "node $name already defined\n";
340 if ($param->{force}) {
341 exit (0);
342 } else {
343 exit (-1);
344 }
345 } else {
346 die "can't add existing node\n";
347 }
348 } elsif (!$param->{nodeid}) {
349 my $nodeid = 1;
350
351 while(1) {
352 my $found = 0;
353 foreach my $v (values %$nodelist) {
354 if ($v->{nodeid} eq $nodeid) {
355 $found = 1;
356 $nodeid++;
357 last;
358 }
359 }
360 last if !$found;
361 };
362
363 $param->{nodeid} = $nodeid;
364 }
365
366 $param->{votes} = 1 if !defined($param->{votes});
367
368 PVE::Cluster::gen_local_dirs($name);
369
370 eval { PVE::Cluster::ssh_merge_keys(); };
371 warn $@ if $@;
372
373 $nodelist->{$name} = {
374 ring0_addr => $param->{ring0_addr},
375 nodeid => $param->{nodeid},
376 name => $name,
377 };
378 $nodelist->{$name}->{ring1_addr} = $param->{ring1_addr} if $param->{ring1_addr};
379 $nodelist->{$name}->{quorum_votes} = $param->{votes} if $param->{votes};
380
381 corosync_update_nodelist($conf, $nodelist);
382
383 exit (0);
384 }});
385
386
387 __PACKAGE__->register_method ({
388 name => 'delnode',
389 path => 'delnode',
390 method => 'PUT',
391 description => "Removes a node to the cluster configuration.",
392 parameters => {
393 additionalProperties => 0,
394 properties => {
395 node => PVE::JSONSchema::get_standard_option('pve-node'),
396 },
397 },
398 returns => { type => 'null' },
399
400 code => sub {
401 my ($param) = @_;
402
403 PVE::Cluster::check_cfs_quorum();
404
405 my $conf = PVE::Cluster::cfs_read_file("corosync.conf");
406
407 my $nodelist = corosync_nodelist($conf);
408
409 my $nd = delete $nodelist->{$param->{node}};
410 die "no such node '$param->{node}'\n" if !$nd;
411
412 corosync_update_nodelist($conf, $nodelist);
413
414 return undef;
415 }});
416
417 __PACKAGE__->register_method ({
418 name => 'add',
419 path => 'add',
420 method => 'PUT',
421 description => "Adds the current node to an existing cluster.",
422 parameters => {
423 additionalProperties => 0,
424 properties => {
425 hostname => {
426 type => 'string',
427 description => "Hostname (or IP) of an existing cluster member."
428 },
429 nodeid => {
430 type => 'integer',
431 description => "Node id for this node.",
432 minimum => 1,
433 optional => 1,
434 },
435 votes => {
436 type => 'integer',
437 description => "Number of votes for this node",
438 minimum => 0,
439 optional => 1,
440 },
441 force => {
442 type => 'boolean',
443 description => "Do not throw error if node already exists.",
444 optional => 1,
445 },
446 ring0_addr => {
447 type => 'string', format => 'address',
448 description => "Hostname (or IP) of the corosync ring0 address of this node.".
449 " Defaults to nodes hostname.",
450 optional => 1,
451 },
452 ring1_addr => {
453 type => 'string', format => 'address',
454 description => "Hostname (or IP) of the corosync ring1 address, this".
455 " needs an valid configured ring 1 interface in the cluster.",
456 optional => 1,
457 },
458 },
459 },
460 returns => { type => 'null' },
461
462 code => sub {
463 my ($param) = @_;
464
465 my $nodename = PVE::INotify::nodename();
466
467 PVE::Cluster::setup_sshd_config();
468 PVE::Cluster::setup_rootsshconfig();
469 PVE::Cluster::setup_ssh_keys();
470
471 my $host = $param->{hostname};
472
473 if (!$param->{force}) {
474
475 if (-f $authfile) {
476 die "authentication key already exists\n";
477 }
478
479 if (-f $clusterconf) {
480 die "cluster config '$clusterconf' already exists\n";
481 }
482
483 my $vmlist = PVE::Cluster::get_vmlist();
484 if ($vmlist && $vmlist->{ids} && scalar(keys %{$vmlist->{ids}})) {
485 die "this host already contains virtual machines - please remove them first\n";
486 }
487
488 if (system("corosync-quorumtool >/dev/null 2>&1") == 0) {
489 die "corosync is already running\n";
490 }
491 }
492
493 # make sure known_hosts is on local filesystem
494 PVE::Cluster::ssh_unmerge_known_hosts();
495
496 my $cmd = "ssh-copy-id -i /root/.ssh/id_rsa 'root\@$host' >/dev/null 2>&1";
497 system ($cmd) == 0 ||
498 die "unable to copy ssh ID\n";
499
500 $cmd = ['ssh', $host, '-o', 'BatchMode=yes',
501 'pvecm', 'addnode', $nodename, '--force', 1];
502
503 push @$cmd, '--nodeid', $param->{nodeid} if $param->{nodeid};
504
505 push @$cmd, '--votes', $param->{votes} if defined($param->{votes});
506
507 push @$cmd, '--ring0_addr', $param->{ring0_addr} if defined($param->{ring0_addr});
508
509 push @$cmd, '--ring1_addr', $param->{ring1_addr} if defined($param->{ring1_addr});
510
511 if (system (@$cmd) != 0) {
512 my $cmdtxt = join (' ', @$cmd);
513 die "unable to add node: command failed ($cmdtxt)\n";
514 }
515
516 my $tmpdir = "$libdir/.pvecm_add.tmp.$$";
517 mkdir $tmpdir;
518
519 eval {
520 print "copy corosync auth key\n";
521 $cmd = ['rsync', '--rsh=ssh -l root -o BatchMode=yes', '-lpgoq',
522 "[$host]:$authfile $clusterconf", $tmpdir];
523
524 system(@$cmd) == 0 || die "can't rsync data from host '$host'\n";
525
526 mkdir "/etc/corosync";
527 my $confbase = basename($clusterconf);
528
529 $cmd = "cp '$tmpdir/$confbase' '/etc/corosync/$confbase'";
530 system($cmd) == 0 || die "can't copy cluster configuration\n";
531
532 my $keybase = basename($authfile);
533 system ("cp '$tmpdir/$keybase' '$authfile'") == 0 ||
534 die "can't copy '$tmpdir/$keybase' to '$authfile'\n";
535
536 print "stopping pve-cluster service\n";
537
538 system("umount $basedir -f >/dev/null 2>&1");
539 system("systemctl stop pve-cluster") == 0 ||
540 die "can't stop pve-cluster service\n";
541
542 backup_database();
543
544 unlink $dbfile;
545
546 system("systemctl start pve-cluster") == 0 ||
547 die "starting pve-cluster failed\n";
548
549 system("systemctl start corosync");
550
551 # wait for quorum
552 my $printqmsg = 1;
553 while (!PVE::Cluster::check_cfs_quorum(1)) {
554 if ($printqmsg) {
555 print "waiting for quorum...";
556 STDOUT->flush();
557 $printqmsg = 0;
558 }
559 sleep(1);
560 }
561 print "OK\n" if !$printqmsg;
562
563 # system("systemctl start clvm");
564
565 my $local_ip_address = PVE::Cluster::remote_node_ip($nodename);
566
567 print "generating node certificates\n";
568 PVE::Cluster::gen_pve_node_files($nodename, $local_ip_address);
569
570 print "merge known_hosts file\n";
571 PVE::Cluster::ssh_merge_known_hosts($nodename, $local_ip_address, 1);
572
573 print "restart services\n";
574 # restart pvedaemon (changed certs)
575 system("systemctl restart pvedaemon");
576 # restart pveproxy (changed certs)
577 system("systemctl restart pveproxy");
578
579 print "successfully added node '$nodename' to cluster.\n";
580 };
581 my $err = $@;
582
583 rmtree $tmpdir;
584
585 die $err if $err;
586
587 return undef;
588 }});
589
590 __PACKAGE__->register_method ({
591 name => 'status',
592 path => 'status',
593 method => 'GET',
594 description => "Displays the local view of the cluster status.",
595 parameters => {
596 additionalProperties => 0,
597 properties => {},
598 },
599 returns => { type => 'null' },
600
601 code => sub {
602 my ($param) = @_;
603
604 my $cmd = ['corosync-quorumtool', '-siH'];
605
606 exec (@$cmd);
607
608 exit (-1); # should not be reached
609 }});
610
611 __PACKAGE__->register_method ({
612 name => 'nodes',
613 path => 'nodes',
614 method => 'GET',
615 description => "Displays the local view of the cluster nodes.",
616 parameters => {
617 additionalProperties => 0,
618 properties => {},
619 },
620 returns => { type => 'null' },
621
622 code => sub {
623 my ($param) = @_;
624
625 my $cmd = ['corosync-quorumtool', '-l'];
626
627 exec (@$cmd);
628
629 exit (-1); # should not be reached
630 }});
631
632 __PACKAGE__->register_method ({
633 name => 'expected',
634 path => 'expected',
635 method => 'PUT',
636 description => "Tells corosync a new value of expected votes.",
637 parameters => {
638 additionalProperties => 0,
639 properties => {
640 expected => {
641 type => 'integer',
642 description => "Expected votes",
643 minimum => 1,
644 },
645 },
646 },
647 returns => { type => 'null' },
648
649 code => sub {
650 my ($param) = @_;
651
652 my $cmd = ['corosync-quorumtool', '-e', $param->{expected}];
653
654 exec (@$cmd);
655
656 exit (-1); # should not be reached
657
658 }});
659
660 sub corosync_update_nodelist {
661 my ($conf, $nodelist) = @_;
662
663 delete $conf->{digest};
664
665 my $version = PVE::Cluster::corosync_conf_version($conf);
666 PVE::Cluster::corosync_conf_version($conf, undef, $version + 1);
667
668 my $children = [];
669 foreach my $v (values %$nodelist) {
670 next if !($v->{ring0_addr} || $v->{name});
671 my $kv = [];
672 foreach my $k (keys %$v) {
673 push @$kv, { key => $k, value => $v->{$k} };
674 }
675 my $ns = { section => 'node', children => $kv };
676 push @$children, $ns;
677 }
678
679 foreach my $main (@{$conf->{children}}) {
680 next if !defined($main->{section});
681 if ($main->{section} eq 'nodelist') {
682 $main->{children} = $children;
683 last;
684 }
685 }
686
687
688 PVE::Cluster::cfs_write_file("corosync.conf.new", $conf);
689
690 rename("/etc/pve/corosync.conf.new", "/etc/pve/corosync.conf")
691 || die "activate corosync.conf.new failed - $!\n";
692 }
693
694 sub corosync_nodelist {
695 my ($conf) = @_;
696
697 my $nodelist = {};
698
699 foreach my $main (@{$conf->{children}}) {
700 next if !defined($main->{section});
701 if ($main->{section} eq 'nodelist') {
702 foreach my $ne (@{$main->{children}}) {
703 next if !defined($ne->{section}) || ($ne->{section} ne 'node');
704 my $node = { quorum_votes => 1 };
705 my $name;
706 foreach my $child (@{$ne->{children}}) {
707 next if !defined($child->{key});
708 $node->{$child->{key}} = $child->{value};
709 # use 'name' over 'ring0_addr' if set
710 if ($child->{key} eq 'name') {
711 delete $nodelist->{$name} if $name;
712 $name = $child->{value};
713 $nodelist->{$name} = $node;
714 } elsif(!$name && $child->{key} eq 'ring0_addr') {
715 $name = $child->{value};
716 $nodelist->{$name} = $node;
717 }
718 }
719 }
720 }
721 }
722
723 return $nodelist;
724 }
725
726 # get a hash representation of the corosync config totem section
727 sub corosync_totem_config {
728 my ($conf) = @_;
729
730 my $res = {};
731
732 foreach my $main (@{$conf->{children}}) {
733 next if !defined($main->{section}) ||
734 $main->{section} ne 'totem';
735
736 foreach my $e (@{$main->{children}}) {
737
738 if ($e->{section} && $e->{section} eq 'interface') {
739 my $entry = {};
740
741 $res->{interface} = {};
742
743 foreach my $child (@{$e->{children}}) {
744 next if !defined($child->{key});
745 $entry->{$child->{key}} = $child->{value};
746 if($child->{key} eq 'ringnumber') {
747 $res->{interface}->{$child->{value}} = $entry;
748 }
749 }
750
751 } elsif ($e->{key}) {
752 $res->{$e->{key}} = $e->{value};
753 }
754 }
755 }
756
757 return $res;
758 }
759
760 __PACKAGE__->register_method ({
761 name => 'updatecerts',
762 path => 'updatecerts',
763 method => 'PUT',
764 description => "Update node certificates (and generate all needed files/directories).",
765 parameters => {
766 additionalProperties => 0,
767 properties => {
768 force => {
769 description => "Force generation of new SSL certifate.",
770 type => 'boolean',
771 optional => 1,
772 },
773 silent => {
774 description => "Ignore errors (i.e. when cluster has no quorum).",
775 type => 'boolean',
776 optional => 1,
777 },
778 },
779 },
780 returns => { type => 'null' },
781 code => sub {
782 my ($param) = @_;
783
784 PVE::Cluster::setup_rootsshconfig();
785
786 PVE::Cluster::gen_pve_vzdump_symlink();
787
788 if (!PVE::Cluster::check_cfs_quorum(1)) {
789 return undef if $param->{silent};
790 die "no quorum - unable to update files\n";
791 }
792
793 PVE::Cluster::setup_ssh_keys();
794
795 my $nodename = PVE::INotify::nodename();
796
797 my $local_ip_address = PVE::Cluster::remote_node_ip($nodename);
798
799 PVE::Cluster::gen_pve_node_files($nodename, $local_ip_address, $param->{force});
800 PVE::Cluster::ssh_merge_keys();
801 PVE::Cluster::ssh_merge_known_hosts($nodename, $local_ip_address);
802 PVE::Cluster::gen_pve_vzdump_files();
803
804 return undef;
805 }});
806
807
808 our $cmddef = {
809 keygen => [ __PACKAGE__, 'keygen', ['filename']],
810 create => [ __PACKAGE__, 'create', ['clustername']],
811 add => [ __PACKAGE__, 'add', ['hostname']],
812 addnode => [ __PACKAGE__, 'addnode', ['node']],
813 delnode => [ __PACKAGE__, 'delnode', ['node']],
814 status => [ __PACKAGE__, 'status' ],
815 nodes => [ __PACKAGE__, 'nodes' ],
816 expected => [ __PACKAGE__, 'expected', ['expected']],
817 updatecerts => [ __PACKAGE__, 'updatecerts', []],
818 };
819
820 1;
821
822 __END__
823
824 =head1 NAME
825
826 pvecm - Proxmox VE cluster manager toolkit
827
828 =head1 SYNOPSIS
829
830 =include synopsis
831
832 =head1 DESCRIPTION
833
834 pvecm is a program to manage the cluster configuration. It can be used
835 to create a new cluster, join nodes to a cluster, leave the cluster,
836 get status information and do various other cluster related tasks.
837
838 =include pve_copyright
839
840