feat(deploy): halkyon.li's first start, its .env made on the host (ADR 0039, D21)
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
first-start.md takes the instance from a bare host to running, once: the sidecars' users, deploy with a read-only key to the forge and the developer's keys (GPG for redeploy.sh's check, SSH pinned to it), .env, SimpleLogin's app, the sidecars' folders and Jellyfin moved in, a role and database for each app's server, the first start, the first operator by ADR 0021's SQL, and the backups. halkyon-li-env.sh writes .env with every secret made on the host and never over an existing file, as the peppers must not change once people sign in: SimpleLogin alone with approval, everything running behind the forward, Train's map and routes over southern Bavaria and its search over Germany until the Instance console cuts regions, Germany as where data and backups are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
cc70e09913
commit
916799c051
1 changed files with 529 additions and 1 deletions
107
test/deploy/first_start_test.dart
Normal file
107
test/deploy/first_start_test.dart
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// halkyon.li's first start (deploy/host/first-start.md, ADR 0039) against what the
|
||||
/// compose files run: the sidecars' users as their containers run, their folders
|
||||
/// where the containers mount them, and a role and database for each app's server.
|
||||
void main() {
|
||||
final steps = File('deploy/host/first-start.md').readAsStringSync();
|
||||
String compose(String name) => File('deploy/$name').readAsStringSync();
|
||||
|
||||
/// The users step 1 makes, by name: their IDs.
|
||||
final users = {
|
||||
for (final match in RegExp(
|
||||
r'(\w+):(\d{5})',
|
||||
).allMatches(RegExp('for each in (.+); do').firstMatch(steps)![1]!))
|
||||
match[1]!: match[2]!,
|
||||
};
|
||||
|
||||
test("makes each sidecar's user with the ID its container runs as", () {
|
||||
const runs = {
|
||||
'jellyfin': 'compose.jellyfin.yaml',
|
||||
'languagetool': 'compose.languagetool.yaml',
|
||||
'brouter': 'compose.train.yaml',
|
||||
'maptiles': 'compose.maps.yaml',
|
||||
'photon': 'compose.search.yaml',
|
||||
};
|
||||
expect(users.keys, unorderedEquals(runs.keys));
|
||||
for (final MapEntry(key: name, value: file) in runs.entries) {
|
||||
final id = users[name]!;
|
||||
expect(compose(file), contains('user: "$id:$id"'), reason: name);
|
||||
}
|
||||
});
|
||||
|
||||
test("never gives a sidecar the runner's ID, nor one ID twice", () {
|
||||
expect(users.values, isNot(contains('61000')));
|
||||
expect(users.values.toSet(), hasLength(users.length));
|
||||
});
|
||||
|
||||
test("makes each sidecar's folder its own, where its container mounts "
|
||||
'it', () {
|
||||
const folders = {
|
||||
'languagetool': (r'$d/languagetool/fasttext', './languagetool/fasttext'),
|
||||
'brouter': (r'$d/brouter/segments', './brouter/segments'),
|
||||
'maptiles': (r'$d/maps/tiles', './maps/tiles'),
|
||||
'photon': (r'$d/photon/data', './photon/data'),
|
||||
};
|
||||
final mounted = [
|
||||
for (final file in [
|
||||
'compose.languagetool.yaml',
|
||||
'compose.train.yaml',
|
||||
'compose.maps.yaml',
|
||||
'compose.search.yaml',
|
||||
])
|
||||
compose(file),
|
||||
].join('\n');
|
||||
for (final MapEntry(key: owner, value: (made, mount)) in folders.entries) {
|
||||
expect(
|
||||
RegExp('sudo install -d -o $owner -g $owner .*${RegExp.escape(made)}')
|
||||
.hasMatch(steps),
|
||||
isTrue,
|
||||
reason: made,
|
||||
);
|
||||
expect(mounted, contains(mount), reason: mount);
|
||||
}
|
||||
});
|
||||
|
||||
test("gives each app's server a role of its own, owning its database "
|
||||
'alone, and lets no role connect to a database not its own', () {
|
||||
for (final app in ['train', 'todo', 'meditate']) {
|
||||
expect(
|
||||
steps,
|
||||
contains("CREATE ROLE halkyon_$app LOGIN PASSWORD :'$app';"),
|
||||
reason: app,
|
||||
);
|
||||
expect(
|
||||
steps,
|
||||
contains('CREATE DATABASE halkyon_$app OWNER halkyon_$app;'),
|
||||
reason: app,
|
||||
);
|
||||
expect(
|
||||
compose('compose.$app.yaml'),
|
||||
contains(
|
||||
r'${'
|
||||
'${app.toUpperCase()}_POSTGRES_PASSWORD',
|
||||
),
|
||||
reason: app,
|
||||
);
|
||||
}
|
||||
for (final app in ['learn', 'train', 'todo', 'meditate']) {
|
||||
expect(
|
||||
steps,
|
||||
contains('REVOKE CONNECT ON DATABASE halkyon_$app FROM PUBLIC;'),
|
||||
reason: app,
|
||||
);
|
||||
}
|
||||
expect(steps, isNot(contains('SUPERUSER')));
|
||||
expect(steps, isNot(contains('CREATEDB')));
|
||||
});
|
||||
|
||||
test("pins the developer's key on deploy to redeploy.sh alone", () {
|
||||
expect(
|
||||
steps,
|
||||
contains('restrict,command="/home/deploy/halkyon/deploy/redeploy.sh" %s'),
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue