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
6 changed files with 529 additions and 1 deletions
|
|
@ -182,7 +182,8 @@ for the later.
|
|||
6. **Reboot**, at home, to see each step hold: the passphrase over SSH (step 2), no
|
||||
desktop (step 3), Docker on the HDD (step 4), the tunnel up before Docker (step 5).
|
||||
|
||||
The instance itself follows (its users, checkout, `.env` and first deploy). Then:
|
||||
The instance itself follows, once: `first-start.md` (its users, checkout, `.env`, the
|
||||
databases, the first start, the first operator, the backups). Then:
|
||||
|
||||
7. **The CI runner in its nightly window** (D6, D16), once `deploy` and its checkout
|
||||
exist. Its user, its folder on the HDD, Forgejo's runner (v13.1.0, the version
|
||||
|
|
|
|||
183
deploy/host/first-start.md
Normal file
183
deploy/host/first-start.md
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
# halkyon.li's first start
|
||||
|
||||
The instance on balthasar, once (ADR 0039), after the host's steps 1 to 6
|
||||
(`README.md`): Learn, Train, Todo and Meditate with Jellyfin, LanguageTool, BRouter,
|
||||
Train's map and place search, all from the start (the developer's choice of
|
||||
8 Nov 2026). Each step leaves what came before running. Commands run on balthasar
|
||||
as the developer (`ssh balthasar` from melchior) unless a step says otherwise; those
|
||||
for `deploy` after `sudo -iu deploy`. Later deploys are `redeploy.sh`'s (D13).
|
||||
|
||||
1. **The sidecars' users**, each ID the sidecar's alone, as its compose file runs it
|
||||
(`useradd` warns that the IDs lie above the system range, which is meant: they must
|
||||
never be a person's, `../sidecars/README.md`):
|
||||
|
||||
```sh
|
||||
for each in jellyfin:61096 languagetool:61081 brouter:61177 maptiles:61178 photon:61179; do
|
||||
name=${each%:*} id=${each#*:}
|
||||
sudo groupadd --system --gid "$id" "$name"
|
||||
sudo useradd --system --uid "$id" --gid "$id" --no-create-home --shell /usr/sbin/nologin "$name"
|
||||
done
|
||||
```
|
||||
|
||||
2. **`deploy`**, which owns the checkout and runs the instance, in Docker's group (so
|
||||
as good as root on this host: its key may run `redeploy.sh` alone, step 3). Its home
|
||||
is on the HDD, as `/home` is:
|
||||
|
||||
```sh
|
||||
sudo useradd --create-home --shell /bin/bash --groups docker deploy
|
||||
sudo -iu deploy
|
||||
ssh-keygen -t ed25519 -N '' -C 'deploy@balthasar: halkyon, read-only' -f ~/.ssh/id_ed25519
|
||||
cat ~/.ssh/id_ed25519.pub
|
||||
```
|
||||
|
||||
In Forgejo (`code.halkyon.app`, the repository's *Settings › Deploy keys*), that key
|
||||
as a deploy key, **without** write access. Then, still as `deploy`, the clone; SSH
|
||||
first shows the forge's host key, which must be
|
||||
`SHA256:98mCW/wdTIK7Jl6vwDE4qQqLECa8wkgDWye7+nQONn4` (ED25519):
|
||||
|
||||
```sh
|
||||
git clone ssh://git@code.halkyon.app/project-halkyon/halkyon.git ~/halkyon
|
||||
```
|
||||
|
||||
3. **The developer's keys**: the GPG key `redeploy.sh` checks every commit's signature
|
||||
with (`deploy/allowed-signers`), and the SSH key it alone may be run with. On
|
||||
melchior:
|
||||
|
||||
```sh
|
||||
gpg --export --armor B172B9ED76A32EBCABA24A9F07F6FCF405488B64 >developer.asc
|
||||
scp developer.asc ~/.ssh/raisondetredev@ramiel.pub balthasar:/tmp/
|
||||
```
|
||||
|
||||
On balthasar, as `deploy`:
|
||||
|
||||
```sh
|
||||
gpg --import /tmp/developer.asc
|
||||
git -C ~/halkyon log -1 --format='%G? %GP' # U B172B9ED…B64: good, vouched for by the list
|
||||
printf 'restrict,command="/home/deploy/halkyon/deploy/redeploy.sh" %s\n' \
|
||||
"$(cat /tmp/raisondetredev@ramiel.pub)" >>~/.ssh/authorized_keys
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
ssh-keygen -lf ~/.ssh/authorized_keys # SHA256:eSJ3RtDT0oJxCUq33B50snmkt1jSYw19Z0U6X+W9+hA
|
||||
```
|
||||
|
||||
(`rm /tmp/developer.asc /tmp/raisondetredev@ramiel.pub` as the developer afterwards.) On melchior, in
|
||||
`~/.ssh/config`, through the tunnel, so it works at home and away alike:
|
||||
|
||||
```
|
||||
Host balthasar-deploy
|
||||
HostName 10.61.97.2
|
||||
User deploy
|
||||
IdentityFile ~/.ssh/raisondetredev@ramiel
|
||||
IdentitiesOnly yes
|
||||
ProxyJump ramiel
|
||||
```
|
||||
|
||||
4. **`.env`**, as `deploy`: every secret made here, the developer's settings, never
|
||||
over an existing file (`halkyon-li-env.sh`):
|
||||
|
||||
```sh
|
||||
cd ~/halkyon
|
||||
sh deploy/host/halkyon-li-env.sh deploy/.env
|
||||
cat deploy/.env # the whole file into Proton Pass: Infra, a note "halkyon.li .env"
|
||||
```
|
||||
|
||||
5. **SimpleLogin's app**, the only way in (D4): in SimpleLogin, *Settings › Developer ›
|
||||
New website*, named *Halkyon (halkyon.li)*, its URL `https://learn.halkyon.li`, its
|
||||
redirect URL (callback) `https://learn.halkyon.li/simplelogin-callback`. Its client ID
|
||||
and secret into `deploy/.env` (`SIMPLELOGIN_CLIENT_ID`, `SIMPLELOGIN_CLIENT_SECRET`,
|
||||
as `deploy` with `nano ~/halkyon/deploy/.env`) and into the note in Proton Pass.
|
||||
|
||||
6. **The sidecars' folders, and Jellyfin moved in.** As the developer:
|
||||
|
||||
```sh
|
||||
d=/home/deploy/halkyon/deploy
|
||||
sudo install -d -o languagetool -g languagetool $d/languagetool $d/languagetool/fasttext
|
||||
sudo install -d -o brouter -g brouter $d/brouter/segments
|
||||
sudo install -d -o maptiles -g maptiles $d/maps/tiles
|
||||
sudo install -d -o photon -g photon $d/photon/data
|
||||
```
|
||||
|
||||
Then `../sidecars/README.md`, *Jellyfin on halkyon.li*, steps 2 to 5: the library
|
||||
to `/data/media`, the configuration and cache into the deploy, `network.xml` for
|
||||
Caddy, ADR 0032's folder removed. `.env` names the library already.
|
||||
|
||||
7. **The databases.** PostgreSQL first, alone, which makes the instance's superuser
|
||||
`halkyon_learn` with `.env`'s password; then a role and a database for each of the
|
||||
other three, as `../sidecars/README.md` explains for Train's (*Train*, step 2: each
|
||||
role logs in and is nothing more, owns its database alone and connects to no
|
||||
other). As `deploy`, the passwords read from `.env` rather than typed:
|
||||
|
||||
```sh
|
||||
cd ~/halkyon/deploy
|
||||
docker compose up -d --wait postgres
|
||||
set -a; . ./.env; set +a
|
||||
docker compose exec -T postgres psql -U halkyon_learn -d postgres -v ON_ERROR_STOP=1 \
|
||||
-v train="$TRAIN_POSTGRES_PASSWORD" -v todo="$TODO_POSTGRES_PASSWORD" \
|
||||
-v meditate="$MEDITATE_POSTGRES_PASSWORD" <<'SQL'
|
||||
CREATE ROLE halkyon_train LOGIN PASSWORD :'train';
|
||||
CREATE DATABASE halkyon_train OWNER halkyon_train;
|
||||
CREATE ROLE halkyon_todo LOGIN PASSWORD :'todo';
|
||||
CREATE DATABASE halkyon_todo OWNER halkyon_todo;
|
||||
CREATE ROLE halkyon_meditate LOGIN PASSWORD :'meditate';
|
||||
CREATE DATABASE halkyon_meditate OWNER halkyon_meditate;
|
||||
REVOKE CONNECT ON DATABASE halkyon_learn FROM PUBLIC;
|
||||
REVOKE CONNECT ON DATABASE halkyon_train FROM PUBLIC;
|
||||
REVOKE CONNECT ON DATABASE halkyon_todo FROM PUBLIC;
|
||||
REVOKE CONNECT ON DATABASE halkyon_meditate FROM PUBLIC;
|
||||
SQL
|
||||
exit # leaves deploy's shell, with .env's secrets in it
|
||||
```
|
||||
|
||||
8. **The first start**, as `deploy` in `~/halkyon/deploy`. It builds every image (the
|
||||
four servers, the dictionaries, BRouter, the map's and Photon's), half an hour or
|
||||
more, then starts everything; each server applies its migrations as it starts:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
docker compose ps # every service up; the servers healthy within a minute or two
|
||||
```
|
||||
|
||||
What builds on afterwards, by itself: the dictionaries (some 3 GB downloaded within
|
||||
the first hour), BRouter's segments for southern Bavaria (minutes), the map's cut
|
||||
(an hour or more) and Photon's Germany (7.5 GB, an hour or more). Until then Train
|
||||
has no routes, an empty map and no search. From melchior, anywhere:
|
||||
|
||||
```sh
|
||||
for name in learn todo train meditate jellyfin; do
|
||||
curl -s -o /dev/null -w "$name.halkyon.li %{http_code}\n" https://$name.halkyon.li/
|
||||
done
|
||||
```
|
||||
|
||||
Every name answers (200, or a redirect), over a certificate Caddy got at home, as
|
||||
ramiel passes TLS through unopened.
|
||||
|
||||
9. **The first operator** (ADR 0021, D20, D32): in Halkyon Learn, another instance,
|
||||
`https://learn.halkyon.li`, *Sign in with SimpleLogin*, a handle chosen; the account
|
||||
then waits. As `deploy`, in `~/halkyon/deploy`, the developer's handle in lower case
|
||||
in place of `their_handle`:
|
||||
|
||||
```sh
|
||||
docker compose exec -T postgres psql -U halkyon_learn -d halkyon_learn -v ON_ERROR_STOP=1 <<'SQL'
|
||||
BEGIN;
|
||||
UPDATE "serverpod_auth_core_user"
|
||||
SET "scopeNames" = ("scopeNames"::jsonb || '["operator"]')::json
|
||||
WHERE "id" = (SELECT "authUserId" FROM "public_profile" WHERE "handleKey" = 'their_handle')
|
||||
AND NOT ("scopeNames"::jsonb ? 'operator');
|
||||
UPDATE "serverpod_auth_core_session"
|
||||
SET "scopeNames" = ("scopeNames"::jsonb || '["operator"]')::json
|
||||
WHERE "authUserId" = (SELECT "authUserId" FROM "public_profile" WHERE "handleKey" = 'their_handle')
|
||||
AND NOT ("scopeNames"::jsonb ? 'operator');
|
||||
COMMIT;
|
||||
SQL
|
||||
```
|
||||
|
||||
`UPDATE 1` twice, or the first once and the second `UPDATE 0` while there's no
|
||||
session yet. Back in the app, *Try again* on the wait screen: an operator never
|
||||
waits, and Settings shows *This instance*. Everyone else now signs up and waits for
|
||||
the operator, who approves them there, the four words read out first.
|
||||
|
||||
10. **Backups** (D18): `../backups.md`, *Setting it up*, for halkyon.li: on the same
|
||||
Storage Box a sub-account of its own (`halkyon-li`), a key and a restic repository
|
||||
of their own, the repository's password into Proton Pass (Infra,
|
||||
"halkyon.li restic"), then the timer.
|
||||
|
||||
Then the CI runner, `README.md`, step 7, and the Instance console (ADR 0040).
|
||||
83
deploy/host/halkyon-li-env.sh
Executable file
83
deploy/host/halkyon-li-env.sh
Executable file
|
|
@ -0,0 +1,83 @@
|
|||
#!/bin/sh
|
||||
# Writes halkyon.li's first deploy/.env (ADR 0039; first-start.md, step 4), as deploy,
|
||||
# on the instance's host at home:
|
||||
#
|
||||
# sh deploy/host/halkyon-li-env.sh deploy/.env
|
||||
#
|
||||
# Every secret is made here and now (32 random bytes, in hex), and never leaves the
|
||||
# host but into Proton Pass (vault Infra, a note "halkyon.li .env", the whole file).
|
||||
# It refuses to write over an existing file: the session peppers and the database
|
||||
# passwords must never change once the instance runs (.env.example says why for each).
|
||||
# The settings are the developer's choices of 8 Nov 2026; .env.example explains each
|
||||
# one. SimpleLogin's client ID and secret stay empty until its app is registered
|
||||
# (first-start.md, step 5).
|
||||
set -eu
|
||||
|
||||
target=${1:?usage: halkyon-li-env.sh <path of the new .env>}
|
||||
if [ -e "$target" ]; then
|
||||
echo "Error: $target exists; its secrets must stay as they are." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
secret() { openssl rand -hex 32; }
|
||||
|
||||
umask 077
|
||||
cat >"$target" <<EOF
|
||||
# halkyon.li's .env, written by deploy/host/halkyon-li-env.sh on $(date -u +%Y-%m-%d).
|
||||
# Its secrets are in Proton Pass (Infra, "halkyon.li .env"). .env.example explains each
|
||||
# setting.
|
||||
|
||||
# Names (ADR 0039, D7): handles on halkyon.li itself.
|
||||
LEARN_DOMAIN=learn.halkyon.li
|
||||
LEARN_PUBLIC_HOST=learn.halkyon.li
|
||||
ACCOUNT_DOMAIN=halkyon.li
|
||||
TRAIN_DOMAIN=train.halkyon.li
|
||||
TODO_DOMAIN=todo.halkyon.li
|
||||
MEDITATE_DOMAIN=meditate.halkyon.li
|
||||
JELLYFIN_DOMAIN=jellyfin.halkyon.li
|
||||
|
||||
# Everything runs, behind ramiel's forward (compose.forwarded.yaml last, its ports win).
|
||||
COMPOSE_FILE=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
|
||||
|
||||
# The register (ADR 0016), which the four services share.
|
||||
INSTANCE_SECRET=$(secret)
|
||||
|
||||
# Who comes in: SimpleLogin alone, approved by an operator (ADR 0039, D4). No mail, so
|
||||
# neither email codes nor passwords.
|
||||
SIGN_IN_WAYS=simplelogin
|
||||
SIGN_UP=approval
|
||||
SIGN_IN_DECOY_KEY=$(secret)
|
||||
# From SimpleLogin's app (first-start.md, step 5).
|
||||
SIMPLELOGIN_CLIENT_ID=
|
||||
SIMPLELOGIN_CLIENT_SECRET=
|
||||
|
||||
# Learn: the instance's PostgreSQL superuser and Learn's session pepper.
|
||||
LEARN_POSTGRES_PASSWORD=$(secret)
|
||||
LEARN_SESSION_KEY_HASH_PEPPER=$(secret)
|
||||
|
||||
# Train, with BRouter, its own map and Photon. Until the Instance console cuts regions
|
||||
# (ADR 0040): the map and routes over southern Bavaria, the search over Germany, the
|
||||
# smallest Photon's databases come in, with memory to fit beside the rest.
|
||||
TRAIN_POSTGRES_PASSWORD=$(secret)
|
||||
TRAIN_SESSION_KEY_HASH_PEPPER=$(secret)
|
||||
BROUTER_REGION=10,45,15,50
|
||||
MAP_KEY_SECRET=$(secret)
|
||||
MAP_REGION=10,47.2,13.9,49
|
||||
PHOTON_REGION=europe/germany
|
||||
PHOTON_HEAP=2g
|
||||
PHOTON_MEMORY=3g
|
||||
|
||||
# Todo and Meditate (no library yet).
|
||||
TODO_POSTGRES_PASSWORD=$(secret)
|
||||
TODO_SESSION_KEY_HASH_PEPPER=$(secret)
|
||||
MEDITATE_POSTGRES_PASSWORD=$(secret)
|
||||
MEDITATE_SESSION_KEY_HASH_PEPPER=$(secret)
|
||||
|
||||
# Jellyfin's library on the HDD (sidecars/README.md, Jellyfin on halkyon.li).
|
||||
JELLYFIN_MEDIA=/data/media
|
||||
|
||||
# What the app tells people: the iMac at home and the Storage Box, both in Germany.
|
||||
DATA_LOCATION=DE
|
||||
BACKUP_LOCATION=DE
|
||||
EOF
|
||||
echo "Written: $target"
|
||||
|
|
@ -198,6 +198,18 @@
|
|||
unlocking by SSH). dracut's last image stays in GRUB's menu, shown for 5 seconds, as
|
||||
the way back until initramfs-tools' images have booted, so a failed image costs a
|
||||
choice at the iMac rather than a USB stick.
|
||||
- **D21 The first start** (the developer's choices of 8 Nov 2026; the steps:
|
||||
`deploy/host/first-start.md`). `.env` is written on balthasar by
|
||||
`deploy/host/halkyon-li-env.sh`, every secret made there, never over an existing
|
||||
file, and copied whole into Proton Pass (over rendering it from Proton Pass on
|
||||
melchior at every change). Everything runs from the start: Jellyfin, LanguageTool,
|
||||
BRouter, Train's map and place search beside the four servers. Until the console
|
||||
cuts regions (ADR 0040), the map and routes cover southern Bavaria (the map
|
||||
`10,47.2,13.9,49`, BRouter `10,45,15,50`) and the search Germany, the smallest
|
||||
Photon's databases come in, with a 2 GB heap in 3 GB, as Europe's (45 GB of tiles
|
||||
and twice that while refreshing, 32.6 GB for Photon with 6 to 8 GB of memory) fits
|
||||
neither the HDD beside the 704 GB library nor the 7.2 GB of memory. The app says
|
||||
Germany for the data and the backups (`DE`). Meditate starts without a library.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
|
|
|||
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