App-Ariza.git | t/ | 17-runner.rakutest
use v6.d;
use Test;
use App::Ariza::Resources;
use App::Ariza::Runner;
use App::Ariza::Tools;
plan 9;
sub tmp-dir(--> IO::Path) {
my $dir = $*TMPDIR.add("ariza-runner-{$*PID}-{(^1_000_000).pick}");
$dir.mkdir;
$dir;
}
#| A stand-in download: writes fixed bytes wherever it is pointed, and
#| records what it was asked for. The real one is `http-download`, which
#| every other module in ariza also takes as a seam.
sub fake-download(Str $body, @log --> Callable) {
-> $url, $dest {
@log.push($url);
ensure-dir($dest.IO.parent);
$dest.IO.spurt($body);
$dest.IO;
}
}
#| The sha256 of `$body`, computed the way ariza computes every digest,
#| so a pin file written here is one `fetch` will accept.
sub digest-of(Str $body --> Str) {
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $f = $dir.add('payload');
$f.spurt($body);
sha256-file($f);
}
sub write-file(IO::Path $path, Str $text --> IO::Path) {
$path.spurt($text);
$path;
}
sub pins-file(IO::Path $dir, %entries --> IO::Path) {
my $path = $dir.add('runner-checksums.txt');
$path.spurt("# a pin file\n\n"
~ %entries.sort.map({ "{.value} {.key}\n" }).join);
$path;
}
subtest 'a platform either has a runner or has none, and says which', {
plan 7;
is App::Ariza::Runner.arch-for('windows-x86_64'), 'x86_64',
'the 64-bit Intel Windows slug';
is App::Ariza::Runner.arch-for('windows-arm64'), 'aarch64',
'and the ARM one, named the way the release artefact is';
# Not an error, and not a special case anywhere: a POSIX bundle has
# a shell script for a launcher and needs nothing compiled.
nok App::Ariza::Runner.arch-for('macos-arm64').defined,
'macOS has no runner';
nok App::Ariza::Runner.arch-for('linux-x86_64-glibc').defined,
'nor Linux';
is App::Ariza::Runner.artifact-name('windows-x86_64'),
'ariza-runner-windows-x86_64.exe', 'the published filename';
is App::Ariza::Runner.artifact-name('windows-arm64'),
'ariza-runner-windows-aarch64.exe', 'per architecture';
nok App::Ariza::Runner.artifact-name('linux-x86_64-glibc').defined,
'and nothing to name for a platform that has none';
};
subtest 'the tag and the URL come from the shipped resources', {
plan 4;
my $tag = App::Ariza::Runner.tag;
ok $tag.defined && $tag.chars, 'resources/RUNNER_VERSION names a tag';
ok $tag.starts-with('runner-v'),
'in the shape the release workflow gates on';
my $url = App::Ariza::Runner.url(:slug<windows-x86_64>);
ok $url.starts-with('https://github.com/m-doughty/App-Ariza/releases/download/'),
'the URL is an App-Ariza release asset — the runner is ariza\'s own';
ok $url.ends-with("/$tag/ariza-runner-windows-x86_64.exe"),
'at the pinned tag, named for the architecture';
};
subtest 'only runner-v2 and later can authenticate update handoffs', {
plan 6;
nok App::Ariza::Runner.update-handoff-capable(Str),
'an absent tag is not capable';
nok App::Ariza::Runner.update-handoff-capable('runner-v1'),
'the original runner predates the handoff';
ok App::Ariza::Runner.update-handoff-capable('runner-v2'),
'runner-v2 introduces it';
ok App::Ariza::Runner.update-handoff-capable('runner-v12'),
'and later numeric protocol-capable runners retain it';
nok App::Ariza::Runner.update-handoff-capable('runner-v2-beta'),
'prerelease-like tags are not inferred to be capable';
nok App::Ariza::Runner.update-handoff-capable('v2'),
'nor tags outside the runner release namespace';
};
subtest 'the pin file is read strictly, because a skipped pin is an unverified download', {
plan 6;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $sha = 'a' x 64;
my $path = $dir.add('pins.txt');
$path.spurt("# a comment\n\n$sha ariza-runner-windows-x86_64.exe\n"
~ "{'B' x 64} ariza-runner-windows-aarch64.exe\n");
my %pins = App::Ariza::Runner.pins($path);
is %pins<ariza-runner-windows-x86_64.exe>, $sha,
'entries are read as artefact => digest';
is %pins<ariza-runner-windows-aarch64.exe>, 'b' x 64,
'and the digest is lowercased, the way sha256-file reports one';
is +%pins, 2, 'comments and blank lines contribute nothing';
$path.spurt("$sha fine.exe\nnot a checksum line at all\n");
throws-like { App::Ariza::Runner.pins($path) }, Exception,
message => /'line 2'/,
'a line that is neither a comment nor an entry dies naming it';
# Dying part way through a file is exactly when a lazy read leaks
# its handle: `IO::Path.lines` closes when the sequence is
# exhausted, and a die on line two exhausts nothing. POSIX does not
# care -- an unlinked-but-open file just goes away -- so this
# assertion is here to fail on Windows, where the leak makes the
# file undeletable and would otherwise blow up in a LEAVE block
# several assertions later, naming nothing useful.
lives-ok { $path.unlink },
'and the file it refused can be deleted afterwards, so no handle'
~ ' was left open on it';
is +App::Ariza::Runner.pins(write-file($dir.add('empty.txt'), '')), 0,
'while an empty file is simply no pins';
};
subtest 'the shipped pin file is one of the two states it is allowed to be in', {
plan 2;
my %pins = App::Ariza::Runner.pins;
ok %pins.defined, 'resources/runner-checksums.txt parses';
# Either it is still empty — the bootstrap state, where a Windows
# bundle ships its scripts alone — or every artefact it pins is one
# ariza would actually ask for. A pin for something else is a typo
# nobody would otherwise notice until a build failed.
my @expected = <windows-x86_64 windows-arm64>.map({
App::Ariza::Runner.artifact-name($_) });
is %pins.keys.grep({ !@expected.first($_) }).List, (),
'and pins nothing ariza would never download';
};
subtest 'a bundle with nothing pinned is built without the executable', {
plan 4;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my @log;
# The bootstrap rung: the code that downloads a runner exists before
# the release it downloads can, so a Windows bundle built in that
# window is the same bundle ariza produced before the runner —
# scripts only, and a loud notice rather than a silent difference.
#
# The notice goes to $*ERR, and this test captures it rather than
# letting it land in the harness output: a passing suite printing
# "no pinned runner build yet" reads like something is wrong with
# the checkout's real pins, which this fixture never consults. The
# capture also makes the notice itself an assertion instead of a
# side effect nobody checks.
my $err-file = $dir.add('captured-err.txt');
my %got = do {
my $*ERR = open($err-file, :w);
LEAVE { $*ERR.close }
App::Ariza::Runner.stage(:bundle-dir($dir),
:slug<windows-x86_64>, :exec<exampleapp>,
:pins-path(write-file($dir.add('no-pins.txt'), "# nothing yet\n")),
:download(fake-download('MZ', @log)));
};
nok %got, 'nothing is staged';
is @log.List, (), 'and nothing was downloaded to not stage';
nok $dir.add('bin/exampleapp.exe').e, 'bin/ has no executable in it';
like $err-file.slurp, /'no pinned runner build yet'/,
'and the bootstrap notice was printed, to $*ERR, once';
};
subtest 'a pinned runner is downloaded, verified and staged', {
plan 9;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my @log;
my $body = "MZ this is a launcher\n";
my $pins = pins-file($dir, %(
'ariza-runner-windows-x86_64.exe' => digest-of($body)));
my %staged = App::Ariza::Runner.stage(:bundle-dir($dir.add('bundle')),
:slug<windows-x86_64>, :exec<exampleapp>,
:pins-path($pins), :cache-dir($dir.add('cache')),
:download(fake-download($body, @log)));
my $exe = %staged<path>;
ok $exe.defined && $exe.f, 'the executable is staged';
is $exe.relative($dir.add('bundle')).subst('\\', '/', :g),
'bin/exampleapp.exe',
'at bin/<exec>.exe, beside the sidecar the runner reads';
is $exe.slurp, $body, 'byte for byte what was published';
is +@log, 1, 'downloaded once';
ok @log[0].ends-with('ariza-runner-windows-x86_64.exe'),
'from the release asset for this architecture';
# The manifest records every downloaded component with the URL it
# came from and the digest it was verified against, and this is the
# only step that knows either.
is %staged<sha256>, digest-of($body),
'and the staged file reports the digest it was verified against';
is %staged<artifact>, 'ariza-runner-windows-x86_64.exe',
'with the published artefact it is a copy of';
ok %staged<url>.ends-with('ariza-runner-windows-x86_64.exe'),
'and the URL a reader can fetch it from themselves';
# Second build, same machine: the cache answers and the network is
# not touched — and the cached copy is re-verified on the way past,
# which is what catches a half-written file from a killed run.
my %again = App::Ariza::Runner.stage(:bundle-dir($dir.add('bundle2')),
:slug<windows-x86_64>, :exec<exampleapp>,
:pins-path($pins), :cache-dir($dir.add('cache')),
:download(fake-download($body, @log)));
is +@log, 1, 'a second bundle reuses the cached artefact'
or diag "downloads: {@log.join(', ')}";
};
subtest 'once anything is pinned, every failure is fatal', {
plan 4;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my @log;
my $body = "MZ this is a launcher\n";
# A digest that does not match what arrives. There is deliberately
# no way to proceed: this is an executable that will run on a user's
# machine, and "close enough" is not a verification result.
my $wrong = pins-file($dir, %(
'ariza-runner-windows-x86_64.exe' => 'c' x 64));
throws-like {
App::Ariza::Runner.stage(:bundle-dir($dir.add('b1')),
:slug<windows-x86_64>, :exec<exampleapp>,
:pins-path($wrong), :cache-dir($dir.add('c1')),
:download(fake-download($body, @log)));
}, Exception, message => /'does not match its pin'/,
'a mismatch fails the build';
is +$dir.add('c1').dir.grep(*.d).map(*.dir).flat.grep(*.f), 0,
'and the file that failed is not left in the cache to be reused';
# An architecture nobody published. The bundle is for a platform
# ariza knows, so silence here would produce a Windows bundle whose
# documented entry point simply is not in it.
my $partial = pins-file($dir, %(
'ariza-runner-windows-x86_64.exe' => digest-of($body)));
throws-like {
App::Ariza::Runner.stage(:bundle-dir($dir.add('b2')),
:slug<windows-arm64>, :exec<exampleapp>,
:pins-path($partial), :cache-dir($dir.add('c2')),
:download(fake-download($body, @log)));
}, Exception, message => /'no digest for ariza-runner-windows-aarch64.exe'/,
'an unpinned architecture fails rather than shipping without one';
# A download that never arrives.
throws-like {
App::Ariza::Runner.stage(:bundle-dir($dir.add('b3')),
:slug<windows-x86_64>, :exec<exampleapp>,
:pins-path($partial), :cache-dir($dir.add('c3')),
:download(-> $url, $dest { die "ariza: downloading $url failed" }));
}, Exception, message => /'failed'/, 'so does a failed download';
};
subtest 'a POSIX bundle asks for nothing and gets nothing', {
plan 2;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my @log;
# Not the bootstrap path: this one is silent, because there is
# nothing missing. The pins are real, and still nothing happens.
my $pins = pins-file($dir, %(
'ariza-runner-windows-x86_64.exe' => 'd' x 64));
my %got = App::Ariza::Runner.stage(:bundle-dir($dir.add('bundle')),
:slug<macos-arm64>, :exec<exampleapp>, :pins-path($pins),
:download(fake-download('MZ', @log)));
nok %got, 'nothing is staged for macOS';
is @log.List, (), 'and nothing was fetched';
};