#!/usr/bin/env perl use strict; use warnings; use Cwd qw(abs_path getcwd); use File::Basename qw(dirname); use File::Path qw(make_path); use File::Spec; use Getopt::Long qw(GetOptionsFromArray); use IO::Select; use IPC::Open3 qw(open3); use JSON::PP qw(decode_json encode_json); use POSIX qw(strftime); use Symbol qw(gensym); use Time::HiRes qw(time); my $DEV_BRANCH = 'supertest-dev'; my $CHANNEL_URL = 'https://git.teralink.net/tribes/guix-tribes.git'; my $INTRO_COMMIT = '7c4f9d3b3477945ca75d22baa237c44895f2e454'; my $DEV_FINGERPRINT = 'F29B A6DA 96E5 EC29 FDDE D994 8F4F 75B3 B19D 4784'; my $DEV_FINGERPRINT_COMPACT = 'F29BA6DA96E5EC29FDDED9948F4F75B3B19D4784'; my $SIGNER_LABEL = 'tribes-supertest-dev'; my $DEV_KEY_FILE = 'supertest-dev-B19D4784.key'; my %COMMANDS = map { $_ => 1 } qw(prepare reset env run ssh rpc help); sub usage { print <<'EOF'; Usage: scripts/test-dev-branch [options] scripts/test-dev-branch prepare [options] scripts/test-dev-branch reset [options] scripts/test-dev-branch env [options] scripts/test-dev-branch run [options] scripts/test-dev-branch ssh [options] [-- ] scripts/test-dev-branch rpc [options] -- Options: --plugin NAME Update and test plugin NAME (default: no plugin override) --plugin-repo PATH Plugin checkout to pin (default: ../tribes-plugin-NAME) --keep-nodes Keep nodes after the scenario run --cert-mode MODE self-signed (default), staging, or acme --run-id ID Override generated SUPERTEST_RUN_ID --guix-repo PATH guix-tribes checkout (default: ../guix-tribes) --tribes-repo PATH tribes checkout (default: ../tribes) --legion-repo PATH legion_kk checkout (default: ../legion_kk) --build-host HOST optional remote Guix build host for pin helpers (default: local Guix) --dry-run Print commands that would run -h, --help Show this help The default form prepares the hard-coded supertest-dev guix-tribes branch and then runs the requested scenario in self-signed mode. EOF } sub fail { die "@_\n"; } sub shell_quote { my ($value) = @_; $value =~ s/'/'\\''/g; return "'$value'"; } sub display_cmd { return join(' ', map { shell_quote($_) } @_); } sub compact_fingerprint { my ($value) = @_; $value =~ s/\s+//g; return $value; } sub run_checked { my ($ctx, @cmd) = @_; print "+ ", display_cmd(@cmd), "\n" if $ctx->{verbose} || $ctx->{dry_run}; return if $ctx->{dry_run}; system(@cmd) == 0 or fail("Command failed: " . display_cmd(@cmd)); } sub capture { my (@cmd) = @_; my $err = gensym; my $pid = open3(my $in, my $out, $err, @cmd); close $in; my $output = ''; my $select = IO::Select->new($out, $err); while (my @ready = $select->can_read) { for my $fh (@ready) { my $chunk = ''; my $bytes = sysread($fh, $chunk, 8192); if (defined $bytes && $bytes > 0) { $output .= $chunk; next; } $select->remove($fh); close $fh; } } waitpid($pid, 0); my $status = $? >> 8; return ($status, $output); } sub capture_checked { my (@cmd) = @_; my ($status, $output) = capture(@cmd); $status == 0 or fail("Command failed: " . display_cmd(@cmd) . "\n$output"); chomp $output; return $output; } sub read_file { my ($path) = @_; open my $fh, '<', $path or fail("Failed to read $path: $!"); local $/; return <$fh>; } sub write_file { my ($path, $content) = @_; open my $fh, '>', $path or fail("Failed to write $path: $!"); print {$fh} $content or fail("Failed to write $path: $!"); close $fh or fail("Failed to close $path: $!"); } sub write_json_file { my ($path, $data) = @_; my $json = JSON::PP->new->canonical->pretty->encode($data); write_file($path, $json); } sub repo_root { my $script_dir = abs_path(dirname($0)); return abs_path(File::Spec->catdir($script_dir, '..')); } sub abs_default { my ($base, $path) = @_; return abs_path(File::Spec->rel2abs($path, $base)); } sub parse_args { my @argv = @_; my $root = repo_root(); my %opts = ( cert_mode => 'self-signed', dry_run => 0, keep_nodes => 0, guix_repo => abs_default($root, '../guix-tribes'), tribes_repo => abs_default($root, '../tribes'), legion_repo => abs_default($root, '../legion_kk'), ); GetOptionsFromArray( \@argv, 'plugin=s' => \$opts{plugin}, 'plugin-repo=s' => \$opts{plugin_repo}, 'keep-nodes' => \$opts{keep_nodes}, 'cert-mode=s' => \$opts{cert_mode}, 'run-id=s' => \$opts{run_id}, 'guix-repo=s' => \$opts{guix_repo}, 'tribes-repo=s' => \$opts{tribes_repo}, 'legion-repo=s' => \$opts{legion_repo}, 'build-host=s' => \$opts{build_host}, 'dry-run' => \$opts{dry_run}, 'h|help' => \$opts{help}, ) or do { usage(); exit 1; }; if ($opts{help}) { usage(); exit 0; } $opts{guix_repo} = abs_default($root, $opts{guix_repo}); $opts{tribes_repo} = abs_default($root, $opts{tribes_repo}); $opts{legion_repo} = abs_default($root, $opts{legion_repo}); $opts{plugin_repo} = abs_default($root, $opts{plugin_repo}) if defined $opts{plugin_repo}; $opts{root} = $root; my $command = shift(@argv); if (!defined $command) { usage(); exit 1; } if (!$COMMANDS{$command}) { unshift @argv, $command; $command = 'run'; } validate_cert_mode($opts{cert_mode}); return ($command, \%opts, \@argv); } sub validate_cert_mode { my ($mode) = @_; return if $mode eq 'self-signed' || $mode eq 'staging' || $mode eq 'acme'; fail("Unsupported --cert-mode $mode; expected self-signed, staging, or acme."); } sub require_repo { my ($path, $label) = @_; my $git = File::Spec->catfile($path, '.git'); (-d $git || -f $git) or fail("$label repo not found: $path"); } sub require_clean_repo { my ($repo, $label) = @_; my $status = capture_checked('git', '-C', $repo, 'status', '--porcelain'); $status eq '' or fail("$label repo has uncommitted changes:\n$status"); } sub require_source_commit_pushed { my ($ctx, $repo, $label, $commit) = @_; run_checked($ctx, 'git', '-C', $repo, 'fetch', 'origin'); my $branches = capture_checked('git', '-C', $repo, 'branch', '-r', '--contains', $commit); $branches =~ /\borigin\// or fail("$label commit $commit is not reachable from any origin branch. Push the source repo first."); } sub current_commit { my ($repo, $rev) = @_; return capture_checked('git', '-C', $repo, 'rev-parse', "$rev^{commit}"); } sub current_branch { my ($repo) = @_; return capture_checked('git', '-C', $repo, 'branch', '--show-current'); } sub ensure_dev_signing_key { my ($ctx) = @_; my $key = capture_checked('git', '-C', $ctx->{guix_repo}, 'config', '--get', 'user.signingkey'); $key eq $DEV_FINGERPRINT_COMPACT or fail("guix-tribes user.signingkey is $key, expected $DEV_FINGERPRINT_COMPACT."); } sub branch_contains { my ($repo, $ancestor, $descendant) = @_; my ($status, undef) = capture('git', '-C', $repo, 'merge-base', '--is-ancestor', $ancestor, $descendant); return $status == 0; } sub ensure_dev_branch_ready { my ($ctx) = @_; my $repo = $ctx->{guix_repo}; my $branch = current_branch($repo); my $local_ready = branch_contains($repo, $INTRO_COMMIT, $DEV_BRANCH); my $remote_ready = branch_contains($repo, $INTRO_COMMIT, "origin/$DEV_BRANCH"); my $start_point = $local_ready ? $DEV_BRANCH : $remote_ready ? "origin/$DEV_BRANCH" : undef; defined $start_point or fail("$DEV_BRANCH does not descend from intro commit $INTRO_COMMIT locally or on origin. Run reset first."); if ($branch ne $DEV_BRANCH || !$local_ready) { if ($ctx->{dry_run}) { run_checked($ctx, 'git', '-C', $repo, 'checkout', '-B', $DEV_BRANCH, $start_point); return $start_point; } run_checked($ctx, 'git', '-C', $repo, 'checkout', '-B', $DEV_BRANCH, $start_point); } branch_contains($repo, $INTRO_COMMIT, 'HEAD') or fail("$DEV_BRANCH does not descend from intro commit $INTRO_COMMIT. Run reset first."); my $auth = read_file(File::Spec->catfile($repo, '.guix-authorizations')); compact_fingerprint($auth) =~ /\Q$DEV_FINGERPRINT_COMPACT\E/ or fail("$DEV_BRANCH does not authorize the supertest dev signing key. Run reset first."); return $start_point; } sub authenticate_dev_branch { my ($ctx) = @_; my $end = current_commit($ctx->{guix_repo}, 'HEAD'); run_checked( $ctx, 'guix', 'git', 'authenticate', $INTRO_COMMIT, $DEV_FINGERPRINT_COMPACT, '--repository=' . $ctx->{guix_repo}, '--end=' . $end, ); } sub require_dev_branch_synced_to_master { my ($ctx) = @_; my $repo = $ctx->{guix_repo}; my $sync_file = File::Spec->catfile($repo, '.supertest-dev-sync.json'); -f $sync_file or fail("$DEV_BRANCH has no .supertest-dev-sync.json. Run scripts/test-dev-branch reset first."); my $sync = decode_json(read_file($sync_file)); my $source_commit = $sync->{source_commit} // ''; my $master_commit = current_commit($repo, 'origin/master'); $source_commit eq $master_commit or fail("$DEV_BRANCH is synced to master $source_commit, but current origin/master is $master_commit. Run scripts/test-dev-branch reset first."); } sub commit_if_changed { my ($ctx, $message) = @_; my $repo = $ctx->{guix_repo}; if ($ctx->{dry_run}) { print "Would commit guix-tribes changes if pin helpers changed files.\n"; run_checked($ctx, 'git', '-C', $repo, 'add', 'tribes/packages/source.scm', 'tribes/plugins'); run_checked($ctx, 'git', '-C', $repo, 'commit', '-S' . $DEV_FINGERPRINT_COMPACT, '-m', $message); return 1; } my $status = capture_checked('git', '-C', $repo, 'status', '--porcelain'); if ($status eq '') { print "No guix-tribes changes to commit.\n"; return 0; } run_checked($ctx, 'git', '-C', $repo, 'add', 'tribes/packages/source.scm', 'tribes/plugins'); run_checked($ctx, 'git', '-C', $repo, 'commit', '-S' . $DEV_FINGERPRINT_COMPACT, '-m', $message); return 1; } sub ensure_plugin_name { my ($name) = @_; defined $name && $name =~ /\A[a-z0-9][a-z0-9_-]*\z/ or fail("Invalid plugin name: " . (defined $name ? $name : '')); } sub plugin_repo_for { my ($ctx, $plugin) = @_; return $ctx->{plugin_repo} if defined $ctx->{plugin_repo}; return abs_default($ctx->{root}, "../tribes-plugin-$plugin"); } sub prepare { my ($ctx) = @_; require_repo($ctx->{guix_repo}, 'guix-tribes'); require_repo($ctx->{tribes_repo}, 'tribes'); require_clean_repo($ctx->{guix_repo}, 'guix-tribes'); require_clean_repo($ctx->{tribes_repo}, 'tribes'); ensure_dev_signing_key($ctx); my $tribes_commit = current_commit($ctx->{tribes_repo}, 'HEAD'); require_source_commit_pushed($ctx, $ctx->{tribes_repo}, 'tribes', $tribes_commit); my ($plugin, $plugin_repo, $plugin_commit); if (defined $ctx->{plugin}) { $plugin = $ctx->{plugin}; ensure_plugin_name($plugin); $plugin_repo = plugin_repo_for($ctx, $plugin); require_repo($plugin_repo, "tribes-plugin-$plugin"); require_clean_repo($plugin_repo, "tribes-plugin-$plugin"); $plugin_commit = current_commit($plugin_repo, 'HEAD'); require_source_commit_pushed($ctx, $plugin_repo, "tribes-plugin-$plugin", $plugin_commit); } run_checked($ctx, 'git', '-C', $ctx->{guix_repo}, 'fetch', 'origin'); my $dev_ref = ensure_dev_branch_ready($ctx); require_dev_branch_synced_to_master($ctx); my @build_host_args = defined $ctx->{build_host} && length $ctx->{build_host} ? ('--build-host', $ctx->{build_host}) : (); run_checked( $ctx, File::Spec->catfile($ctx->{guix_repo}, 'scripts', 'update-tribes-pin'), '--guix-repo', $ctx->{guix_repo}, '--tribes-repo', $ctx->{tribes_repo}, @build_host_args, $tribes_commit, ); if (defined $plugin) { run_checked( $ctx, File::Spec->catfile($ctx->{guix_repo}, 'scripts', 'update-plugin-pin'), '--guix-repo', $ctx->{guix_repo}, '--tribes-repo', $ctx->{tribes_repo}, '--tribes-rev', $tribes_commit, '--plugin-repo', $plugin_repo, @build_host_args, $plugin, $plugin_commit, ); } my $message = defined $plugin ? "chore: update dev pins for $plugin plugin" : 'chore: update dev Tribes pin'; commit_if_changed($ctx, $message); authenticate_dev_branch($ctx); run_checked($ctx, 'git', '-C', $ctx->{guix_repo}, 'push', 'origin', $DEV_BRANCH); my $guix_commit = $ctx->{dry_run} ? current_commit($ctx->{guix_repo}, $dev_ref) : current_commit($ctx->{guix_repo}, 'HEAD'); print "Dry run only; channel commit below is the current dev branch base.\n" if $ctx->{dry_run}; print_dev_env($guix_commit); return $guix_commit; } sub reset_branch { my ($ctx) = @_; require_repo($ctx->{guix_repo}, 'guix-tribes'); require_clean_repo($ctx->{guix_repo}, 'guix-tribes'); ensure_dev_signing_key($ctx); my $repo = $ctx->{guix_repo}; run_checked($ctx, 'git', '-C', $repo, 'fetch', 'origin'); branch_contains($repo, $INTRO_COMMIT, "origin/$DEV_BRANCH") or fail("origin/$DEV_BRANCH does not descend from intro commit $INTRO_COMMIT."); run_checked($ctx, 'git', '-C', $repo, 'checkout', '-B', $DEV_BRANCH, "origin/$DEV_BRANCH"); authenticate_dev_branch($ctx); my $previous_dev_commit = current_commit($repo, 'HEAD'); my $master_commit = current_commit($repo, 'origin/master'); my $master_auth = capture_checked('git', '-C', $repo, 'show', 'origin/master:.guix-authorizations'); my $dev_key = capture_checked('git', '-C', $repo, 'show', "HEAD:$DEV_KEY_FILE"); my $sync_path = File::Spec->catfile($repo, '.supertest-dev-sync.json'); my $sync_data = { mode => 'tree-sync', source_branch => 'origin/master', source_commit => $master_commit, previous_dev_commit => $previous_dev_commit, synced_at => iso_now(), }; if ($ctx->{dry_run}) { print "Would replace $DEV_BRANCH tree with origin/master $master_commit, then restore dev authorization and $DEV_KEY_FILE.\n"; run_checked($ctx, 'git', '-C', $repo, 'read-tree', '--reset', '-u', 'origin/master'); run_checked($ctx, 'git', '-C', $repo, 'add', '-A'); run_checked( $ctx, 'git', '-C', $repo, 'commit', '-S' . $DEV_FINGERPRINT_COMPACT, '-m', 'chore: sync supertest dev channel to master', '-m', "Source: guix-tribes master $master_commit\nBase: previous supertest-dev $previous_dev_commit\nMode: tree sync, preserving dev channel authorization", ); authenticate_dev_branch($ctx); run_checked($ctx, 'git', '-C', $repo, 'push', '--force-with-lease', 'origin', $DEV_BRANCH); return; } run_checked($ctx, 'git', '-C', $repo, 'read-tree', '--reset', '-u', 'origin/master'); write_file(File::Spec->catfile($repo, '.guix-authorizations'), ensure_dev_authorization($master_auth)); write_file(File::Spec->catfile($repo, $DEV_KEY_FILE), $dev_key); write_json_file($sync_path, $sync_data); run_checked($ctx, 'git', '-C', $repo, 'add', '-A'); run_checked( $ctx, 'git', '-C', $repo, 'commit', '-S' . $DEV_FINGERPRINT_COMPACT, '-m', 'chore: sync supertest dev channel to master', '-m', "Source: guix-tribes master $master_commit\nBase: previous supertest-dev $previous_dev_commit\nMode: tree sync, preserving dev channel authorization", ); authenticate_dev_branch($ctx); run_checked($ctx, 'git', '-C', $repo, 'push', '--force-with-lease', 'origin', $DEV_BRANCH); print "Synced $DEV_BRANCH tree to origin/master $master_commit.\n"; } sub ensure_dev_authorization { my ($text) = @_; return $text if compact_fingerprint($text) =~ /\Q$DEV_FINGERPRINT_COMPACT\E/; my $entry = qq( ("$DEV_FINGERPRINT"\n (name "$SIGNER_LABEL"))\n); $text =~ s/\)\)\s*\z/$entry))/s or fail('Could not add dev authorization to .guix-authorizations.'); return $text; } sub print_dev_env { my ($commit) = @_; print "SUPERTEST_DEV_CHANNEL_MODE=1\n"; print "SUPERTEST_GUIX_TRIBES_CHANNEL_URL=$CHANNEL_URL\n"; print "SUPERTEST_GUIX_TRIBES_CHANNEL_BRANCH=$DEV_BRANCH\n"; print "SUPERTEST_GUIX_TRIBES_CHANNEL_COMMIT=$commit\n"; print "SUPERTEST_GUIX_TRIBES_INTRO_COMMIT=$INTRO_COMMIT\n"; print "SUPERTEST_GUIX_TRIBES_INTRO_FINGERPRINT=$DEV_FINGERPRINT\n"; print "SUPERTEST_GUIX_TRIBES_SIGNER_FINGERPRINT=$DEV_FINGERPRINT\n"; print "SUPERTEST_GUIX_TRIBES_SIGNER_LABEL=$SIGNER_LABEL\n"; } sub env_hash_for { my ($ctx, $scenario, $guix_commit) = @_; my %env = ( %ENV, SUPERTEST_DEV_CHANNEL_MODE => '1', SUPERTEST_GUIX_TRIBES_CHANNEL_URL => $CHANNEL_URL, SUPERTEST_GUIX_TRIBES_CHANNEL_BRANCH => $DEV_BRANCH, SUPERTEST_GUIX_TRIBES_CHANNEL_COMMIT => $guix_commit, SUPERTEST_GUIX_TRIBES_INTRO_COMMIT => $INTRO_COMMIT, SUPERTEST_GUIX_TRIBES_INTRO_FINGERPRINT => $DEV_FINGERPRINT, SUPERTEST_GUIX_TRIBES_SIGNER_FINGERPRINT => $DEV_FINGERPRINT, SUPERTEST_GUIX_TRIBES_SIGNER_LABEL => $SIGNER_LABEL, SUPERTEST_CERT_MODE => $ctx->{cert_mode}, SUPERTEST_LEGION_REPO => $ctx->{legion_repo}, ); $env{SUPERTEST_KEEP_NODES} = '1' if $ctx->{keep_nodes}; $env{SUPERTEST_PLUGIN_NAME} = $ctx->{plugin} if defined $ctx->{plugin}; $env{SUPERTEST_RUN_ID} = $ctx->{run_id} if defined $ctx->{run_id}; return %env; } sub run_scenario { my ($ctx, $scenario, $guix_commit) = @_; defined $scenario && length $scenario or fail('Missing scenario name.'); my $run_id = $ctx->{run_id} // build_run_id(); $ctx->{run_id} = $run_id; my $artifact_root = File::Spec->catdir($ctx->{root}, '.state', 'supertest', "$run_id-$scenario"); my $scenario_root = File::Spec->catdir($artifact_root, $scenario); my $state_dir = File::Spec->catdir($scenario_root, 'legion-state'); my $cache_dir = File::Spec->catdir($scenario_root, 'legion-cache'); my %env = env_hash_for($ctx, $scenario, $guix_commit); local %ENV = %env; my $latest = { scenario => $scenario, runId => $run_id, artifactRootDir => $artifact_root, scenarioRootDir => $scenario_root, legionStateDir => $state_dir, legionCacheDir => $cache_dir, legionRepo => $ctx->{legion_repo}, certMode => $ctx->{cert_mode}, dnsRecordTtlSeconds => $ENV{LEGION_DNS_RECORD_TTL_SECONDS} // '60', devBranch => $DEV_BRANCH, guixCommit => $guix_commit, createdAt => iso_now(), (defined $ctx->{plugin} ? (plugin => $ctx->{plugin}) : ()), }; if ($ctx->{dry_run}) { print "Would write latest run metadata:\n", encode_json($latest), "\n"; } else { write_latest($ctx, $latest); } run_checked($ctx, 'devenv', 'shell', '--', 'npm', 'run', 'scenario', '--', $scenario); } sub build_run_id { my $now = time(); my $millis = int(($now - int($now)) * 1000); return strftime('%Y-%m-%dt%H%M%S', gmtime($now)) . sprintf('%03dz', $millis); } sub iso_now { return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime()); } sub write_latest { my ($ctx, $data) = @_; my $dir = File::Spec->catdir($ctx->{root}, '.state', 'dev-branch'); make_path($dir); write_file(File::Spec->catfile($dir, 'latest.json'), encode_json($data) . "\n"); } sub read_latest { my ($ctx) = @_; my $path = File::Spec->catfile($ctx->{root}, '.state', 'dev-branch', 'latest.json'); -f $path or fail("No latest dev-branch run metadata found at $path."); return decode_json(read_file($path)); } sub run_legion_from_latest { my ($ctx, @args) = @_; my $latest = read_latest($ctx); my %env = ( %ENV, LEGION_STATE_DIR => $latest->{legionStateDir}, LEGION_CACHE_DIR => $latest->{legionCacheDir}, LEGION_APP_ROOT => $latest->{legionRepo}, LEGION_TEST_CERT_MODE => $latest->{certMode} // 'self-signed', LEGION_DNS_RECORD_TTL_SECONDS => $latest->{dnsRecordTtlSeconds} // '60', ); defined $ENV{LEGION_UNLOCK_PASSWORD} or fail('LEGION_UNLOCK_PASSWORD is required for Legion ssh/rpc helpers.'); local %ENV = %env; run_checked( $ctx, 'devenv', 'shell', '--', 'node', '--import', 'tsx', File::Spec->catfile($latest->{legionRepo}, 'src', 'engine', 'cli-main.ts'), @args, ); } sub command_env { my ($ctx) = @_; require_repo($ctx->{guix_repo}, 'guix-tribes'); run_checked($ctx, 'git', '-C', $ctx->{guix_repo}, 'fetch', 'origin'); my $ref = branch_contains($ctx->{guix_repo}, $INTRO_COMMIT, "origin/$DEV_BRANCH") ? "origin/$DEV_BRANCH" : $DEV_BRANCH; my $commit = current_commit($ctx->{guix_repo}, $ref); print_dev_env($commit); } sub command_ssh { my ($ctx, $args) = @_; my $node = shift @$args; defined $node or fail('ssh requires a node reference.'); shift @$args if @$args && $args->[0] eq '--'; run_legion_from_latest($ctx, 'node', 'ssh', $node, @$args ? ('--', @$args) : ()); } sub command_rpc { my ($ctx, $args) = @_; my $node = shift @$args; defined $node or fail('rpc requires a node reference.'); shift @$args if @$args && $args->[0] eq '--'; @$args or fail('rpc requires an Elixir expression.'); run_legion_from_latest($ctx, 'node', 'rpc', $node, '--', join(' ', @$args)); } sub main { my ($command, $ctx, $args) = parse_args(@ARGV); chdir $ctx->{root} or fail("Failed to chdir to $ctx->{root}: $!"); if ($command eq 'help') { usage(); return 0; } if ($command eq 'reset') { reset_branch($ctx); return 0; } if ($command eq 'prepare') { prepare($ctx); return 0; } if ($command eq 'env') { command_env($ctx); return 0; } if ($command eq 'ssh') { command_ssh($ctx, $args); return 0; } if ($command eq 'rpc') { command_rpc($ctx, $args); return 0; } if ($command eq 'run') { my $scenario = shift @$args; @$args == 0 or fail("Unexpected extra arguments: @$args"); my $commit = prepare($ctx); run_scenario($ctx, $scenario, $commit); return 0; } fail("Unknown command: $command"); } exit main();