import 'dart:io'; import 'package:test/test.dart'; import 'package:yaml/yaml.dart'; /// The CI runner on halkyon.li's host at home (ADR 0039, D6, D16): jobs in /// the nightly window alone, while the heavy sidecars rest, and a sandbox /// that keeps them away from the instance, the home network and the tunnel. void main() { String read(String path) => File(path).readAsStringSync(); /// [unit]'s value of [key], the last where it is set more than once. String? setting(String unit, String key) => RegExp('^$key=(.*)\$', multiLine: true).allMatches(unit).lastOrNull?[1]; group("the runner's sandbox (D16)", () { final unit = read('deploy/runner/forgejo-runner.service'); test('reaches no IPv6 address at all, so no home address, however its ' 'prefix changes', () { expect(setting(unit, 'IPAddressDeny')!.split(' '), contains('::/0')); }); test('reaches no private IPv4 network: the home network, Docker, the ' 'tunnel, link-local, CGNAT', () { expect( setting(unit, 'IPAddressDeny')!.split(' '), containsAll([ '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', '169.254.0.0/16', ]), ); }); test('sees nothing of /home, nor of /data but its own folder', () { expect(setting(unit, 'ProtectHome'), 'yes'); expect(setting(unit, 'TemporaryFileSystem'), '/data:ro'); expect( setting(unit, 'BindPaths'), '/data/forgejo-runner:/var/lib/forgejo-runner', ); expect(unit, contains('RequiresMountsFor=/data')); }); test("can't reach Docker or containerd", () { final hidden = setting(unit, 'InaccessiblePaths')!.split(' '); expect( hidden, containsAll([ '-/run/docker.sock', '-/run/containerd', '-/var/lib/docker', '-/var/lib/containerd', ]), ); }); test('leaves the instance its share of memory, swapping rather than ' 'starving it', () { expect(setting(unit, 'MemoryMax'), '5G'); expect(setting(unit, 'MemoryHigh'), '4.5G'); expect(setting(unit, 'MemorySwapMax'), isNull); expect(setting(unit, 'OOMScoreAdjust'), '1000'); }); }); group('the nightly window (D6)', () { final unit = read('deploy/runner/forgejo-runner.service'); final timer = read('deploy/runner/forgejo-runner.timer'); test('starts at 02:00 alone, never at boot or to catch up', () { expect(setting(timer, 'OnCalendar'), '*-*-* 02:00:00'); expect(setting(timer, 'Persistent'), 'false'); expect(unit, isNot(contains('WantedBy=multi-user.target'))); }); test('ends three hours later, whatever runs then', () { expect(setting(unit, 'RuntimeMaxSec'), '3h'); }); group('rests the heavy sidecars around it, as deploy', () { final night = read('deploy/night.sh'); final rest = RegExp( r'^heavy="(.+)"$', multiLine: true, ).firstMatch(night)![1]!.split(' '); test('the ones that take memory or build', () { expect( rest, unorderedEquals([ 'jellyfin', 'languagetool', 'dictionaries', 'brouter', 'brouter-segments', 'map-tiles', 'map-tiles-refresh', 'photon', 'photon-data', ]), ); }); test('never Caddy, PostgreSQL or an app server', () { expect( rest, isNot( anyOf( contains('caddy'), contains('postgres'), contains('server'), contains('train-server'), contains('todo-server'), contains('meditate-server'), ), ), ); }); test('before the runner starts and after it ends', () { expect( setting(read('deploy/runner/halkyon-night.timer'), 'OnCalendar'), '*-*-* 01:55:00', ); expect( setting(read('deploy/runner/halkyon-morning.timer'), 'OnCalendar'), '*-*-* 05:00:00', ); final evening = read('deploy/runner/halkyon-night.service'); final morning = read('deploy/runner/halkyon-morning.service'); expect(setting(evening, 'User'), 'deploy'); expect(setting(morning, 'User'), 'deploy'); expect(setting(evening, 'ExecStart'), contains('night.sh stop')); expect(setting(morning, 'ExecStart'), contains('night.sh start')); }); }); }); group('the workflow', () { final workflow = loadYaml(read('.forgejo/workflows/ci.yaml')) as YamlMap; final jobs = workflow['jobs'] as YamlMap; test('checks on the runner at home', () { expect((jobs['check'] as YamlMap)['runs-on'], 'balthasar'); expect(read('deploy/runner/config.yml'), contains('"balthasar:host"')); }); test('deploys nothing: halkyon.li deploys by hand (D13)', () { expect(jobs.keys, ['check']); }); test('first proves it reaches neither the tunnel, the home network nor ' 'any IPv6 address', () { final sandbox = ((jobs['check'] as YamlMap)['steps'] as YamlList) .cast() .singleWhere((step) => step['name'] == 'Sandbox')['run'] as String; expect(sandbox, contains('/dev/tcp/10.61.97.2/443')); expect(sandbox, contains('/dev/tcp/192.168.178.1/80')); expect(sandbox, contains('/dev/tcp/2606:4700:4700::1111/443')); expect(sandbox, contains('/data/home')); expect(sandbox, isNot(contains('10.61.96.2'))); }); }); }