#!/usr/bin/env perl # Simplified BSD License (FreeBSD License) # # Copyright (c) 2026, Daily Data Inc. # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, this # list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # run-tests - entry point for the sneakernet TESTING.md automation harness. # See sneakernet/Documentation/TESTING_automation.md for the full design. # # Part 0 (svn update+verify, dataset rebuild, canonical config) is ordinary catalogue data # like every other Part - there is no separate "preflight" code path here. That is # deliberate: every behavior this harness has lives in the catalogue-plus-runner, not # scattered across special cases in the entry-point script. use strict; use warnings; use FindBin; use lib "$FindBin::Bin/../.."; # repo root (zfs_utils/), for YAMLPatch.pm use lib $FindBin::Bin; # sneakernet/testing/, for Harness::* use Harness::Catalogue; use Harness::Runner; use Harness::Safe; use Harness::Report; use Getopt::Long qw(GetOptions); use POSIX qw(strftime); use File::Path qw(make_path); my %opt = ( mode => 'explain', parts => undef, steps => undef, from => undef, force_partial => 0, include_optional => 0, baseline_maxdelta => 'commented', catalogue_dir => "$FindBin::Bin/catalogue", config => '/storage/testing/code/sneakernet/sneakernet.conf.yaml', harness_dir => '/storage/testing/harness', output => undef, email_to => undef, ); GetOptions( 'explain' => sub { $opt{mode} = 'explain' }, 'dry-run' => sub { $opt{mode} = 'dry_run' }, 'confirm-destructive' => sub { $opt{mode} = 'confirm_destructive' }, 'run' => sub { $opt{mode} = 'run' }, 'parts=s' => \$opt{parts}, 'steps=s' => \$opt{steps}, 'from=s' => \$opt{from}, 'force-partial' => \$opt{force_partial}, 'include-optional' => \$opt{include_optional}, 'baseline-maxdelta=s' => \$opt{baseline_maxdelta}, 'catalogue-dir=s' => \$opt{catalogue_dir}, 'config=s' => \$opt{config}, 'harness-dir=s' => \$opt{harness_dir}, 'output=s' => \$opt{output}, 'email-to=s' => \$opt{email_to}, 'help|h' => \my $help, ) or usage(2); usage(0) if $help; usage(2, "--baseline-maxdelta must be 'commented' or '0.9'") unless $opt{baseline_maxdelta} =~ /^(?:commented|0\.9)$/; # --- cron guard (constraint 5). --explain touches nothing real, so it alone is exempt. --- if ($opt{mode} ne 'explain') { Harness::Safe::assert_outside_cron_guard_window(); } # --- load the catalogue --- my $cat = Harness::Catalogue->load_dir($opt{catalogue_dir}); # --- resolve the step selection --- my @requested; if (defined $opt{steps}) { @requested = split /,/, $opt{steps}; } elsif (defined $opt{parts}) { my @wanted; for my $spec (split /,/, $opt{parts}) { if ($spec =~ /^(\d+)-(\d+)$/) { push @wanted, ($1 .. $2); } else { push @wanted, $spec; } } my %wantPart = map { $_ => 1 } @wanted; @requested = map { $_->{id} } grep { $wantPart{ $_->{part} } } map { $cat->step($_) } $cat->all_step_ids; } elsif ($opt{from}) { my @all = $cat->all_step_ids; my ($idx) = grep { $all[$_] eq $opt{from} } 0 .. $#all; usage(2, "unknown --from step id '$opt{from}'") unless defined $idx; @requested = @all[$idx .. $#all]; } else { @requested = $cat->all_step_ids; } my @selection = eval { $cat->resolve_selection(\@requested, $opt{force_partial}) }; if ($@) { print STDERR "run-tests: $@"; exit 2; } if ($opt{email_to} && $opt{email_to} !~ /^[^<>\s]+\@[^<>\s]+$/) { print STDERR "run-tests: --email-to must be a bare address with no display name (no '<'/'>') - " . "see TESTING_automation.md 3.1a: ZFS_Utils::loadConfig rewrites the whole config " . "if it ever contains '<' or '>'.\n"; exit 2; } # --- set up run id, audit log, mode --- my $runId = strftime('%Y%m%d_%H%M%S', gmtime()) . "_$$"; Harness::Safe::set_mode($opt{mode}); my $runDir; if ($opt{mode} ne 'explain') { $runDir = "$opt{harness_dir}/runs/$runId"; make_path($runDir) or die "run-tests: cannot create '$runDir': $!" unless -d $runDir; Harness::Safe::set_audit_log("$runDir/audit.jsonl"); make_path("$opt{harness_dir}/logs") unless -d "$opt{harness_dir}/logs"; # {TMP} (/storage/testing/tmp) has no ZFS-dataset-creation step anywhere in the catalogue - # unlike {VERIFY}, which TESTING.md itself documents sneakernet creating on demand (Part 4), # {TMP} is purely a harness convenience (the /tmp replacement) with no equivalent bootstrap. # Parts 9/11/15/16/17 all write_file directly under it, which does not create parent # directories - so it must exist before any step runs, not just {HARNESS}. make_path('/storage/testing/tmp') unless -d '/storage/testing/tmp'; # {ONESHOT} (/storage/testing/oneshot, source.oneShotCleanup) is in the same bucket as {TMP}: # sneakernet only ever READS from it (copyCleanupScripts gates on `-d $oneShotCleanup` and # silently skips it otherwise), it never creates the directory itself - unlike {TRANSPORT}/ # {REPORT}, which mountDriveByLabel's folder-based-mode branch mkdir's on first use. Found on # dd-nas1: the "standard" reset profile's empty_dir({ONESHOT}) REFUSED on a from-scratch # environment where nothing had ever created it yet, halting the very first Part 1+ run. make_path('/storage/testing/oneshot') unless -d '/storage/testing/oneshot'; } print STDERR "run-tests: run id $runId, mode=$opt{mode}, " . scalar(@selection) . " step(s) selected" . ($opt{force_partial} ? ' (--force-partial: dependencies NOT auto-included)' : '') . "\n"; # --- production tripwire baseline (readonly - runs for real even in dry-run) --- my $runner = Harness::Runner->new( catalogue => $cat, config_path => $opt{config}, include_optional => $opt{include_optional}, email_to => $opt{email_to}, baseline_maxdelta => $opt{baseline_maxdelta}, ); if ($opt{mode} ne 'explain') { Harness::Safe::assert_mount_topology(); my $baseline = Harness::Safe::production_fingerprint(); $runner->set_production_baseline($baseline); print STDERR "run-tests: production tripwire armed (storage/backup fingerprint captured)\n"; } my $startTime = time(); eval { $runner->run_steps(@selection); 1 } or do { print STDERR "run-tests: run halted: $@"; }; my $endTime = time(); # --- render + write TESTING.log --- my %partTitles = map { $_->{part} => $_->{title} } $cat->parts; my @followups; push @followups, 'Step 8.5 (mixed compressed/plain drive) - known broken, run for evidence, not fixed this pass' if grep { $_->{id} eq '8.5' } $runner->results; push @followups, "maxDelta - baseline runs Parts 1-17 with it commented out (deviation, see TESTING_automation.md 9.2); " . 'Part 18 evidence captured for the spurious-abort bug, not fixed this pass' if grep { $_->{id} =~ /^18\./ } $runner->results; my $report = Harness::Report::render( run_id => $runId, start_time => $startTime, end_time => $endTime, host => (eval { require Sys::Hostname; Sys::Hostname::hostname() } || 'unknown'), svn_rev => (eval { (Harness::Safe::svnversion(dir => '/storage/testing/code'))[0] } || 'unknown'), mode => $opt{mode}, sneakernet_version => 'see run log', zfsutils_version => 'see run log', harness_version => $Harness::Runner::VERSION, baseline_maxdelta => $opt{baseline_maxdelta}, include_optional => $opt{include_optional}, results => [ $runner->results ], part_titles => \%partTitles, followups => \@followups, ); print $report; my $outputPath = $opt{output} // ($runDir ? "$runDir/TESTING.log" : undef); if ($outputPath) { open my $fh, '>', $outputPath or die "run-tests: cannot write '$outputPath': $!"; print {$fh} $report; close $fh; print STDERR "run-tests: report written to $outputPath\n"; } my $failCount = grep { $_->{status} eq 'FAIL' } $runner->results; exit($failCount > 0 ? 1 : 0); sub usage { my ($code, $msg) = @_; print STDERR "run-tests: $msg\n\n" if $msg; print STDERR <<'USAGE'; Usage: run-tests [MODE] [SELECTION] [OPTIONS] Modes (default: --explain): --explain Static render only. Executes nothing, connects to nothing. --dry-run Readonly primitives run for real; mutating/destructive simulated. --confirm-destructive Real execution; prompts before each destructive primitive. --run Unattended, full execution. Selection (default: every step in the catalogue): --parts 1-6,9 Comma-separated part numbers/ranges. --steps 7.2a,7.2b Comma-separated step ids. --from 9.2 Every step from this id onward, in catalogue order. --force-partial Don't auto-include a selected step's dependencies. Options: --include-optional Run steps classified 'optional' (8.5, 13.3, 17). --baseline-maxdelta MODE 'commented' (default) or '0.9' - see TESTING_automation.md 9.2. --email-to ADDRESS Required to actually run Step 13.3. Bare address only (no '<'/'>'). --catalogue-dir DIR Default: ./catalogue next to this script. --config PATH Default: /storage/testing/code/sneakernet/sneakernet.conf.yaml --harness-dir DIR Default: /storage/testing/harness --output PATH Where to write TESTING.log (default: under --harness-dir/runs//) --help USAGE exit $code; }