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
2 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'),
|
||||
);
|
||||
});
|
||||
}
|
||||
142
test/deploy/halkyon_li_env_test.dart
Normal file
142
test/deploy/halkyon_li_env_test.dart
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// halkyon.li's first `.env` (ADR 0039; the developer's choices of 8 Nov 2026):
|
||||
/// every secret made fresh on the host, halkyon.li's settings, and never an existing
|
||||
/// file overwritten, as the session peppers must never change once people sign in.
|
||||
void main() {
|
||||
const script = 'deploy/host/halkyon-li-env.sh';
|
||||
|
||||
late Directory folder;
|
||||
setUp(() => folder = Directory.systemTemp.createTempSync('halkyon-li-env'));
|
||||
tearDown(() => folder.deleteSync(recursive: true));
|
||||
|
||||
ProcessResult make(String target) => Process.runSync('sh', [script, target]);
|
||||
|
||||
Map<String, String> settings(String text) => {
|
||||
for (final line in text.split('\n'))
|
||||
if (line.isNotEmpty && !line.startsWith('#'))
|
||||
line.substring(0, line.indexOf('=')): line.substring(
|
||||
line.indexOf('=') + 1,
|
||||
),
|
||||
};
|
||||
|
||||
group('a new .env', () {
|
||||
late Map<String, String> env;
|
||||
late File file;
|
||||
|
||||
setUp(() {
|
||||
file = File('${folder.path}/.env');
|
||||
final result = make(file.path);
|
||||
expect(result.exitCode, 0, reason: '${result.stderr}');
|
||||
env = settings(file.readAsStringSync());
|
||||
});
|
||||
|
||||
test('is readable by its owner alone', () {
|
||||
expect(file.statSync().modeString(), 'rw-------');
|
||||
});
|
||||
|
||||
test("makes every secret fresh, each of 32 bytes, none another's", () {
|
||||
const secrets = [
|
||||
'INSTANCE_SECRET',
|
||||
'LEARN_POSTGRES_PASSWORD',
|
||||
'LEARN_SESSION_KEY_HASH_PEPPER',
|
||||
'SIGN_IN_DECOY_KEY',
|
||||
'TRAIN_POSTGRES_PASSWORD',
|
||||
'TRAIN_SESSION_KEY_HASH_PEPPER',
|
||||
'MAP_KEY_SECRET',
|
||||
'TODO_POSTGRES_PASSWORD',
|
||||
'TODO_SESSION_KEY_HASH_PEPPER',
|
||||
'MEDITATE_POSTGRES_PASSWORD',
|
||||
'MEDITATE_SESSION_KEY_HASH_PEPPER',
|
||||
];
|
||||
for (final name in secrets) {
|
||||
expect(env[name], matches(RegExp(r'^[0-9a-f]{64}$')), reason: name);
|
||||
}
|
||||
expect(
|
||||
secrets.map((name) => env[name]).toSet(),
|
||||
hasLength(secrets.length),
|
||||
);
|
||||
});
|
||||
|
||||
test('differs from the next one made', () {
|
||||
final other = File('${folder.path}/other.env');
|
||||
make(other.path);
|
||||
expect(
|
||||
settings(other.readAsStringSync())['INSTANCE_SECRET'],
|
||||
isNot(env['INSTANCE_SECRET']),
|
||||
);
|
||||
});
|
||||
|
||||
test("serves halkyon.li's names, handles on halkyon.li itself (D7)", () {
|
||||
expect(env['LEARN_DOMAIN'], 'learn.halkyon.li');
|
||||
expect(env['LEARN_PUBLIC_HOST'], 'learn.halkyon.li');
|
||||
expect(env['ACCOUNT_DOMAIN'], 'halkyon.li');
|
||||
expect(env['TRAIN_DOMAIN'], 'train.halkyon.li');
|
||||
expect(env['TODO_DOMAIN'], 'todo.halkyon.li');
|
||||
expect(env['MEDITATE_DOMAIN'], 'meditate.halkyon.li');
|
||||
expect(env['JELLYFIN_DOMAIN'], 'jellyfin.halkyon.li');
|
||||
});
|
||||
|
||||
test('lets people in through SimpleLogin alone, once an operator '
|
||||
'approves them, and mails nothing', () {
|
||||
expect(env['SIGN_IN_WAYS'], 'simplelogin');
|
||||
expect(env['SIGN_UP'], 'approval');
|
||||
expect(env['SIMPLELOGIN_CLIENT_ID'], isEmpty);
|
||||
expect(env['SIMPLELOGIN_CLIENT_SECRET'], isEmpty);
|
||||
for (final name in [
|
||||
'EMAIL_SIGN_IN_PEPPER',
|
||||
'EMAIL_SMTP_HOST',
|
||||
'TOTP_KEY',
|
||||
'RECOVERY_CODE_KEY',
|
||||
]) {
|
||||
expect(env[name] ?? '', isEmpty, reason: name);
|
||||
}
|
||||
});
|
||||
|
||||
test('runs everything chosen, behind the forward, each file there', () {
|
||||
final files = env['COMPOSE_FILE']!.split(':');
|
||||
expect(files, [
|
||||
'compose.learn.yaml',
|
||||
'compose.jellyfin.yaml',
|
||||
'compose.languagetool.yaml',
|
||||
'compose.train.yaml',
|
||||
'compose.maps.yaml',
|
||||
'compose.search.yaml',
|
||||
'compose.todo.yaml',
|
||||
'compose.todo-search.yaml',
|
||||
'compose.meditate.yaml',
|
||||
'compose.forwarded.yaml',
|
||||
]);
|
||||
for (final name in files) {
|
||||
expect(File('deploy/$name').existsSync(), isTrue, reason: name);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps Train's map and routes to southern Bavaria, its search to "
|
||||
'Germany, in memory beside the rest', () {
|
||||
expect(env['MAP_REGION'], '10,47.2,13.9,49');
|
||||
expect(env['BROUTER_REGION'], '10,45,15,50');
|
||||
expect(env['PHOTON_REGION'], 'europe/germany');
|
||||
expect(env['PHOTON_HEAP'], '2g');
|
||||
expect(env['PHOTON_MEMORY'], '3g');
|
||||
});
|
||||
|
||||
test('says Germany for the data and the backups, and reads the library '
|
||||
'from the HDD', () {
|
||||
expect(env['DATA_LOCATION'], 'DE');
|
||||
expect(env['BACKUP_LOCATION'], 'DE');
|
||||
expect(env['JELLYFIN_MEDIA'], '/data/media');
|
||||
expect(env.containsKey('MEDITATE_LIBRARY'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
test('never overwrites an existing .env, whose peppers must stay', () {
|
||||
final file = File('${folder.path}/.env')..writeAsStringSync('KEPT=1\n');
|
||||
final result = make(file.path);
|
||||
expect(result.exitCode, isNot(0));
|
||||
expect(result.stderr, contains('exists'));
|
||||
expect(file.readAsStringSync(), 'KEPT=1\n');
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue