feat(meditate,deploy): Meditate beside the instance, and every server's public address from .env

Meditate's deploy step (ADR 0038, step 7; ADR 0039, D14), as Todo's: the
server's Dockerfile with the library's check compiled beside it, its
production config on its own database and role, compose.meditate.yaml as a
member of the register, its site sending /library/* to the web server and
the rest to the API server, the operator's library read-only in a compose
file of its own, and redeploy.sh, backup.sh and the image replay taking it
in.

Every server's public address now comes from .env (Serverpod's
SERVERPOD_API_SERVER_PUBLIC_HOST and _WEB_SERVER_, which win over the
config file): Train, Todo and Meditate from their domains, Learn from
LEARN_PUBLIC_HOST, unset learn.halkyon.eu as before. Without it a server on
halkyon.li would name halkyon.eu in its SimpleLogin callback, profile pages
and privacy page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-08 19:12:31 +01:00
commit fd9c2dfe76
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 517 additions and 8 deletions

View file

@ -9,4 +9,5 @@ What holds for every app and package of this repository is in the [root AGENTS.m
- **The bells** (D5) are `assets/bells/*.wav`, synthesised by `apps/meditate/tool/bells.py` (Python's standard library, run from `apps/meditate`; the same bytes every run) and played with `audioplayers` (`AudioplayersBellPlayer`). Only while the app runs: bells with the screen off are for later. - **The bells** (D5) are `assets/bells/*.wav`, synthesised by `apps/meditate/tool/bells.py` (Python's standard library, run from `apps/meditate`; the same bytes every run) and played with `audioplayers` (`AudioplayersBellPlayer`). Only while the app runs: bells with the screen off are for later.
- **The icon** is Lucide's `sprout` from cobalt through pacific into fern (ADR 0031, D5, D7), drawn by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py meditate` from the root): `assets/icon/halkyon_meditate_icon.png`, Lucide's licence beside it, every platform's launcher icon. - **The icon** is Lucide's `sprout` from cobalt through pacific into fern (ADR 0031, D5, D7), drawn by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py meditate` from the root): `assets/icon/halkyon_meditate_icon.png`, Lucide's licence beside it, every platform's launcher icon.
- **`serverpod start`** runs in `apps/meditate/halkyon_meditate_server` on ports of its own (8280 to 8282, PostgreSQL 8290), so it can run beside Learn's (8080…) and Train's (8180…); its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. - **`serverpod start`** runs in `apps/meditate/halkyon_meditate_server` on ports of its own (8280 to 8282, PostgreSQL 8290), so it can run beside Learn's (8080…) and Train's (8180…); its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them.
- **Not yet**: releases (`apps/meditate/release.dart`, its key, F-Droid listing, snap), the deploy beside learn.halkyon.eu (`compose.meditate.yaml` with the library's folder mounted read-only, Caddy forwarding `/library/*`, `config/production.yaml`, the `Dockerfile`), and what ADR 0038 lists under *Later*. - **The deploy** (ADR 0038, step 7; ADR 0039, D14, 8 Nov 2026): the server's `Dockerfile` (as Todo's, with `bin/check_library` compiled beside the server) and `config/production.yaml` (its database `halkyon_meditate` as its own role; the public host from `.env`, `MEDITATE_DOMAIN`), `deploy/compose.meditate.yaml` (a member of the register) with `sidecars/meditate.caddy` (`/library/*` to the web server, the rest to the API server), the library in `deploy/compose.meditate-library.yaml` (`MEDITATE_LIBRARY`, read-only), `redeploy.sh` and `backup.sh` taking it in; `test/deploy/meditate_deploy_test.dart`; the steps in `deploy/sidecars/README.md`, *Meditate*. Not deployed yet: halkyon.li is its first instance.
- **Not yet**: releases (`apps/meditate/release.dart`, its key, F-Droid listing, snap), and what ADR 0038 lists under *Later*.

View file

@ -0,0 +1,69 @@
# Meditate's server image (ADR 0038, step 7). Built from the repository root (the build
# context), because the workspace's root pubspec and lockfile live there:
#
# docker build -f apps/meditate/halkyon_meditate_server/Dockerfile .
#
# Todo's server image (apps/todo/halkyon_todo_server/Dockerfile) for Meditate: the same
# Dart, the same Ubuntu runtime and start command, and beside the server the library's
# check (bin/check_library.dart, D16), so an operator checks a folder on the host with
# the image the server runs:
#
# docker compose run --rm --no-deps -v /path/to/library:/check:ro \
# --entrypoint ./bin/check_library meditate-server /check
# ── Build stage: compile the server and the check to native executables ───────────
FROM dart:3.13.4 AS build
WORKDIR /app
COPY pubspec.yaml pubspec.lock ./
COPY tool/workspace_only.sh tool/
COPY apps/meditate/halkyon_meditate_server apps/meditate/halkyon_meditate_server
COPY packages/halkyon_base_server packages/halkyon_base_server
COPY packages/halkyon_base_client packages/halkyon_base_client
COPY packages/halkyon_core packages/halkyon_core
COPY packages/halkyon_llm packages/halkyon_llm
COPY packages/halkyon_oidc_server packages/halkyon_oidc_server
COPY packages/halkyon_oidc_client packages/halkyon_oidc_client
# The workspace root with only the server and the modules it builds on, as Learn's
# image has it (the reasons there).
RUN sh tool/workspace_only.sh apps/meditate/halkyon_meditate_server packages/halkyon_base_server packages/halkyon_base_client packages/halkyon_core packages/halkyon_llm packages/halkyon_oidc_server packages/halkyon_oidc_client
RUN dart pub get
WORKDIR /app/apps/meditate/halkyon_meditate_server
RUN dart build cli --target bin/main.dart --output build
RUN mv build/bundle/bin/main build/bundle/bin/server
# The check, built on its own and put beside the server: it reads a folder as the
# server does, with the same code.
RUN dart build cli --target bin/check_library.dart --output build-check
RUN mv build-check/bundle/bin/check_library build/bundle/bin/check_library
RUN mkdir -p config migrations
# ── Runtime stage ─────────────────────────────────────────────────────────────────
# Ubuntu 24.04 as Learn's; CA certificates for outgoing TLS (the operator's
# assistants, which the module forwards, ADR 0021).
FROM ubuntu:24.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV runmode=production
ENV serverid=default
ENV logging=normal
ENV role=monolith
COPY --from=build /app/apps/meditate/halkyon_meditate_server/build/bundle/ ./
COPY --from=build /app/apps/meditate/halkyon_meditate_server/config/ config/
COPY --from=build /app/apps/meditate/halkyon_meditate_server/migrations/ migrations/
COPY --from=build /app/apps/meditate/halkyon_meditate_server/lib/src/generated/protocol.yaml lib/src/generated/protocol.yaml
# No root rights needed: it listens on 8080 and 8082 and writes nothing; the library is
# read-only (compose.meditate-library.yaml).
RUN useradd --system --no-create-home --shell /usr/sbin/nologin app
USER app
# The API server, and the web server with the library's files (D16) and the register's
# calls about accounts gone (ADR 0016), the latter on the instance's own network alone.
EXPOSE 8080 8082
ENTRYPOINT ["/bin/sh", "-c", "exec ./bin/server --mode=$runmode --server-id=$serverid --logging=$logging --role=$role \"$@\"", "--"]
CMD []

View file

@ -0,0 +1,66 @@
# Meditate's server beside the instance's other servers (ADR 0038, step 7),
# behind the instance's Caddy (deploy/compose.meditate.yaml): its API and its web server
# on the one domain, as Todo's; its database in the instance's PostgreSQL. The
# public host is the instance's, from deploy/.env (MEDITATE_DOMAIN), which overrides
# the one below.
# Configuration for the main API server.
apiServer:
port: 8080
publicHost: meditate.halkyon.eu
publicPort: 443
publicScheme: https
# No insightsServer, as Learn's: without the block Serverpod doesn't start Insights.
# Configuration for the web server.
webServer:
port: 8082
publicHost: meditate.halkyon.eu
publicPort: 443
publicScheme: https
# This is the database setup for your servers. The default for the Google Cloud
# Engine Terraform configuration is to connect on a private IP address.
# If you are connecting on a public IP (e.g. on AWS or Google Cloud Run), you
# connect on the public IP of the database e.g. database.examplepod.com.
database:
host: postgres
port: 5432
name: halkyon_meditate
user: halkyon_meditate
# On the instance's own network, never leaving the server.
requireSsl: false
#isUnixSocket: true # defaults to false
#maxConnectionCount: 10 # Defaults to 10, a negative or null value removes the limit
maxRequestSize: 524288 # The maximum size of requests allowed in bytes
# Session logs stay out of the database, for privacy, as Learn's (never re-enable).
sessionLogs:
consoleEnabled: false
persistentEnabled: false
# consoleLogFormat: json # Defaults to "json", options are "text" or "json"
#futureCall:
# enabled: true # Defaults to true, set to false to disable scheduling and running future calls
# executionEnabled: true # Defaults to true
# concurrencyLimit: 1 # Defaults to 1, a negative or null value removes the limit
# scanInterval: 5000 # Unit in milliseconds, defaults to 5000
# Recommended when serving web clients: keeps auth tokens in httpOnly
# cookies. Without this, web tokens live in JavaScript-readable storage,
# where any XSS can steal them. Web clients opt in with
# `client.cookieAuth = true`. Requires `allowedOrigins`; browsers from
# origins not in the list lose cross-origin access, including to public
# endpoints.
#authCookie:
# sameSite: lax # Defaults to "lax", options are "strict" or "none"
# #name: serverpod_auth # Defaults to "serverpod_auth"
# #refreshName: serverpod_auth_refresh # Defaults to "<name>_refresh"
# #domain: examplepod.com # Defaults to host-only; set to share across subdomains
# #path: / # Defaults to "/"
# #secure: true # Defaults to true; the cookie is only sent over https
#allowedOrigins:
# - https://app.examplepod.com # The origin serving your Flutter web app

View file

@ -7,6 +7,10 @@
# DOMAIN until 2 Nov 2026): `http://localhost` for the local rehearsal; # DOMAIN until 2 Nov 2026): `http://localhost` for the local rehearsal;
# `learn.halkyon.eu` on its server. # `learn.halkyon.eu` on its server.
LEARN_DOMAIN=http://localhost LEARN_DOMAIN=http://localhost
# Learn's server's public address, a host alone, where it isn't learn.halkyon.eu
# (`learn.halkyon.li`): the address its SimpleLogin callback, profile pages and privacy
# page are on. Unset: learn.halkyon.eu.
#LEARN_PUBLIC_HOST=learn.halkyon.li
# The domain people's handles are on (`raisondetredev@halkyon.eu`) and their profile # The domain people's handles are on (`raisondetredev@halkyon.eu`) and their profile
# pages (`https://halkyon.eu/@raisondetredev`, ADR 0011), when it isn't LEARN_DOMAIN's # pages (`https://halkyon.eu/@raisondetredev`, ADR 0011), when it isn't LEARN_DOMAIN's
@ -100,6 +104,14 @@ COMPOSE_FILE=compose.learn.yaml
#TODO_POSTGRES_PASSWORD= #TODO_POSTGRES_PASSWORD=
#TODO_SESSION_KEY_HASH_PEPPER= #TODO_SESSION_KEY_HASH_PEPPER=
# With Halkyon Meditate beside the instance (compose.meditate.yaml, sidecars/README.md,
# Meditate): as Todo's. Its library (compose.meditate-library.yaml, optional): the
# folder on the host with library.yaml, mounted read-only.
#MEDITATE_DOMAIN=meditate.example.org
#MEDITATE_POSTGRES_PASSWORD=
#MEDITATE_SESSION_KEY_HASH_PEPPER=
#MEDITATE_LIBRARY=/data/meditate-library
# The password of the instance's PostgreSQL superuser, halkyon_learn, which Learn's # The password of the instance's PostgreSQL superuser, halkyon_learn, which Learn's
# server signs in as, and backups and deploys dump as (Train's and Todo's servers have # server signs in as, and backups and deploys dump as (Train's and Todo's servers have
# roles of their own), as TRAIN_POSTGRES_PASSWORD and TODO_POSTGRES_PASSWORD are their # roles of their own), as TRAIN_POSTGRES_PASSWORD and TODO_POSTGRES_PASSWORD are their

View file

@ -41,7 +41,7 @@ dump="$HOME/backups/latest.dump"
# (halkyon_<app>) dumped to latest-<app>.dump. # (halkyon_<app>) dumped to latest-<app>.dump.
services=$(docker compose config --services) services=$(docker compose config --services)
apps= apps=
for app in train todo; do for app in train todo meditate; do
if printf '%s\n' "$services" | grep -qx "$app-server"; then if printf '%s\n' "$services" | grep -qx "$app-server"; then
apps="$apps $app" apps="$apps $app"
fi fi

View file

@ -80,6 +80,12 @@ services:
environment: environment:
SERVERPOD_DATABASE_PASSWORD: ${LEARN_POSTGRES_PASSWORD:?set LEARN_POSTGRES_PASSWORD in deploy/.env} SERVERPOD_DATABASE_PASSWORD: ${LEARN_POSTGRES_PASSWORD:?set LEARN_POSTGRES_PASSWORD in deploy/.env}
SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper: ${LEARN_SESSION_KEY_HASH_PEPPER:?set LEARN_SESSION_KEY_HASH_PEPPER in deploy/.env} SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper: ${LEARN_SESSION_KEY_HASH_PEPPER:?set LEARN_SESSION_KEY_HASH_PEPPER in deploy/.env}
# Its public address, which config/production.yaml names otherwise: the host of
# LEARN_DOMAIN where the instance isn't learn.halkyon.eu (halkyon.li's
# learn.halkyon.li). A host alone, never a URL; unset: learn.halkyon.eu, as the
# file has it, which the local rehearsal keeps too.
SERVERPOD_API_SERVER_PUBLIC_HOST: ${LEARN_PUBLIC_HOST:-learn.halkyon.eu}
SERVERPOD_WEB_SERVER_PUBLIC_HOST: ${LEARN_PUBLIC_HOST:-learn.halkyon.eu}
# Email sign-in (ADR 0010), only with the pepper and an SMTP service; empty: off. # Email sign-in (ADR 0010), only with the pepper and an SMTP service; empty: off.
SERVERPOD_PASSWORD_emailSignInPepper: ${EMAIL_SIGN_IN_PEPPER:-} SERVERPOD_PASSWORD_emailSignInPepper: ${EMAIL_SIGN_IN_PEPPER:-}
SERVERPOD_PASSWORD_emailSmtpHost: ${EMAIL_SMTP_HOST:-} SERVERPOD_PASSWORD_emailSmtpHost: ${EMAIL_SMTP_HOST:-}

View file

@ -0,0 +1,17 @@
# The operator's library for Halkyon Meditate (ADR 0038, D16): recordings, pictures and
# their library.yaml in a folder on the host, which Meditate's server reads, rereads every
# ten minutes and serves to people signed in. After compose.meditate.yaml in .env:
#
# COMPOSE_FILE=…:compose.meditate.yaml:compose.meditate-library.yaml
# MEDITATE_LIBRARY=/data/meditate-library
#
# A file of its own, as an instance may run Meditate without a library: its app then
# shows no library section. Read-only: the server never writes into the folder. Check
# a folder before pointing the instance at it (the server's Dockerfile says how).
services:
meditate-server:
environment:
MEDITATE_LIBRARY_DIR: /library
volumes:
- ${MEDITATE_LIBRARY:?set MEDITATE_LIBRARY in deploy/.env}:/library:ro

View file

@ -0,0 +1,60 @@
# Halkyon Meditate's server beside the instance (ADR 0038, step 7; ADR 0039, D14).
# Switched on in .env, with Meditate's domain and secrets:
#
# COMPOSE_FILE=compose.learn.yaml:compose.meditate.yaml
# MEDITATE_DOMAIN=meditate.halkyon.li
#
# Meditate is a member of the instance's register, which Learn's server holds (ADR
# 0016), as Todo is: one account a person, signing in through the register, the shared
# INSTANCE_SECRET on every call between them, which reach each other on the instance's
# own networks alone. Its database is `halkyon_meditate` in the instance's PostgreSQL,
# under a role of its own that can't connect to the other apps'. Its server reads no
# sitting and no dream: every row is sealed on the person's devices (ADR 0038, D7). The
# operator's library (D16) is a file of its own, compose.meditate-library.yaml; without
# it Meditate has none. Setting it up: sidecars/README.md, Meditate.
services:
meditate-server:
build:
context: ..
dockerfile: apps/meditate/halkyon_meditate_server/Dockerfile
image: halkyon-meditate-server
restart: unless-stopped
# As Learn's: migrations at every start (redeploy.sh has applied them before).
command: ["--apply-migrations"]
environment:
SERVERPOD_DATABASE_PASSWORD: ${MEDITATE_POSTGRES_PASSWORD:?set MEDITATE_POSTGRES_PASSWORD in deploy/.env}
SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper: ${MEDITATE_SESSION_KEY_HASH_PEPPER:?set MEDITATE_SESSION_KEY_HASH_PEPPER in deploy/.env}
# Its public address, which config/production.yaml names otherwise: the
# instance's own domain.
SERVERPOD_API_SERVER_PUBLIC_HOST: ${MEDITATE_DOMAIN:?set MEDITATE_DOMAIN in deploy/.env}
SERVERPOD_WEB_SERVER_PUBLIC_HOST: ${MEDITATE_DOMAIN:?set MEDITATE_DOMAIN in deploy/.env}
# The register (ADR 0016): Learn's web server, on the instance's own network,
# and the secret every call between them carries.
ACCOUNT_REGISTER_URL: http://server:8082/
SERVERPOD_PASSWORD_instanceSecret: ${INSTANCE_SECRET:?set INSTANCE_SECRET in deploy/.env}
# Caddy, as for Learn's server (compose.learn.yaml).
TRUSTED_PROXIES: 172.30.80.2 fd4a:6c6b:7e80::2
SOURCE_URL: ${SOURCE_URL:-}
DATA_LOCATION: ${DATA_LOCATION:-}
BACKUP_LOCATION: ${BACKUP_LOCATION:-}
networks:
- default
- edge
depends_on:
postgres:
condition: service_healthy
logging:
driver: journald
caddy:
environment:
MEDITATE_DOMAIN: ${MEDITATE_DOMAIN:?set MEDITATE_DOMAIN in deploy/.env}
volumes:
- ./sidecars/meditate.caddy:/etc/caddy/sidecars/meditate.caddy:ro
server:
environment:
# Meditate's web server, told when an account goes (ADR 0016), beside the members
# the other compose files and INSTANCE_MEMBERS name.
INSTANCE_MEMBER_MEDITATE: http://meditate-server:8082/

View file

@ -24,6 +24,10 @@ services:
environment: environment:
SERVERPOD_DATABASE_PASSWORD: ${TODO_POSTGRES_PASSWORD:?set TODO_POSTGRES_PASSWORD in deploy/.env} SERVERPOD_DATABASE_PASSWORD: ${TODO_POSTGRES_PASSWORD:?set TODO_POSTGRES_PASSWORD in deploy/.env}
SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper: ${TODO_SESSION_KEY_HASH_PEPPER:?set TODO_SESSION_KEY_HASH_PEPPER in deploy/.env} SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper: ${TODO_SESSION_KEY_HASH_PEPPER:?set TODO_SESSION_KEY_HASH_PEPPER in deploy/.env}
# Its public address, which config/production.yaml names otherwise: the
# instance's own domain.
SERVERPOD_API_SERVER_PUBLIC_HOST: ${TODO_DOMAIN:?set TODO_DOMAIN in deploy/.env}
SERVERPOD_WEB_SERVER_PUBLIC_HOST: ${TODO_DOMAIN:?set TODO_DOMAIN in deploy/.env}
# The register (ADR 0016): Learn's web server, on the instance's own network, # The register (ADR 0016): Learn's web server, on the instance's own network,
# and the secret every call between them carries. # and the secret every call between them carries.
ACCOUNT_REGISTER_URL: http://server:8082/ ACCOUNT_REGISTER_URL: http://server:8082/

View file

@ -26,6 +26,10 @@ services:
environment: environment:
SERVERPOD_DATABASE_PASSWORD: ${TRAIN_POSTGRES_PASSWORD:?set TRAIN_POSTGRES_PASSWORD in deploy/.env} SERVERPOD_DATABASE_PASSWORD: ${TRAIN_POSTGRES_PASSWORD:?set TRAIN_POSTGRES_PASSWORD in deploy/.env}
SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper: ${TRAIN_SESSION_KEY_HASH_PEPPER:?set TRAIN_SESSION_KEY_HASH_PEPPER in deploy/.env} SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper: ${TRAIN_SESSION_KEY_HASH_PEPPER:?set TRAIN_SESSION_KEY_HASH_PEPPER in deploy/.env}
# Its public address, which config/production.yaml names otherwise: the
# instance's own domain.
SERVERPOD_API_SERVER_PUBLIC_HOST: ${TRAIN_DOMAIN:?set TRAIN_DOMAIN in deploy/.env}
SERVERPOD_WEB_SERVER_PUBLIC_HOST: ${TRAIN_DOMAIN:?set TRAIN_DOMAIN in deploy/.env}
# The register (ADR 0016): Learn's web server, on the instance's own network, # The register (ADR 0016): Learn's web server, on the instance's own network,
# and the secret every call between them carries. # and the secret every call between them carries.
ACCOUNT_REGISTER_URL: http://server:8082/ ACCOUNT_REGISTER_URL: http://server:8082/

View file

@ -10,8 +10,9 @@
# - as a fast-forward of the deployed commit, # - as a fast-forward of the deployed commit,
# - if every new commit is signed by a key in deploy/allowed-signers, as the deployed # - if every new commit is signed by a key in deploy/allowed-signers, as the deployed
# checkout lists them (so no commit can add its own key), # checkout lists them (so no commit can add its own key),
# dumps the database first (the last 3 dumps stay in ~/backups; Train's and Todo's too where # dumps the database first (the last 3 dumps stay in ~/backups; Train's, Todo's and Meditate's
# .env switches each on, compose.train.yaml, compose.todo.yaml), applies the migrations # too where .env switches each on, compose.train.yaml, compose.todo.yaml,
# compose.meditate.yaml), applies the migrations
# before switching to the new server, starts it once more after a migration (see below), # before switching to the new server, starts it once more after a migration (see below),
# and fails unless that server answers afterwards. A failure leaves the checkout at the deployed commit, so # and fails unless that server answers afterwards. A failure leaves the checkout at the deployed commit, so
# running the CI job again retries. By hand, with your own key, which passes the commit as CI does: # running the CI job again retries. By hand, with your own key, which passes the commit as CI does:
@ -61,10 +62,11 @@ main() {
trap back_out EXIT trap back_out EXIT
cd deploy cd deploy
# The apps' servers beside Learn's, where .env switches each on (compose.train.yaml, # The apps' servers beside Learn's, where .env switches each on (compose.train.yaml,
# compose.todo.yaml): built, dumped, migrated and waited for as Learn's. # compose.todo.yaml, compose.meditate.yaml): built, dumped, migrated and waited for as
# Learn's.
services=$(docker compose config --services) services=$(docker compose config --services)
apps= apps=
for app in train todo; do for app in train todo meditate; do
if printf '%s\n' "$services" | grep -qx "$app-server"; then if printf '%s\n' "$services" | grep -qx "$app-server"; then
apps="$apps $app" apps="$apps $app"
fi fi
@ -136,7 +138,7 @@ main() {
echo "Deployed $commit." echo "Deployed $commit."
} }
# Dumps the database $1 (halkyon_learn, halkyon_train, halkyon_todo), as it is before the new server # Dumps the database $1 (halkyon_learn, halkyon_train, halkyon_todo, halkyon_meditate), as it is before the new server
# applies its migrations; as the instance's superuser, halkyon_learn. # applies its migrations; as the instance's superuser, halkyon_learn.
dump() { dump() {
mkdir -p "$HOME/backups" mkdir -p "$HOME/backups"
@ -153,7 +155,7 @@ dump() {
ls -1t "$HOME"/backups/"$1"-*.dump | tail -n +4 | xargs -r rm -- ls -1t "$HOME"/backups/"$1"-*.dump | tail -n +4 | xargs -r rm --
} }
# Waits up to a minute for the new server $1 (server, train-server, todo-server) to answer inside the # Waits up to a minute for the new server $1 (server, train-server, todo-server, meditate-server) to answer inside the
# stack's network. # stack's network.
wait_until_answering() { wait_until_answering() {
tries=0 tries=0

View file

@ -25,6 +25,8 @@ site changes.
| Photon, which finds places for Train's route planner, beside Train | `compose.search.yaml` | none | | Photon, which finds places for Train's route planner, beside Train | `compose.search.yaml` | none |
| Halkyon Todo's server | `compose.todo.yaml` | `todo.caddy` | | Halkyon Todo's server | `compose.todo.yaml` | `todo.caddy` |
| Todo's place search, on Train's Photon | `compose.todo-search.yaml` | none | | Todo's place search, on Train's Photon | `compose.todo-search.yaml` | none |
| Halkyon Meditate's server | `compose.meditate.yaml` | `meditate.caddy` |
| Meditate's library, the operator's recordings, read-only | `compose.meditate-library.yaml` | none |
Both together: `COMPOSE_FILE=compose.learn.yaml:compose.jellyfin.yaml:compose.languagetool.yaml`. Both together: `COMPOSE_FILE=compose.learn.yaml:compose.jellyfin.yaml:compose.languagetool.yaml`.
@ -527,3 +529,70 @@ Steps, in order:
alone. Without it Todo starts as it did, and its app offers no search. Nothing alone. Without it Todo starts as it did, and its app offers no search. Nothing
searched is kept or logged, and a task's location stays sealed on the person's searched is kept or logged, and a task's location stays sealed on the person's
devices. devices.
## Meditate
Halkyon Meditate's server beside the instance (Halkyon Meditate's ADR 0038, step 7;
ADR 0039, D14), as Todo's: at its own domain, a member of the register Learn's server
holds (ADR 0016), its database `halkyon_meditate` in the instance's PostgreSQL. It
reads no sitting and no dream: every row is sealed on the person's devices (ADR 0038,
D7). `redeploy.sh` builds, dumps, migrates and waits for it once `.env` names its
compose file, and `backup.sh` backs its database up beside the others'.
Steps, in order:
1. **Its database** (the operator, on the instance's server, as `deploy`, in
`~/halkyon/deploy`): a role of Meditate's own, with the password that goes into
`.env` as `MEDITATE_POSTGRES_PASSWORD` (`openssl rand -hex 32`), and its database, as
Todo's:
```sh
docker compose exec -T postgres psql -U halkyon_learn -d postgres -v ON_ERROR_STOP=1 \
-v password="<MEDITATE_POSTGRES_PASSWORD>" <<'SQL'
CREATE ROLE halkyon_meditate LOGIN PASSWORD :'password';
CREATE DATABASE halkyon_meditate OWNER halkyon_meditate;
REVOKE CONNECT ON DATABASE halkyon_meditate FROM PUBLIC;
SQL
```
The role owns its database alone and connects to no other: the `REVOKE`s of
Learn's, Train's and Todo's databases were made with theirs (on an instance without
them, also `REVOKE CONNECT ON DATABASE halkyon_learn FROM PUBLIC;`).
2. **DNS** (the operator, at the domain's DNS): Meditate's domain pointing at the
instance's server (`A`, and `AAAA` if it has an IPv6 address); for halkyon.li, at
ramiel (ADR 0039, D7).
3. **Switch it on** (the operator, as `deploy`): in `deploy/.env`,
`compose.meditate.yaml` added to `COMPOSE_FILE`, and Meditate's settings
(`.env.example`, *With Halkyon Meditate*):
```sh
COMPOSE_FILE=compose.learn.yaml:compose.train.yaml:compose.todo.yaml:compose.meditate.yaml
MEDITATE_DOMAIN=meditate.halkyon.li
MEDITATE_POSTGRES_PASSWORD=<as in step 1>
MEDITATE_SESSION_KEY_HASH_PEPPER=<openssl rand -hex 32>
```
`INSTANCE_SECRET` must be set. The compose file names Meditate to Learn's register
(`INSTANCE_MEMBER_MEDITATE`).
4. **Start it** (the operator, as `deploy`): `docker compose up -d --build`. Caddy and
Learn's server are recreated with it (Meditate's site, and the register's member);
the instance is away for seconds. Meditate's server applies its migrations as it
starts.
5. **Check it**: `https://meditate.halkyon.li/` answers; in Meditate, signed in with the
instance's account, a sitting kept on one device shows on another.
6. **The library** (optional; ADR 0038, D16): recordings, pictures and `library.yaml`
(the server's `library.example.yaml` says how) in a folder on the host, readable by
all (the server runs as an unprivileged user), such as `/data/meditate-library`.
Check it with the server's own image, which reads it as the server will and exits 1
on anything it would refuse:
```sh
docker compose run --rm --no-deps -v /data/meditate-library:/check:ro \
--entrypoint ./bin/check_library meditate-server /check
```
Then, in `deploy/.env`, `compose.meditate-library.yaml` added to `COMPOSE_FILE` after
`compose.meditate.yaml`, and `MEDITATE_LIBRARY=/data/meditate-library`, then
`docker compose up -d`, which recreates Meditate's server alone. It rereads the
folder every ten minutes, so later changes need no restart; check each before it is
copied in. Without the file, Meditate starts as before, and its app shows no library.

View file

@ -0,0 +1,20 @@
# Halkyon Meditate's site (compose.meditate.yaml, ADR 0038, step 7): its API server, and
# the operator's library's files from its web server (D16), which serves them only with
# an access its catalogue gave a person signed in. Never the register's calls between
# the instance's services (`/halkyon-base/…`, ADR 0016), which its web server takes on
# the instance's own network alone: only /library/* goes there.
{$MEDITATE_DOMAIN} {
encode zstd gzip
# Recordings and pictures, served by ranges (encode leaves audio and pictures
# as they are).
handle /library/* {
reverse_proxy meditate-server:8082
}
# Serverpod's streaming methods use WebSockets; reverse_proxy upgrades them as-is.
# The visitor's address comes last in X-Forwarded-For, as for Learn's server.
handle {
reverse_proxy meditate-server:8080
}
}

View file

@ -0,0 +1,178 @@
import 'dart:io';
import 'package:test/test.dart';
import 'package:yaml/yaml.dart';
import '../../tool/src/images.dart';
/// Meditate's server beside the instance (Halkyon Meditate's ADR 0038, step
/// 7; ADR 0039, D14), as Todo's: a member of the register, its own database
/// and role, its site, and the operator's library read-only (D16).
void main() {
YamlMap services(String path) =>
(loadYaml(File(path).readAsStringSync()) as YamlMap)['services']
as YamlMap;
String read(String path) => File(path).readAsStringSync();
group('its server', () {
final server =
services('deploy/compose.meditate.yaml')['meditate-server'] as YamlMap;
final environment = server['environment'] as YamlMap;
test('signs in through the register, with the instance secret', () {
expect(environment['ACCOUNT_REGISTER_URL'], 'http://server:8082/');
expect(
environment['SERVERPOD_PASSWORD_instanceSecret'],
r'${INSTANCE_SECRET:?set INSTANCE_SECRET in deploy/.env}',
);
});
test('has a database password and pepper of its own', () {
expect(
environment['SERVERPOD_DATABASE_PASSWORD'],
startsWith(r'${MEDITATE_POSTGRES_PASSWORD:?'),
);
expect(
environment['SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper'],
startsWith(r'${MEDITATE_SESSION_KEY_HASH_PEPPER:?'),
);
});
test('names its own domain as its public address', () {
for (final variable in [
'SERVERPOD_API_SERVER_PUBLIC_HOST',
'SERVERPOD_WEB_SERVER_PUBLIC_HOST',
]) {
expect(environment[variable], startsWith(r'${MEDITATE_DOMAIN:?'));
}
});
test('has no library unless its own file mounts one', () {
expect(environment.keys, isNot(contains('MEDITATE_LIBRARY_DIR')));
expect(server['volumes'], isNull);
});
test("is told to Learn's register as a member", () {
final learn =
services('deploy/compose.meditate.yaml')['server'] as YamlMap;
expect(
(learn['environment'] as YamlMap)['INSTANCE_MEMBER_MEDITATE'],
'http://meditate-server:8082/',
);
});
test('connects to its own database as its own role', () {
final config = loadYaml(
read('apps/meditate/halkyon_meditate_server/config/production.yaml'),
) as YamlMap;
final database = config['database'] as YamlMap;
expect(database['name'], 'halkyon_meditate');
expect(database['user'], 'halkyon_meditate');
expect(database['host'], 'postgres');
expect((config['sessionLogs'] as YamlMap)['persistentEnabled'], isFalse);
expect(config.keys, isNot(contains('insightsServer')));
});
});
group("the operator's library (D16)", () {
final server =
services('deploy/compose.meditate-library.yaml')['meditate-server']
as YamlMap;
test('is mounted read-only where the server reads it', () {
expect(server['volumes'], [
r'${MEDITATE_LIBRARY:?set MEDITATE_LIBRARY in deploy/.env}:/library:ro',
]);
expect(
(server['environment'] as YamlMap)['MEDITATE_LIBRARY_DIR'],
'/library',
);
});
test('is checked by a program in the image', () {
final dockerfile = read(
'apps/meditate/halkyon_meditate_server/Dockerfile',
);
expect(dockerfile, contains('--target bin/check_library.dart'));
expect(dockerfile, contains('bin/check_library'));
});
});
group('its site', () {
// The site's directives, without its comments.
final site = read('deploy/sidecars/meditate.caddy')
.split('\n')
.where((line) => !line.trim().startsWith('#'))
.join('\n');
test("sends the library's files to the web server, everything else to "
'the API server', () {
expect(site, contains('handle /library/* {'));
expect(site, contains('reverse_proxy meditate-server:8082'));
expect(site, contains('reverse_proxy meditate-server:8080'));
});
test("never passes on the calls between the instance's services", () {
expect(site, isNot(contains('/halkyon-base')));
expect(
RegExp('reverse_proxy meditate-server:8082').allMatches(site),
hasLength(1),
);
});
test('is mounted into Caddy by the compose file', () {
final caddy =
services('deploy/compose.meditate.yaml')['caddy'] as YamlMap;
expect(
caddy['volumes'],
contains(
'./sidecars/meditate.caddy:/etc/caddy/sidecars/meditate.caddy:ro',
),
);
});
});
group('the deploy and the backups', () {
test('build, dump, migrate and wait for it as for Todo', () {
expect(
read('deploy/redeploy.sh'),
contains('for app in train todo meditate; do'),
);
});
test('back its database up beside the others', () {
expect(
read('deploy/backup.sh'),
contains('for app in train todo meditate; do'),
);
});
test("have tool/check.dart replay its image's resolution", () {
expect(
dartImages,
contains('apps/meditate/halkyon_meditate_server/Dockerfile'),
);
});
});
group("every app's server names the instance's domain, not halkyon.eu's", () {
for (final (file, service, variable) in [
('compose.learn.yaml', 'server', 'LEARN_PUBLIC_HOST'),
('compose.train.yaml', 'train-server', 'TRAIN_DOMAIN'),
('compose.todo.yaml', 'todo-server', 'TODO_DOMAIN'),
('compose.meditate.yaml', 'meditate-server', 'MEDITATE_DOMAIN'),
]) {
test(service, () {
final environment =
(services('deploy/$file')[service] as YamlMap)['environment']
as YamlMap;
for (final key in [
'SERVERPOD_API_SERVER_PUBLIC_HOST',
'SERVERPOD_WEB_SERVER_PUBLIC_HOST',
]) {
expect(environment[key], startsWith('\${$variable'));
}
});
}
});
}

View file

@ -11,6 +11,7 @@ const dartImages = [
'apps/learn/halkyon_learn_curator/Dockerfile', 'apps/learn/halkyon_learn_curator/Dockerfile',
'apps/train/halkyon_train_server/Dockerfile', 'apps/train/halkyon_train_server/Dockerfile',
'apps/todo/halkyon_todo_server/Dockerfile', 'apps/todo/halkyon_todo_server/Dockerfile',
'apps/meditate/halkyon_meditate_server/Dockerfile',
]; ];
/// One instruction of a Dockerfile's build stage that the replay follows. /// One instruction of a Dockerfile's build stage that the replay follows.