]> git.proxmox.com Git - pmg-api.git/blame - src/PMG/CLI/pmg7to8.pm
bump version to 8.1.4
[pmg-api.git] / src / PMG / CLI / pmg7to8.pm
CommitLineData
16fe9a1e
DC
1package PMG::CLI::pmg7to8;
2
3use strict;
4use warnings;
5
6use Cwd ();
7
8use PVE::INotify;
9use PVE::JSONSchema;
10use PVE::Tools qw(run_command split_list file_get_contents);
11
12use PMG::API2::APT;
13use PMG::API2::Certificates;
14use PMG::API2::Cluster;
15use PMG::RESTEnvironment;
16use PMG::Utils;
17
18use Term::ANSIColor;
19
20use PVE::CLIHandler;
21
22use base qw(PVE::CLIHandler);
23
24my $nodename = PVE::INotify::nodename();
25
d29d46e0
TL
26my $old_postgres_release = '13';
27my $new_postgres_release = '15';
28
29my $old_suite = 'bullseye';
30my $new_suite = 'bookworm';
31
16fe9a1e
DC
32my $upgraded = 0; # set in check_pmg_packages
33
34sub setup_environment {
35 PMG::RESTEnvironment->setup_default_cli_env();
36}
37
38my ($min_pmg_major, $min_pmg_minor, $min_pmg_pkgrel) = (7, 3, 2);
39
40my $counters = {
41 pass => 0,
42 skip => 0,
d29d46e0 43 notice => 0,
16fe9a1e
DC
44 warn => 0,
45 fail => 0,
46};
47
48my $log_line = sub {
49 my ($level, $line) = @_;
50
51 $counters->{$level}++ if defined($level) && defined($counters->{$level});
52
53 print uc($level), ': ' if defined($level);
54 print "$line\n";
55};
56
57sub log_pass {
58 print color('green');
59 $log_line->('pass', @_);
60 print color('reset');
61}
62
63sub log_info {
64 $log_line->('info', @_);
65}
66sub log_skip {
67 $log_line->('skip', @_);
68}
d29d46e0
TL
69sub log_notice {
70 print color('bold');
71 $log_line->('notice', @_);
72 print color('reset');
73}
16fe9a1e
DC
74sub log_warn {
75 print color('yellow');
76 $log_line->('warn', @_);
77 print color('reset');
78}
79sub log_fail {
80 print color('bold red');
81 $log_line->('fail', @_);
82 print color('reset');
83}
84
85my $print_header_first = 1;
86sub print_header {
87 my ($h) = @_;
88 print "\n" if !$print_header_first;
89 print "= $h =\n\n";
90 $print_header_first = 0;
91}
92
93my $get_systemd_unit_state = sub {
94 my ($unit, $suppress_stderr) = @_;
95
96 my $state;
97 my $filter_output = sub {
98 $state = shift;
99 chomp $state;
100 };
101
102 my %extra = (outfunc => $filter_output, noerr => 1);
103 $extra{errfunc} = sub { } if $suppress_stderr;
104
105 eval {
106 run_command(['systemctl', 'is-enabled', "$unit"], %extra);
107 return if !defined($state);
108 run_command(['systemctl', 'is-active', "$unit"], %extra);
109 };
110
111 return $state // 'unknown';
112};
113
114my $log_systemd_unit_state = sub {
115 my ($unit, $no_fail_on_inactive) = @_;
116
117 my $log_method = \&log_warn;
118
119 my $state = $get_systemd_unit_state->($unit);
120 if ($state eq 'active') {
121 $log_method = \&log_pass;
122 } elsif ($state eq 'inactive') {
123 $log_method = $no_fail_on_inactive ? \&log_warn : \&log_fail;
124 } elsif ($state eq 'failed') {
125 $log_method = \&log_fail;
126 }
127
128 $log_method->("systemd unit '$unit' is in state '$state'");
129};
130
131my $versions;
132my $get_pkg = sub {
133 my ($pkg) = @_;
134
135 $versions = eval { PMG::API2::APT->versions({ node => $nodename }) } if !defined($versions);
136
137 if (!defined($versions)) {
138 my $msg = "unable to retrieve package version information";
139 $msg .= "- $@" if $@;
140 log_fail("$msg");
141 return undef;
142 }
143
144 my $pkgs = [ grep { $_->{Package} eq $pkg } @$versions ];
145 if (!defined $pkgs || $pkgs == 0) {
146 log_fail("unable to determine installed $pkg version.");
147 return undef;
148 } else {
149 return $pkgs->[0];
150 }
151};
152
153sub check_pmg_packages {
154 print_header("CHECKING VERSION INFORMATION FOR PMG PACKAGES");
155
156 print "Checking for package updates..\n";
157 my $updates = eval { PMG::API2::APT->list_updates({ node => $nodename }); };
158 if (!defined($updates)) {
159 log_warn("$@") if $@;
160 log_fail("unable to retrieve list of package updates!");
161 } elsif (@$updates > 0) {
162 my $pkgs = join(', ', map { $_->{Package} } @$updates);
163 log_warn("updates for the following packages are available:\n $pkgs");
164 } else {
165 log_pass("all packages up-to-date");
166 }
167
168 print "\nChecking proxmox-mailgateway package version..\n";
169 my $pkg = 'proxmox-mailgateway';
170 my $pmg = $get_pkg->($pkg);
171 if (!defined($pmg)) {
172 print "\n$pkg not found, checking for proxmox-mailgateway-container..\n";
173 $pkg = 'proxmox-mailgateway-container';
174 }
175 if (defined(my $pmg = $get_pkg->($pkg))) {
176 # TODO: update to native version for pmg8to9
177 my $min_pmg_ver = "$min_pmg_major.$min_pmg_minor-$min_pmg_pkgrel";
178
179 my ($maj, $min, $pkgrel) = $pmg->{OldVersion} =~ m/^(\d+)\.(\d+)[.-](\d+)/;
180
181 if ($maj > $min_pmg_major) {
182 log_pass("already upgraded to Proxmox Mail Gateway " . ($min_pmg_major + 1));
183 $upgraded = 1;
184 } elsif ($maj >= $min_pmg_major && $min >= $min_pmg_minor && $pkgrel >= $min_pmg_pkgrel) {
185 log_pass("$pkg package has version >= $min_pmg_ver");
186 } else {
187 log_fail("$pkg package is too old, please upgrade to >= $min_pmg_ver!");
188 }
189
f4a5478b
SI
190 if ($pkg eq 'proxmox-mailgateway-container') {
191 log_skip("Ignoring kernel version checks for $pkg meta-package");
192 return;
193 }
194
16fe9a1e 195 # FIXME: better differentiate between 6.2 from bullseye or bookworm
9d67a9af 196 my $kinstalled = 'proxmox-kernel-6.2';
16fe9a1e
DC
197 if (!$upgraded) {
198 # we got a few that avoided 5.15 in cluster with mixed CPUs, so allow older too
9d67a9af 199 $kinstalled = 'pve-kernel-5.15';
16fe9a1e
DC
200 }
201
9d67a9af
SI
202 my $kernel_version_is_expected = sub {
203 my ($version) = @_;
204
205 return $version =~ m/^(?:5\.(?:13|15)|6\.2)/ if !$upgraded;
206
207 if ($version =~ m/^6\.(?:2\.(?:[2-9]\d+|1[6-8]|1\d\d+)|5)[^~]*$/) {
208 return 1;
209 } elsif ($version =~ m/^(\d+).(\d+)[^~]*-pve$/) {
210 return $1 >= 6 && $2 >= 2;
211 }
212 return 0;
213 };
214
16fe9a1e
DC
215 print "\nChecking running kernel version..\n";
216 my $kernel_ver = $pmg->{RunningKernel};
217 if (!defined($kernel_ver)) {
218 log_fail("unable to determine running kernel version.");
9d67a9af 219 } elsif ($kernel_version_is_expected->($kernel_ver)) {
16fe9a1e
DC
220 if ($upgraded) {
221 log_pass("running new kernel '$kernel_ver' after upgrade.");
222 } else {
223 log_pass("running kernel '$kernel_ver' is considered suitable for upgrade.");
224 }
225 } elsif ($get_pkg->($kinstalled)) {
226 # with 6.2 kernel being available in both we might want to fine-tune the check?
2b2b30d3 227 log_warn("a suitable kernel ($kinstalled) is installed, but an unsuitable ($kernel_ver) is booted, missing reboot?!");
16fe9a1e
DC
228 } else {
229 log_warn("unexpected running and installed kernel '$kernel_ver'.");
230 }
231
9d67a9af 232 if ($upgraded && $kernel_version_is_expected->($kernel_ver)) {
16fe9a1e
DC
233 my $outdated_kernel_meta_pkgs = [];
234 for my $kernel_meta_version ('5.4', '5.11', '5.13', '5.15') {
235 my $pkg = "pve-kernel-${kernel_meta_version}";
236 if ($get_pkg->($pkg)) {
237 push @$outdated_kernel_meta_pkgs, $pkg;
238 }
239 }
240 if (scalar(@$outdated_kernel_meta_pkgs) > 0) {
241 log_info(
242 "Found outdated kernel meta-packages, taking up extra space on boot partitions.\n"
243 ." After a successful upgrade, you can remove them using this command:\n"
244 ." apt remove " . join(' ', $outdated_kernel_meta_pkgs->@*)
245 );
246 }
247 }
248 } else {
249 log_fail("$pkg package not found!");
250 }
251}
252
253my sub check_max_length {
254 my ($raw, $max_length, $warning) = @_;
255 log_warn($warning) if defined($raw) && length($raw) > $max_length;
256}
257
258my $is_cluster = 0;
259my $cluster_healthy = 0;
260
261sub check_cluster_status {
262 log_info("Checking if the cluster nodes are in sync");
263
264 my $rpcenv = PMG::RESTEnvironment->get();
265 my $ticket = PMG::Ticket::assemble_ticket($rpcenv->get_user());
266 $rpcenv->set_ticket($ticket);
267
268 my $nodes = PMG::API2::Cluster->status({});
269 if (!scalar($nodes->@*)) {
270 log_skip("no cluster, no sync status to check");
271 $cluster_healthy = 1;
272 return;
273 }
274
275 $is_cluster = 1;
276 my $syncing = 0;
277 my $errors = 0;
278
279 for my $node ($nodes->@*) {
280 if (!$node->{insync}) {
281 $syncing = 1;
282 }
283 if ($node->{conn_error}) {
284 $errors = 1;
285 }
286 }
287
288 if ($errors) {
289 log_fail("Cluster not healthy, please fix the cluster before continuing");
290 } elsif ($syncing) {
291 log_warn("Cluster currently syncing.");
292 } else {
293 log_pass("Cluster healthy and in sync.");
294 $cluster_healthy = 1;
295 }
296}
297
298
299sub check_running_postgres {
300 my $version = PMG::Utils::get_pg_server_version();
301
302 my $upgraded_db = 0;
303
304 if ($upgraded) {
d29d46e0
TL
305 if ($version ne $new_postgres_release) {
306 log_warn("Running postgres version is still $old_postgres_release. Please upgrade the database.");
16fe9a1e 307 } else {
d29d46e0 308 log_pass("After upgrade and running postgres version is $new_postgres_release.");
16fe9a1e
DC
309 $upgraded_db = 1;
310 }
311 } else {
d29d46e0
TL
312 if ($version ne $old_postgres_release) {
313 log_fail("Running postgres version '$version' is not '$old_postgres_release', was a previous upgrade left unfinished?");
16fe9a1e 314 } else {
d29d46e0 315 log_pass("Before upgrade and running postgres version is $old_postgres_release.");
16fe9a1e
DC
316 }
317 }
318
319 return $upgraded_db;
320}
321
322sub check_services_disabled {
323 my ($upgraded_db) = @_;
324 my $unit_inactive = sub { return $get_systemd_unit_state->($_[0], 1) eq 'inactive' ? $_[0] : undef };
325
326 my $services = [qw(postfix pmg-smtp-filter pmgpolicy pmgdaemon pmgproxy)];
327
328 if ($is_cluster) {
329 push $services->@*, 'pmgmirror', 'pmgtunnel';
330 }
331
332 my $active_list = [];
333 my $inactive_list = [];
334 for my $service ($services->@*) {
335 if (!$unit_inactive->($service)) {
336 push $active_list->@*, $service;
337 } else {
338 push $inactive_list->@*, $service;
339 }
340 }
341
342 if (!$upgraded) {
343 if (scalar($active_list->@*) < 1) {
344 log_pass("All services inactive.");
345 } else {
d29d46e0 346 my $msg = "Not upgraded but core services still active. Consider stopping and masking them for the upgrade: \n ";
16fe9a1e
DC
347 $msg .= join("\n ", $active_list->@*);
348 log_warn($msg);
349 }
350 } else {
351 if (scalar($inactive_list->@*) < 1) {
352 log_pass("All services active.");
353 } elsif ($upgraded_db) {
d29d46e0 354 my $msg = "Already upgraded DB, but not all services active again. Consider unmasking and starting them: \n ";
16fe9a1e
DC
355 $msg .= join("\n ", $inactive_list->@*);
356 log_warn($msg);
357 } else {
d29d46e0 358 log_skip("Not all services active, but DB was not upgraded yet - please upgrade DB and then unmask and start services again.");
16fe9a1e
DC
359 }
360 }
361}
362
363sub check_apt_repos {
d29d46e0 364 log_info("Checking for package repository suite mismatches..");
16fe9a1e
DC
365
366 my $dir = '/etc/apt/sources.list.d';
367 my $in_dir = 0;
368
369 # TODO: check that (original) debian and Proxmox MG mirrors are present.
370
d29d46e0
TL
371 my ($found_suite, $found_suite_where);
372 my ($mismatches, $strange_suite);
373
16fe9a1e
DC
374 my $check_file = sub {
375 my ($file) = @_;
376
377 $file = "${dir}/${file}" if $in_dir;
378
379 my $raw = eval { PVE::Tools::file_get_contents($file) };
380 return if !defined($raw);
381 my @lines = split(/\n/, $raw);
382
383 my $number = 0;
384 for my $line (@lines) {
385 $number++;
386
387 next if length($line) == 0; # split would result in undef then...
388
389 ($line) = split(/#/, $line);
390
391 next if $line !~ m/^deb[[:space:]]/; # is case sensitive
392
393 my $suite;
d29d46e0 394 if ($line =~ m|deb\s+\w+://\S+\s+(\S*)|i) {
16fe9a1e
DC
395 $suite = $1;
396 } else {
397 next;
398 }
d29d46e0 399 my $where = "in ${file}:${number}";
16fe9a1e 400
0f79fea4 401 $suite =~ s/-(?:(?:proposed-)?updates|backports|security)$//;
d29d46e0
TL
402 if ($suite ne $old_suite && $suite ne $new_suite) {
403 log_notice(
404 "found unusual suite '$suite', neither old '$old_suite' nor new '$new_suite'.."
405 ."\n Affected file:line $where"
406 ."\n Please assure this is shipping compatible packages for the upgrade!"
407 );
408 $strange_suite = 1;
409 next;
410 }
16fe9a1e 411
d29d46e0
TL
412 if (!defined($found_suite)) {
413 $found_suite = $suite;
414 $found_suite_where = $where;
415 } elsif ($suite ne $found_suite) {
416 if (!defined($mismatches)) {
417 $mismatches = [];
418 push $mismatches->@*,
419 { suite => $found_suite, where => $found_suite_where},
420 { suite => $suite, where => $where};
421 } else {
422 push $mismatches->@*, { suite => $suite, where => $where};
423 }
424 }
16fe9a1e
DC
425 }
426 };
427
428 $check_file->("/etc/apt/sources.list");
429
430 $in_dir = 1;
431
432 PVE::Tools::dir_glob_foreach($dir, '^.*\.list$', $check_file);
433
d29d46e0
TL
434 if (defined($mismatches)) {
435 my @mismatch_list = map { "found suite $_->{suite} at $_->{where}" } $mismatches->@*;
436
437 log_fail(
438 "Found mixed old and new package repository suites, fix before upgrading! Mismatches:"
439 ."\n " . join("\n ", @mismatch_list)
440 );
441 } elsif ($strange_suite) {
442 log_notice("found no suite mismatches, but found at least one strange suite");
443 } else {
444 log_pass("found no suite mismatch");
16fe9a1e
DC
445 }
446}
447
448sub check_time_sync {
449 my $unit_active = sub { return $get_systemd_unit_state->($_[0], 1) eq 'active' ? $_[0] : undef };
450
451 log_info("Checking for supported & active NTP service..");
452 if ($unit_active->('systemd-timesyncd.service')) {
453 log_warn(
454 "systemd-timesyncd is not the best choice for time-keeping on servers, due to only applying"
c0605d00
SI
455 ." updates on boot.\n It's recommended to use one of:\n"
456 ." * chrony\n * ntpsec\n * openntpd\n"
16fe9a1e
DC
457 );
458 } elsif ($unit_active->('ntp.service')) {
459 log_info("Debian deprecated and removed the ntp package for Bookworm, but the system"
460 ." will automatically migrate to the 'ntpsec' replacement package on upgrade.");
461 } elsif (my $active_ntp = ($unit_active->('chrony.service') || $unit_active->('openntpd.service') || $unit_active->('ntpsec.service'))) {
462 log_pass("Detected active time synchronisation unit '$active_ntp'");
463 } else {
c0605d00 464 log_notice("No (active) time synchronisation daemon (NTP) detected");
16fe9a1e
DC
465 }
466}
467
468sub check_bootloader {
469 log_info("Checking bootloader configuration...");
16fe9a1e 470
de57f3ac
SI
471 if (! -d '/sys/firmware/efi') {
472 log_skip("System booted in legacy-mode - no need for additional packages");
16fe9a1e
DC
473 return;
474 }
475
de57f3ac
SI
476 if ( -f "/etc/kernel/proxmox-boot-uuids") {
477 if (!$upgraded) {
478 log_skip("not yet upgraded, no need to check the presence of systemd-boot");
479 return;
480 }
481 if ( -f "/usr/share/doc/systemd-boot/changelog.Debian.gz") {
482 log_pass("bootloader packages installed correctly");
483 return;
484 }
16fe9a1e
DC
485 log_warn(
486 "proxmox-boot-tool is used for bootloader configuration in uefi mode"
de57f3ac
SI
487 . " but the separate systemd-boot package is not installed,"
488 . " initializing new ESPs will not work until the package is installed"
489 );
490 return;
491 } elsif ( ! -f "/usr/share/doc/grub-efi-amd64/changelog.Debian.gz" ) {
492 log_warn(
493 "System booted in uefi mode but grub-efi-amd64 meta-package not installed,"
494 . " new grub versions will not be installed to /boot/efi!"
495 . " Install grub-efi-amd64."
16fe9a1e 496 );
de57f3ac
SI
497 return;
498 } else {
499 log_pass("bootloader packages installed correctly");
16fe9a1e
DC
500 }
501}
502
35276d68
SI
503sub check_dkms_modules {
504 if (defined($get_pkg->('proxmox-mailgateway-container'))) {
505 log_skip("Ignore dkms in containers.");
506 return;
507 }
508
509 log_info("Check for dkms modules...");
510
511 my $count;
512 my $set_count = sub {
513 $count = scalar @_;
514 };
515
516 my $exit_code = eval {
517 run_command(['dkms', 'status', '-k', '`uname -r`'], outfunc => $set_count, noerr => 1)
518 };
519
520 if ($exit_code != 0) {
521 log_skip("could not get dkms status");
522 } elsif (!$count) {
523 log_pass("no dkms modules found");
524 } else {
525 log_warn("dkms modules found, this might cause issues during upgrade.");
526 }
527}
528
16fe9a1e
DC
529sub check_misc {
530 print_header("MISCELLANEOUS CHECKS");
531 my $ssh_config = eval { PVE::Tools::file_get_contents('/root/.ssh/config') };
532 if (defined($ssh_config)) {
533 log_fail("Unsupported SSH Cipher configured for root in /root/.ssh/config: $1")
534 if $ssh_config =~ /^Ciphers .*(blowfish|arcfour|3des).*$/m;
535 } else {
536 log_skip("No SSH config file found.");
537 }
538
539 check_time_sync();
540
541 my $root_free = PVE::Tools::df('/', 10);
542 log_warn("Less than 5 GB free space on root file system.")
543 if defined($root_free) && $root_free->{avail} < 5 * 1000*1000*1000;
544
545 log_info("Checking if the local node's hostname '$nodename' is resolvable..");
546 my $local_ip = eval { PVE::Network::get_ip_from_hostname($nodename) };
547 if ($@) {
548 log_warn("Failed to resolve hostname '$nodename' to IP - $@");
549 } else {
550 log_info("Checking if resolved IP is configured on local node..");
551 my $cidr = Net::IP::ip_is_ipv6($local_ip) ? "$local_ip/128" : "$local_ip/32";
552 my $configured_ips = PVE::Network::get_local_ip_from_cidr($cidr);
553 my $ip_count = scalar(@$configured_ips);
554
555 if ($ip_count <= 0) {
556 log_fail("Resolved node IP '$local_ip' not configured or active for '$nodename'");
557 } elsif ($ip_count > 1) {
558 log_warn("Resolved node IP '$local_ip' active on multiple ($ip_count) interfaces!");
559 } else {
560 log_pass("Resolved node IP '$local_ip' configured and active on single interface.");
561 }
562 }
563
564 log_info("Check node certificate's RSA key size");
565 my $certs = PMG::API2::Certificates->info({ node => $nodename });
566 my $certs_check = {
567 'rsaEncryption' => {
568 minsize => 2048,
569 name => 'RSA',
570 },
571 'id-ecPublicKey' => {
572 minsize => 224,
573 name => 'ECC',
574 },
575 };
576
577 my $certs_check_failed = 0;
578 for my $cert (@$certs) {
579 my ($type, $size, $fn) = $cert->@{qw(public-key-type public-key-bits filename)};
580
581 if (!defined($type) || !defined($size)) {
582 log_warn("'$fn': cannot check certificate, failed to get it's type or size!");
583 }
584
585 my $check = $certs_check->{$type};
586 if (!defined($check)) {
587 log_warn("'$fn': certificate's public key type '$type' unknown!");
588 next;
589 }
590
591 if ($size < $check->{minsize}) {
592 log_fail("'$fn', certificate's $check->{name} public key size is less than 2048 bit");
593 $certs_check_failed = 1;
594 } else {
595 log_pass("Certificate '$fn' passed Debian Busters (and newer) security level for TLS connections ($size >= 2048)");
596 }
597 }
598
599 check_apt_repos();
600 check_bootloader();
35276d68 601 check_dkms_modules();
11398ae5
SI
602
603 my ($template_dir, $base_dir) = ('/etc/pmg/templates/', '/var/lib/pmg/templates');
604 my @override_but_unmodified = ();
605 PVE::Tools::dir_glob_foreach($base_dir, '.*\.(?:tt|in).*', sub {
606 my ($filename) = @_;
607 return if !-e "$template_dir/$filename";
608
609 my $shipped = PVE::Tools::file_get_contents("$base_dir/$filename", 1024*1024);
610 my $override = PVE::Tools::file_get_contents("$template_dir/$filename", 1024*1024);
611
612 push @override_but_unmodified, $filename if $shipped eq $override;
613 });
614 if (scalar(@override_but_unmodified)) {
615 my $msg = "Found overrides in '/etc/pmg/templates/' for template, but without modification."
616 ." Consider simply removing them: \n "
617 . join("\n ", @override_but_unmodified);
618 log_notice($msg);
619 }
16fe9a1e
DC
620}
621
622my sub colored_if {
623 my ($str, $color, $condition) = @_;
624 return "". ($condition ? colored($str, $color) : $str);
625}
626
627__PACKAGE__->register_method ({
628 name => 'checklist',
629 path => 'checklist',
630 method => 'GET',
631 description => 'Check (pre-/post-)upgrade conditions.',
632 parameters => {
633 additionalProperties => 0,
634 properties => {},
635 },
636 returns => { type => 'null' },
637 code => sub {
638 my ($param) = @_;
639
640 check_pmg_packages();
641 check_cluster_status();
642 my $upgraded_db = check_running_postgres();
643 check_services_disabled($upgraded_db);
644 check_misc();
645
646 print_header("SUMMARY");
647
648 my $total = 0;
649 $total += $_ for values %$counters;
650
651 print "TOTAL: $total\n";
652 print colored("PASSED: $counters->{pass}\n", 'green');
653 print "SKIPPED: $counters->{skip}\n";
654 print colored_if("WARNINGS: $counters->{warn}\n", 'yellow', $counters->{warn} > 0);
655 print colored_if("FAILURES: $counters->{fail}\n", 'bold red', $counters->{fail} > 0);
656
657 if ($counters->{warn} > 0 || $counters->{fail} > 0) {
658 my $color = $counters->{fail} > 0 ? 'bold red' : 'yellow';
659 print colored("\nATTENTION: Please check the output for detailed information!\n", $color);
660 print colored("Try to solve the problems one at a time and then run this checklist tool again.\n", $color) if $counters->{fail} > 0;
661 }
662
663 return undef;
664 }});
665
666our $cmddef = [ __PACKAGE__, 'checklist', [], {}];
667
6681;