build(ci,deploy)!: the runner at home in a nightly window, deploys by hand (ADR 0039, D6, D13, D16)
The CI runner moves to halkyon.li's host at home and runs from 02:00 to
05:00 alone: a timer starts it, its unit's RuntimeMaxSec ends it, and
night.sh rests the instance's heavy sidecars around it as deploy, while
Caddy, PostgreSQL and the apps' servers keep serving. Jobs pushed during the
day wait in Forgejo's queue.
Its sandbox fits the new host: its folder on the HDD, the rest of /data
hidden, containerd's store out of reach, no private IPv4 network and no
IPv6 at all (the home network's IPv6 addresses are public ones under a
changing prefix, on the internet's own interface), 5 GB of memory with swap
beyond. The Sandbox step checks the tunnel, the home router and IPv6.
BREAKING CHANGE: CI deploys nothing any more: the deploy job goes with
learn.halkyon.eu's server, and halkyon.li deploys by hand with redeploy.sh
once a commit's night's run passed. Jobs run on the runner labelled
balthasar; sahaquiel's runner picks up none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-08 19:20:11 +01:00
|
|
|
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'));
|
|
|
|
|
});
|
2026-11-10 20:00:58 +01:00
|
|
|
|
|
|
|
|
test('starts the heavy sidecars after every boot too', () {
|
|
|
|
|
// A reboot or a power cut between 01:55 and 05:00 would otherwise leave them
|
|
|
|
|
// stopped (unless-stopped keeps a stopped container so) until the next morning;
|
|
|
|
|
// the runner's own timer never catches up, so nothing clashes with it.
|
|
|
|
|
expect(
|
|
|
|
|
setting(read('deploy/runner/halkyon-morning.timer'), 'OnBootSec'),
|
|
|
|
|
isNotNull,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
setting(read('deploy/runner/forgejo-runner.timer'), 'Persistent'),
|
|
|
|
|
'false',
|
|
|
|
|
);
|
|
|
|
|
});
|
build(ci,deploy)!: the runner at home in a nightly window, deploys by hand (ADR 0039, D6, D13, D16)
The CI runner moves to halkyon.li's host at home and runs from 02:00 to
05:00 alone: a timer starts it, its unit's RuntimeMaxSec ends it, and
night.sh rests the instance's heavy sidecars around it as deploy, while
Caddy, PostgreSQL and the apps' servers keep serving. Jobs pushed during the
day wait in Forgejo's queue.
Its sandbox fits the new host: its folder on the HDD, the rest of /data
hidden, containerd's store out of reach, no private IPv4 network and no
IPv6 at all (the home network's IPv6 addresses are public ones under a
changing prefix, on the internet's own interface), 5 GB of memory with swap
beyond. The Sandbox step checks the tunnel, the home router and IPv6.
BREAKING CHANGE: CI deploys nothing any more: the deploy job goes with
learn.halkyon.eu's server, and halkyon.li deploys by hand with redeploy.sh
once a commit's night's run passed. Jobs run on the runner labelled
balthasar; sahaquiel's runner picks up none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-08 19:20:11 +01:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-10 20:14:20 +01:00
|
|
|
group('a window by hand (halkyon-ci-now)', () {
|
|
|
|
|
final script = read('deploy/runner/ci-now.sh');
|
|
|
|
|
|
|
|
|
|
test('rests the heavy sidecars around the runner, unless asked to keep '
|
|
|
|
|
'them', () {
|
|
|
|
|
expect(script, contains('systemctl start halkyon-night'));
|
|
|
|
|
expect(script, contains('systemctl start halkyon-morning'));
|
|
|
|
|
expect(script, contains('--keep-sidecars'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('stops the runner and starts the sidecars again however it ends: '
|
|
|
|
|
'done, interrupted, stopped or its SSH session gone', () {
|
|
|
|
|
final trap = RegExp(
|
|
|
|
|
r'^trap (\S+) (.*)$',
|
|
|
|
|
multiLine: true,
|
|
|
|
|
).firstMatch(script);
|
|
|
|
|
expect(trap, isNotNull);
|
|
|
|
|
expect(trap![2]!.split(' '), containsAll(['EXIT', 'INT', 'TERM', 'HUP']));
|
|
|
|
|
final restore = RegExp(
|
|
|
|
|
'^${trap[1]}\\(\\) \\{([^}]*)\\}',
|
|
|
|
|
multiLine: true,
|
|
|
|
|
).firstMatch(script)![1]!;
|
|
|
|
|
expect(restore, contains('systemctl stop forgejo-runner'));
|
|
|
|
|
expect(restore, contains('systemctl start halkyon-morning'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('leaves the nightly window alone: refuses while the runner runs', () {
|
|
|
|
|
expect(script, contains('systemctl is-active --quiet forgejo-runner'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
"is installed by host step 7, root's own, never deploy's checkout",
|
|
|
|
|
() {
|
|
|
|
|
final readme = read('deploy/host/README.md');
|
|
|
|
|
expect(
|
|
|
|
|
readme,
|
|
|
|
|
contains('deploy/runner/ci-now.sh /usr/local/sbin/halkyon-ci-now'),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
build(ci,deploy)!: the runner at home in a nightly window, deploys by hand (ADR 0039, D6, D13, D16)
The CI runner moves to halkyon.li's host at home and runs from 02:00 to
05:00 alone: a timer starts it, its unit's RuntimeMaxSec ends it, and
night.sh rests the instance's heavy sidecars around it as deploy, while
Caddy, PostgreSQL and the apps' servers keep serving. Jobs pushed during the
day wait in Forgejo's queue.
Its sandbox fits the new host: its folder on the HDD, the rest of /data
hidden, containerd's store out of reach, no private IPv4 network and no
IPv6 at all (the home network's IPv6 addresses are public ones under a
changing prefix, on the internet's own interface), 5 GB of memory with swap
beyond. The Sandbox step checks the tunnel, the home router and IPv6.
BREAKING CHANGE: CI deploys nothing any more: the deploy job goes with
learn.halkyon.eu's server, and halkyon.li deploys by hand with redeploy.sh
once a commit's night's run passed. Jobs run on the runner labelled
balthasar; sahaquiel's runner picks up none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-08 19:20:11 +01:00
|
|
|
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<YamlMap>()
|
|
|
|
|
.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')));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|