fix: make server operations executable

This commit is contained in:
2026-08-05 11:06:34 +02:00
parent a707fb442c
commit 96fe5bfa79
11 changed files with 1299 additions and 43 deletions
+1
View File
@@ -9,6 +9,7 @@ THT_SECRETS_FILE=/absolute/path/to/thothii.secrets
THT_DATA_ROOT=/srv/thothii/data THT_DATA_ROOT=/srv/thothii/data
THT_PI_STATE_ROOT=/srv/thothii/pi-state THT_PI_STATE_ROOT=/srv/thothii/pi-state
THT_WORKSPACE_REGISTRY_ROOT=/srv/thothii/workspace-registry THT_WORKSPACE_REGISTRY_ROOT=/srv/thothii/workspace-registry
THT_BACKUP_ROOT=/srv/thothii-backups
THT_SERVER_WORKSPACE_CONFIG=/absolute/path/to/server-sessions.yaml THT_SERVER_WORKSPACE_CONFIG=/absolute/path/to/server-sessions.yaml
THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git
THT_WORKSPACE_GIT_BRANCH=main THT_WORKSPACE_GIT_BRANCH=main
+6 -4
View File
@@ -14,8 +14,8 @@ first startup. Keep storage separated:
```text ```text
/srv/thothii/data/ # settings, session data, Pi state as applicable /srv/thothii/data/ # settings, session data, Pi state as applicable
/srv/thothii/workspace-registry/ # repo/, snapshots/, state/, locks/ /srv/thothii/workspace-registry/ # repo/, snapshots/, state/, locks/
/srv/thothii/secrets/ # Git and connector secret files, mode 0700 /srv/thothii/secrets/ # Git and connector secret files, setgid mode 2750
/srv/thothii/operator/ # untracked Compose/.env, mode 0700 /srv/thothii/operator/ # untracked Compose/.env, setgid mode 2750
``` ```
Permit outbound TCP only to approved Git/Gitea, DWH, vector, embedding, and bastion endpoints. Permit outbound TCP only to approved Git/Gitea, DWH, vector, embedding, and bastion endpoints.
@@ -41,8 +41,10 @@ authoring environment for migration.
## Git credentials, CA, SSH key, and known-hosts mounts ## Git credentials, CA, SSH key, and known-hosts mounts
Use the secret manager or a protected host-only procedure to create independent regular files under Use the secret manager or a protected host-only procedure to create independent regular files under
`/srv/thothii/secrets`. Set individual mode `0600`, directory mode `0700`, and ownership readable `/srv/thothii/secrets`. As established in the server guide, use owner UID 10001, group
by the service account. These path-only variables are mounted read-only by Compose: `thothii-ops`, file mode `0640`, and setgid directory mode `2750`. This lets the UID 10001
container and the reviewed Docker operator running `thothctl` read the files without making them
public. These path-only variables are mounted read-only by Compose:
```dotenv ```dotenv
THT_WORKSPACE_GIT_CREDENTIALS_FILE=/srv/thothii/secrets/git-credentials THT_WORKSPACE_GIT_CREDENTIALS_FILE=/srv/thothii/secrets/git-credentials
+123 -25
View File
@@ -43,19 +43,29 @@ sudo useradd --system --uid 10001 --user-group --home-dir /srv/thothii \
--create-home --shell /usr/sbin/nologin thothii --create-home --shell /usr/sbin/nologin thothii
``` ```
The human operator who runs `thothctl` needs Docker access. On many installations membership in Use a dedicated `thothii-ops` group for the small set of human operators. A human who runs
the `docker` group is effectively host-root access; grant it only according to site policy. The `thothctl` must be in both `thothii-ops` (to traverse operator paths and read declared secret files
non-login `thothii` account owns application data and secrets but does not itself need Docker for output redaction) and the host `docker` group (to invoke Docker). Docker-group membership is
access. effectively host-root access; grant both memberships only to reviewed administrators. The
non-login `thothii` account owns files and writable data but does not need Docker access.
```sh
sudo groupadd --system thothii-ops
sudo usermod --append --groups thothii-ops,docker "$USER"
```
Log out and back in before continuing; `id` must show both groups. Do not run `thothctl` through
`sudo -u thothii`: that account deliberately lacks Docker access. Do not grant the human direct
write access to runtime bind trees.
Create explicit directories. `source` contains the clone; `operator` contains untracked path-only Create explicit directories. `source` contains the clone; `operator` contains untracked path-only
configuration; the three writable trees are bind-mounted into `core`; `secrets` contains regular configuration; the three writable trees are bind-mounted into `core`; `secrets` contains regular
files only. Backups are separate from live data. files only. Backups are separate from live data.
```sh ```sh
sudo install -d -o 10001 -g 10001 -m 0750 /srv/thothii/source sudo install -d -o 10001 -g thothii-ops -m 2750 /srv/thothii/source
sudo install -d -o 10001 -g 10001 -m 0700 /srv/thothii/operator sudo install -d -o 10001 -g thothii-ops -m 2750 /srv/thothii/operator
sudo install -d -o 10001 -g 10001 -m 0700 /srv/thothii/secrets sudo install -d -o 10001 -g thothii-ops -m 2750 /srv/thothii/secrets
sudo install -d -o 10001 -g 10001 -m 0750 /srv/thothii/data sudo install -d -o 10001 -g 10001 -m 0750 /srv/thothii/data
sudo install -d -o 10001 -g 10001 -m 0750 /srv/thothii/pi-state sudo install -d -o 10001 -g 10001 -m 0750 /srv/thothii/pi-state
sudo install -d -o 10001 -g 10001 -m 0750 /srv/thothii/workspace-registry sudo install -d -o 10001 -g 10001 -m 0750 /srv/thothii/workspace-registry
@@ -103,8 +113,50 @@ services:
``` ```
Use `host.docker.internal` in the endpoint binding. The `host-gateway` mapping supplies routing; Use `host.docker.internal` in the endpoint binding. The `host-gateway` mapping supplies routing;
it does not bundle or trust the target service. Keep the target port bound/firewalled for Docker it does not bundle or trust the target service. A host service listening only on host
host access only. A stable internal DNS record is the preferred alternative. `127.0.0.1` is **not reachable** through this mapping. Bind that service to the ThothII Docker
bridge gateway address or to a dedicated private host interface—never to `0.0.0.0` merely to make
the check pass. A stable internal DNS record routed through an authenticated private listener is
the preferred alternative.
After the first bounded start attempt, copy the exact core container name from `thothctl status`
into `CORE_NAME`, then derive—not guess—the network ID, Linux bridge interface, gateway, and
subnet. Compose networks normally use `br-<first-12-network-id>`; an explicit
`com.docker.network.bridge.name` option takes precedence:
```sh
CORE_NAME=replace-with-exact-core-container-name
NETWORK_ID=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.NetworkID}}{{end}}' "$CORE_NAME")
NETWORK_NAME=$(docker network inspect --format '{{.Name}}' "$NETWORK_ID")
BRIDGE=$(docker network inspect --format '{{index .Options "com.docker.network.bridge.name"}}' "$NETWORK_ID")
test -n "$BRIDGE" || BRIDGE="br-${NETWORK_ID%${NETWORK_ID#????????????}}"
GATEWAY=$(docker network inspect --format '{{(index .IPAM.Config 0).Gateway}}' "$NETWORK_ID")
SUBNET=$(docker network inspect --format '{{(index .IPAM.Config 0).Subnet}}' "$NETWORK_ID")
printf 'network=%s bridge=%s gateway=%s subnet=%s\n' "$NETWORK_NAME" "$BRIDGE" "$GATEWAY" "$SUBNET"
ip address show dev "$BRIDGE"
```
Bind the co-resident service to `$GATEWAY`. In the host firewall `INPUT` chain, allow its exact
TCP port only when source is `$SUBNET`, input interface is `$BRIDGE`, and destination is
`$GATEWAY`; reject other sources to that listener and persist the rules using the distribution's
firewall manager. Docker's `DOCKER-USER` chain governs forwarded/published traffic and does not
replace this host-input rule. Ask the firewall administrator to implement the equivalent policy
with nftables when iptables is not the site's source of truth.
For an iptables-managed host, replace the port before applying these reviewed rules; the second
rule prevents any other interface/source from reaching that gateway listener:
```sh
EXTERNAL_PORT=replace-with-exact-service-port
sudo iptables -I INPUT 1 -i "$BRIDGE" -s "$SUBNET" -d "$GATEWAY" -p tcp --dport "$EXTERNAL_PORT" -j ACCEPT
sudo iptables -I INPUT 2 -d "$GATEWAY" -p tcp --dport "$EXTERNAL_PORT" -j REJECT
```
Confirm reachability with `thothctl pi test` for the configured LLM/Pi path and with the
authenticated Workspace Diagnostics action for DWH, vector collection/embedding pairing, and
embedding endpoints. A timeout paired with `ss -lntp`, `ip address show dev "$BRIDGE"`, and the
firewall counters distinguishes a loopback bind from a subnet/interface rule failure. Do not add
a shell to the browser or mount the Docker socket into core for this diagnostic.
Configure each boundary independently: Configure each boundary independently:
@@ -136,7 +188,9 @@ Copy the path-only server environment and installation descriptor:
sudo -u thothii cp deploy/env/server.env.example /srv/thothii/operator/server.env sudo -u thothii cp deploy/env/server.env.example /srv/thothii/operator/server.env
sudo -u thothii cp docs/install/examples/thothii-installation.server.yaml \ sudo -u thothii cp docs/install/examples/thothii-installation.server.yaml \
/srv/thothii/operator/thothii-installation.yaml /srv/thothii/operator/thothii-installation.yaml
sudo chmod 0600 /srv/thothii/operator/server.env \ sudo chown 10001:thothii-ops /srv/thothii/operator/server.env \
/srv/thothii/operator/thothii-installation.yaml
sudo chmod 0640 /srv/thothii/operator/server.env \
/srv/thothii/operator/thothii-installation.yaml /srv/thothii/operator/thothii-installation.yaml
``` ```
@@ -146,15 +200,16 @@ session-server overlay and generated connector-secret override. Optional host-ga
image overrides go after them. image overrides go after them.
Create each credential as an independent regular file in `/srv/thothii/secrets`, owned by Create each credential as an independent regular file in `/srv/thothii/secrets`, owned by
UID/GID 10001 and mode `0600`. The operator environment records only absolute `*_FILE` or UID 10001, group `thothii-ops`, and mode `0640`. Owner access lets the UID 10001 container read a
file mounted under `/run/secrets`; group access lets the reviewed human run `thothctl`. The
operator environment records only absolute `*_FILE` or
`*_SOURCE` paths. Compose mounts application and connector targets read-only under `/run/secrets`; `*_SOURCE` paths. Compose mounts application and connector targets read-only under `/run/secrets`;
the frontend receives none. Do not print file contents while testing permissions. the frontend receives none. Do not print file contents while testing permissions.
```sh ```sh
sudo find /srv/thothii/secrets -type f -exec chown 10001:10001 {} + sudo find /srv/thothii/secrets -type f -exec chown 10001:thothii-ops {} +
sudo find /srv/thothii/secrets -type f -exec chmod 0600 {} + sudo find /srv/thothii/secrets -type f -exec chmod 0640 {} +
sudo find /srv/thothii/secrets -type f ! -user thothii -print sudo find /srv/thothii/secrets -type f \( ! -user thothii -o ! -group thothii-ops -o ! -perm 0640 \) -print
sudo find /srv/thothii/secrets -type f ! -perm 0600 -print
``` ```
Add `THT_WORKSPACE_BINDINGS_ENV_FILE=/srv/thothii/operator/workspace-bindings.env` and the matching Add `THT_WORKSPACE_BINDINGS_ENV_FILE=/srv/thothii/operator/workspace-bindings.env` and the matching
@@ -185,13 +240,18 @@ services:
core: core:
build: !reset null build: !reset null
image: registry.example.com/thothii/core@sha256:<64-lowercase-hex-digits> image: registry.example.com/thothii/core@sha256:<64-lowercase-hex-digits>
session-migrate:
build: !reset null
image: registry.example.com/thothii/core@sha256:<64-lowercase-hex-digits>
frontend: frontend:
build: !reset null build: !reset null
image: registry.example.com/thothii/frontend@sha256:<64-lowercase-hex-digits> image: registry.example.com/thothii/frontend@sha256:<64-lowercase-hex-digits>
``` ```
Add that absolute file last in `overrides`. Both images must come from one compatible release; the Add that absolute file last in `overrides`. `core` and `session-migrate` must use the exact same
core image must retain the declared Pi version labels checked by `thothctl pi doctor`. Pull access core digest; neither may retain a local build or `:local` image. Frontend uses its own exact digest.
Both images must come from one compatible release; the core image must retain the declared Pi
version labels checked by `thothctl pi doctor`. Pull access
belongs in the host Docker credential store, not in Compose or the installation descriptor. belongs in the host Docker credential store, not in Compose or the installation descriptor.
## Install thothctl ## Install thothctl
@@ -201,7 +261,7 @@ Build the operator binaries with Docker. No Go installation or Go knowledge is r
```sh ```sh
cd /srv/thothii/source/ThothII cd /srv/thothii/source/ThothII
bash scripts/build-thothctl.sh bash scripts/build-thothctl.sh
sudo install -o 10001 -g 10001 -m 0755 dist/thothctl/thothctl-linux-amd64 \ sudo install -o root -g thothii-ops -m 0750 dist/thothctl/thothctl-linux-amd64 \
/srv/thothii/operator/thothctl /srv/thothii/operator/thothctl
``` ```
@@ -211,7 +271,7 @@ not source `server.env` as shell code:
```sh ```sh
THTCTL=/srv/thothii/operator/thothctl THTCTL=/srv/thothii/operator/thothctl
INSTALLATION=/srv/thothii/operator/thothii-installation.yaml INSTALLATION=/srv/thothii/operator/thothii-installation.yaml
"$THTCTL" --installation "$INSTALLATION" --help "$THTCTL" --help
"$THTCTL" --installation "$INSTALLATION" update --check-only "$THTCTL" --installation "$INSTALLATION" update --check-only
``` ```
@@ -221,7 +281,24 @@ profile, overrides, project identity, and durable current-image selector. The ge
## Start and verify readiness ## Start and verify readiness
Keep the TLS proxy stopped or firewalled during bootstrap: Keep the TLS proxy stopped or firewalled during bootstrap. First stop the app, run the
installation-aware session migration, and inspect its pristine JSON. The command activates only
the `session-migrate` profile/service with `--no-deps --no-TTY`; it derives the migrator image from the
selected core image after all installation overrides, so this procedure is identical for source
and pinned modes. It exits nonzero unless both arrays are empty:
```sh
"$THTCTL" --installation "$INSTALLATION" stop
"$THTCTL" --installation "$INSTALLATION" sessions migrate --yes
```
Successful output has this shape (the `applied` list may contain versions on first use):
```json
{"applied":[],"drifted":[],"pending":[]}
```
Only after seeing `"pending":[]` and `"drifted":[]`, start and verify:
```sh ```sh
"$THTCTL" --installation "$INSTALLATION" start "$THTCTL" --installation "$INSTALLATION" start
@@ -303,7 +380,7 @@ BACKUP=/srv/thothii-backups/2026-08-05
sudo install -d -o root -g root -m 0700 "$BACKUP" sudo install -d -o root -g root -m 0700 "$BACKUP"
sudo tar --numeric-owner --xattrs --acls -C /srv/thothii -czf "$BACKUP/runtime-data.tgz" \ sudo tar --numeric-owner --xattrs --acls -C /srv/thothii -czf "$BACKUP/runtime-data.tgz" \
data pi-state workspace-registry data pi-state workspace-registry
sudo sha256sum "$BACKUP/runtime-data.tgz" >"$BACKUP/SHA256SUMS" sudo sh -ceu 'cd "$1"; sha256sum runtime-data.tgz > SHA256SUMS; sha256sum --check SHA256SUMS' sh "$BACKUP"
``` ```
Back up the installation descriptor, path-only environment, generated overrides, source revision, Back up the installation descriptor, path-only environment, generated overrides, source revision,
@@ -318,7 +395,7 @@ the restored set. This keeps the previous state recoverable:
```sh ```sh
RESTORE=/srv/thothii-restore-2026-08-05 RESTORE=/srv/thothii-restore-2026-08-05
sudo install -d -o root -g root -m 0700 "$RESTORE" sudo install -d -o root -g root -m 0700 "$RESTORE"
sudo sha256sum --check /srv/thothii-backups/2026-08-05/SHA256SUMS sudo sh -ceu 'cd "$1"; sha256sum --check SHA256SUMS' sh /srv/thothii-backups/2026-08-05
sudo tar --numeric-owner --xattrs --acls -C "$RESTORE" \ sudo tar --numeric-owner --xattrs --acls -C "$RESTORE" \
-xzf /srv/thothii-backups/2026-08-05/runtime-data.tgz -xzf /srv/thothii-backups/2026-08-05/runtime-data.tgz
sudo test -d "$RESTORE/workspace-registry/repo" sudo test -d "$RESTORE/workspace-registry/repo"
@@ -357,9 +434,30 @@ external service identity; and the proxy/identity provider for login failures.
## Data-preserving uninstall ## Data-preserving uninstall
Drain and stop through `thothctl`, take and verify one final backup, disable the TLS proxy route, Drain and stop through `thothctl`, take and verify one final backup, and disable the TLS proxy
and remove only this installation's stopped `frontend` and `core` containers and optional images route. Set `THT_BACKUP_ROOT=/srv/thothii-backups` in `server.env`; the removal command verifies the
by their exact Compose project labels. Keep `/srv/thothii/data`, `pi-state`, filesystem identity of that backup root, all three bind trees, and every declared secret before
and after removing anything.
First run without confirmation. It displays the exact installation project, service, container
name, container ID, and stopped state, then exits without mutation. Check every target:
```sh
"$THTCTL" --installation "$INSTALLATION" stop
"$THTCTL" --installation "$INSTALLATION" remove
```
If and only if both targets are the expected stopped `frontend` and `core` containers, confirm:
```sh
"$THTCTL" --installation "$INSTALLATION" remove --yes exact-core-id exact-frontend-id
```
Replace both example IDs with the values from the immediately preceding dry-run. The command
refuses confirmation if the current target set differs. The confirmed operation passes only those
previously displayed immutable container IDs to Docker,
uses no force or volume option, rejects running/replaced containers, and proves the preservation
paths still identify the same filesystem objects. Keep `/srv/thothii/data`, `pi-state`,
`workspace-registry`, `operator`, protected secrets, database backups, and the installation `workspace-registry`, `operator`, protected secrets, database backups, and the installation
descriptor if reinstallation is possible. Do not prune global Docker data. descriptor if reinstallation is possible. Do not prune global Docker data.
+113 -1
View File
@@ -20,6 +20,8 @@ for fixture in \
"Caddy reverse-proxy guide contract" \ "Caddy reverse-proxy guide contract" \
"local installation example rendered from path with spaces" \ "local installation example rendered from path with spaces" \
"server installation example rendered from path with spaces" \ "server installation example rendered from path with spaces" \
"server pinned migration image fixture" \
"server backup checksum root-only fixture" \
"local manual canonical base+override references" \ "local manual canonical base+override references" \
"server manual canonical base+override references" \ "server manual canonical base+override references" \
"canonical local base+override fixture" \ "canonical local base+override fixture" \
@@ -33,6 +35,32 @@ for fixture in \
} }
done done
server_guide="$root/docs/install/server.md"
for required in \
'thothii-ops' \
'THT_BACKUP_ROOT=/srv/thothii-backups' \
'sessions migrate --yes' \
'"pending":[]' \
'"drifted":[]' \
'remove --yes' \
'sha256sum --check SHA256SUMS' \
'DOCKER-USER' \
'iptables -I INPUT' \
'com.docker.network.bridge.name'; do
grep -Fq -- "$required" "$server_guide" || {
echo "server operations guide lacks executable contract: $required" >&2
exit 1
}
done
grep -Fq '"$THTCTL" --help' "$server_guide" || {
echo "server guide lacks plain thothctl --help" >&2
exit 1
}
if grep -Fq '"$THTCTL" --installation "$INSTALLATION" --help' "$server_guide"; then
echo "server guide still uses installation-scoped --help" >&2
exit 1
fi
for manual in "$root/docs/install/local-workspace-registry.md"; do for manual in "$root/docs/install/local-workspace-registry.md"; do
grep -Fq 'export THT_SOURCE_ROOT=/absolute/path/to/ThothII' "$manual" || { grep -Fq 'export THT_SOURCE_ROOT=/absolute/path/to/ThothII' "$manual" || {
echo "installation manual does not publish a self-contained THT_SOURCE_ROOT export: $manual" >&2 echo "installation manual does not publish a self-contained THT_SOURCE_ROOT export: $manual" >&2
@@ -114,6 +142,18 @@ switch (mutation) {
case "server-coupling": case "server-coupling":
changed += "\nAttach core to the omics_portal application network.\n"; changed += "\nAttach core to the omics_portal application network.\n";
break; break;
case "server-host-loopback":
changed += "\nFor host-gateway, keep the external service listening on 127.0.0.1.\n";
break;
case "server-raw-remove":
changed += "\n```sh\ndocker rm thothii-core thothii-frontend\n```\n";
break;
case "server-pinned-migrator-mismatch":
changed += "\n```yaml\nservices:\n core:\n image: registry.invalid/core@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n session-migrate:\n image: thothii-core:local\n```\n";
break;
case "server-pinned-frontend-missing":
changed = original.replace(' frontend:\n build: !reset null\n image: registry.example.com/thothii/frontend@sha256:<64-lowercase-hex-digits>\n', '');
break;
case "nginx-no-auth": case "nginx-no-auth":
changed = original.replace(" auth_request /_authenticate;", " # authentication omitted"); changed = original.replace(" auth_request /_authenticate;", " # authentication omitted");
break; break;
@@ -123,6 +163,18 @@ switch (mutation) {
case "nginx-no-sse": case "nginx-no-sse":
changed = original.replace(" proxy_buffering off;", " proxy_buffering on;"); changed = original.replace(" proxy_buffering off;", " proxy_buffering on;");
break; break;
case "nginx-no-issuer-clear":
changed = original.replaceAll('proxy_set_header X-Thoth-Principal-Issuer "";', 'proxy_set_header X-Thoth-Principal-Issuer $http_x_thoth_principal_issuer;');
break;
case "nginx-no-subject-capture":
changed = original.replace("auth_request_set $thoth_principal_subject", "# missing auth capture $thoth_principal_subject");
break;
case "nginx-no-display-map":
changed = original.replace("proxy_set_header X-Thoth-Trusted-Principal-Display-Name $thoth_principal_display_name;", "proxy_set_header X-Thoth-Trusted-Principal-Display-Name \"\";");
break;
case "nginx-no-admin-map":
changed = original.replace("proxy_set_header X-Thoth-Trusted-Is-Admin $thoth_is_admin;", "proxy_set_header X-Thoth-Trusted-Is-Admin \"\";");
break;
case "caddy-no-auth": case "caddy-no-auth":
changed = original.replace("forward_auth auth-gateway:4180 {", "# forward authentication omitted"); changed = original.replace("forward_auth auth-gateway:4180 {", "# forward authentication omitted");
break; break;
@@ -132,6 +184,18 @@ switch (mutation) {
case "caddy-core-upstream": case "caddy-core-upstream":
changed = original.replaceAll("127.0.0.1:8080", "127.0.0.1:8787"); changed = original.replaceAll("127.0.0.1:8080", "127.0.0.1:8787");
break; break;
case "caddy-no-issuer-public-clear":
changed = original.replace("request_header -X-Thoth-Principal-Issuer", "request_header X-Thoth-Principal-Issuer {header.X-Thoth-Principal-Issuer}");
break;
case "caddy-no-subject-trusted-clear":
changed = original.replace("request_header -X-Thoth-Trusted-Principal-Subject", "request_header X-Thoth-Trusted-Principal-Subject {header.X-Thoth-Trusted-Principal-Subject}");
break;
case "caddy-no-display-map":
changed = original.replace("X-Thoth-Principal-Display-Name>X-Thoth-Trusted-Principal-Display-Name", "X-Thoth-Principal-Display-Name");
break;
case "caddy-no-admin-map":
changed = original.replace("X-Thoth-Is-Admin>X-Thoth-Trusted-Is-Admin", "X-Thoth-Is-Admin");
break;
case "dirty-source": case "dirty-source":
changed = original.replaceAll("git status --porcelain --untracked-files=all", "git status --short"); changed = original.replaceAll("git status --porcelain --untracked-files=all", "git status --short");
break; break;
@@ -215,6 +279,22 @@ expect_guide_rejected \
"server application coupling" verify_server_guide \ "server application coupling" verify_server_guide \
"$root/docs/install/server.md" docs/install/server.md server-coupling \ "$root/docs/install/server.md" docs/install/server.md server-coupling \
"server installation guide introduces forbidden application coupling" "server installation guide introduces forbidden application coupling"
expect_guide_rejected \
"server host-gateway loopback listener" verify_server_guide \
"$root/docs/install/server.md" docs/install/server.md server-host-loopback \
"server host-gateway guidance assumes a host loopback listener"
expect_guide_rejected \
"server raw container removal" verify_server_guide \
"$root/docs/install/server.md" docs/install/server.md server-raw-remove \
"server uninstall bypasses installation-aware removal"
expect_guide_rejected \
"server pinned migrator differs from core" verify_server_guide \
"$root/docs/install/server.md" docs/install/server.md server-pinned-migrator-mismatch \
"server pinned migration image must equal the pinned core image"
expect_guide_rejected \
"server pinned frontend is missing" verify_server_guide \
"$root/docs/install/server.md" docs/install/server.md server-pinned-frontend-missing \
"server pinned image override must pin core, session-migrate, and frontend without builds"
expect_guide_rejected \ expect_guide_rejected \
"Nginx identity without authentication" verify_reverse_proxy_nginx_guide \ "Nginx identity without authentication" verify_reverse_proxy_nginx_guide \
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-auth \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-auth \
@@ -227,6 +307,22 @@ expect_guide_rejected \
"Nginx buffered SSE" verify_reverse_proxy_nginx_guide \ "Nginx buffered SSE" verify_reverse_proxy_nginx_guide \
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-sse \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-sse \
"Nginx proxy lacks structural token: proxy_buffering off;" "Nginx proxy lacks structural token: proxy_buffering off;"
expect_guide_rejected \
"Nginx issuer inbound claim not cleared" verify_reverse_proxy_nginx_guide \
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-issuer-clear \
"Nginx proxy does not clear inbound issuer identity"
expect_guide_rejected \
"Nginx subject auth response not captured" verify_reverse_proxy_nginx_guide \
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-subject-capture \
"Nginx proxy does not capture authenticated subject identity"
expect_guide_rejected \
"Nginx display identity not mapped to private hop" verify_reverse_proxy_nginx_guide \
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-display-map \
"Nginx proxy does not map authenticated display identity"
expect_guide_rejected \
"Nginx admin identity not mapped to private hop" verify_reverse_proxy_nginx_guide \
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-admin-map \
"Nginx proxy does not map authenticated admin identity"
expect_guide_rejected \ expect_guide_rejected \
"Caddy identity without authentication" verify_reverse_proxy_caddy_guide \ "Caddy identity without authentication" verify_reverse_proxy_caddy_guide \
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-auth \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-auth \
@@ -234,11 +330,27 @@ expect_guide_rejected \
expect_guide_rejected \ expect_guide_rejected \
"Caddy untrusted identity forwarding" verify_reverse_proxy_caddy_guide \ "Caddy untrusted identity forwarding" verify_reverse_proxy_caddy_guide \
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-client-identity \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-client-identity \
"Caddy proxy lacks structural token: X-Thoth-Principal-Subject>X-Thoth-Trusted-Principal-Subject" "Caddy proxy does not map authenticated subject identity"
expect_guide_rejected \ expect_guide_rejected \
"Caddy direct core exposure" verify_reverse_proxy_caddy_guide \ "Caddy direct core exposure" verify_reverse_proxy_caddy_guide \
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-core-upstream \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-core-upstream \
"Caddy proxy must forward only to frontend on 127.0.0.1:8080" "Caddy proxy must forward only to frontend on 127.0.0.1:8080"
expect_guide_rejected \
"Caddy issuer inbound claim not cleared" verify_reverse_proxy_caddy_guide \
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-issuer-public-clear \
"Caddy proxy does not clear inbound issuer identity"
expect_guide_rejected \
"Caddy subject private-hop claim not cleared" verify_reverse_proxy_caddy_guide \
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-subject-trusted-clear \
"Caddy proxy does not clear inbound trusted subject identity"
expect_guide_rejected \
"Caddy display identity not mapped to private hop" verify_reverse_proxy_caddy_guide \
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-display-map \
"Caddy proxy does not map authenticated display identity"
expect_guide_rejected \
"Caddy admin identity not mapped to private hop" verify_reverse_proxy_caddy_guide \
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-admin-map \
"Caddy proxy does not map authenticated admin identity"
expect_guide_rejected \ expect_guide_rejected \
"dirty or untracked source tree" verify_local_guide \ "dirty or untracked source tree" verify_local_guide \
+140 -9
View File
@@ -512,12 +512,16 @@ verify_server_guide() {
"frontend" \ "frontend" \
"core" \ "core" \
"UID/GID 10001" \ "UID/GID 10001" \
"thothii-ops" \
"/srv/thothii" \ "/srv/thothii" \
"example operator root" \ "example operator root" \
"/run/secrets" \ "/run/secrets" \
"Git-backed workspace registry is the source of truth" \ "Git-backed workspace registry is the source of truth" \
"host.docker.internal" \ "host.docker.internal" \
"host-gateway" \ "host-gateway" \
"com.docker.network.bridge.name" \
"DOCKER-USER" \
"iptables -I INPUT" \
"container 127.0.0.1" \ "container 127.0.0.1" \
"collection" \ "collection" \
"embedding" \ "embedding" \
@@ -525,6 +529,12 @@ verify_server_guide() {
"@sha256:" \ "@sha256:" \
"bash scripts/build-thothctl.sh" \ "bash scripts/build-thothctl.sh" \
"thothctl --installation" \ "thothctl --installation" \
"sessions migrate --yes" \
'"pending":[]' \
'"drifted":[]' \
"remove --yes" \
"THT_BACKUP_ROOT=/srv/thothii-backups" \
"sha256sum --check SHA256SUMS" \
"curl --fail http://127.0.0.1:8080/health" \ "curl --fail http://127.0.0.1:8080/health" \
"https://thoth.example.com" \ "https://thoth.example.com" \
"pi update" \ "pi update" \
@@ -564,6 +574,36 @@ for (const line of source.split(/\n/)) {
throw new Error("server docker compose down --volumes must appear only in an explicit prose prohibition"); throw new Error("server docker compose down --volumes must appear only in an explicit prose prohibition");
} }
} }
if (/```(?:sh|bash)\n[\s\S]*?\bdocker\s+rm\b[\s\S]*?```/i.test(source)) {
throw new Error("server uninstall bypasses installation-aware removal");
}
if (/host-gateway[^\n]{0,120}(?:listen|listening|bound)[^\n]{0,80}127\.0\.0\.1|(?:listen|listening|bound)[^\n]{0,80}127\.0\.0\.1[^\n]{0,120}host-gateway/i.test(source)) {
throw new Error("server host-gateway guidance assumes a host loopback listener");
}
const pinnedStart = source.indexOf("## Build locally or select pinned images");
const pinnedEnd = source.indexOf("\n## ", pinnedStart + 3);
const pinnedSection = source.slice(pinnedStart, pinnedEnd < 0 ? source.length : pinnedEnd);
const pinnedBlock = [...pinnedSection.matchAll(/```yaml\n([\s\S]*?)```/g)].map((match) => match[1])
.find((block) => block.includes("session-migrate:")) || "";
function pinnedService(name) {
const match = pinnedBlock.match(new RegExp(`^ ${name}:\\n((?: [^\\n]*\\n)+)`, "m"));
return match ? match[1] : "";
}
const pinnedCore = pinnedService("core");
const pinnedMigrator = pinnedService("session-migrate");
const pinnedFrontend = pinnedService("frontend");
const coreImage = pinnedCore.match(/image:\s*(\S+)/)?.[1];
const migratorImage = pinnedMigrator.match(/image:\s*(\S+)/)?.[1];
const frontendImage = pinnedFrontend.match(/image:\s*(\S+)/)?.[1];
if (![pinnedCore, pinnedMigrator, pinnedFrontend].every((block) => block.includes("build: !reset null")) ||
!coreImage || coreImage !== migratorImage || !/@sha256:<64-lowercase-hex-digits>$/.test(coreImage) ||
!frontendImage || !/@sha256:<64-lowercase-hex-digits>$/.test(frontendImage)) {
throw new Error("server pinned image override must pin core, session-migrate, and frontend without builds");
}
if (/session-migrate:[\s\S]{0,180}image:\s*thothii-core:local/.test(source) &&
/core:[\s\S]{0,180}image:\s*registry\.[^\n]+@sha256:[a-f0-9]{64}/.test(source)) {
throw new Error("server pinned migration image must equal the pinned core image");
}
if (/```(?:sh|bash)\n[\s\S]*?\bdocker compose\s+(?:up|stop|down|restart|pull|build)\b[\s\S]*?```/i.test(source)) { if (/```(?:sh|bash)\n[\s\S]*?\bdocker compose\s+(?:up|stop|down|restart|pull|build)\b[\s\S]*?```/i.test(source)) {
throw new Error("server lifecycle must use thothctl, not raw Docker Compose"); throw new Error("server lifecycle must use thothctl, not raw Docker Compose");
} }
@@ -595,11 +635,9 @@ const block = [...source.matchAll(/```nginx\n([\s\S]*?)```/g)].map((match) => ma
const tokens = [ const tokens = [
"listen 443 ssl;", "ssl_certificate ", "ssl_certificate_key ", "listen 443 ssl;", "ssl_certificate ", "ssl_certificate_key ",
"location = /_authenticate {", "internal;", "proxy_pass http://auth-gateway:4180/verify;", "location = /_authenticate {", "internal;", "proxy_pass http://auth-gateway:4180/verify;",
"auth_request /_authenticate;", "auth_request_set $thoth_principal_subject", "auth_request /_authenticate;", "proxy_pass http://127.0.0.1:8080;",
"$upstream_http_x_thoth_principal_subject", "proxy_pass http://127.0.0.1:8080;",
"proxy_http_version 1.1;", "proxy_buffering off;", "proxy_cache off;", "proxy_http_version 1.1;", "proxy_buffering off;", "proxy_cache off;",
"proxy_read_timeout 3600s;", "proxy_set_header X-Thoth-Principal-Subject \"\";", "proxy_read_timeout 3600s;",
"proxy_set_header X-Thoth-Trusted-Principal-Subject $thoth_principal_subject;",
]; ];
if (/127\.0\.0\.1:8787|\bcore:8787\b/.test(block) || !block.includes("http://127.0.0.1:8080")) { if (/127\.0\.0\.1:8787|\bcore:8787\b/.test(block) || !block.includes("http://127.0.0.1:8080")) {
throw new Error("Nginx proxy must forward only to frontend on 127.0.0.1:8080"); throw new Error("Nginx proxy must forward only to frontend on 127.0.0.1:8080");
@@ -610,6 +648,28 @@ for (const token of tokens) {
if (/proxy_set_header\s+X-Thoth-Trusted-[^;]+\$http_/i.test(block)) { if (/proxy_set_header\s+X-Thoth-Trusted-[^;]+\$http_/i.test(block)) {
throw new Error("Nginx proxy trusts a client-supplied identity header"); throw new Error("Nginx proxy trusts a client-supplied identity header");
} }
const identities = [
["issuer", "Principal-Issuer", "thoth_principal_issuer", "x_thoth_principal_issuer"],
["subject", "Principal-Subject", "thoth_principal_subject", "x_thoth_principal_subject"],
["display", "Principal-Display-Name", "thoth_principal_display_name", "x_thoth_principal_display_name"],
["admin", "Is-Admin", "thoth_is_admin", "x_thoth_is_admin"],
];
function escaped(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
for (const [label, publicName, variable, upstream] of identities) {
const trustedName = publicName === "Is-Admin" ? "Is-Admin" : publicName;
const publicClears = block.match(new RegExp(`proxy_set_header\\s+X-Thoth-${escaped(publicName)}\\s+"";`, "g")) || [];
if (publicClears.length < 2) throw new Error(`Nginx proxy does not clear inbound ${label} identity`);
const trustedHeader = `X-Thoth-Trusted-${trustedName}`;
if (!new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+"";`).test(block)) {
throw new Error(`Nginx proxy does not clear inbound trusted ${label} identity`);
}
if (!new RegExp(`auth_request_set\\s+\\$${variable}\\s+\\$upstream_http_${upstream};`, "m").test(block.replace(/\s+/g, " "))) {
throw new Error(`Nginx proxy does not capture authenticated ${label} identity`);
}
if (!new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+\\$${variable};`).test(block)) {
throw new Error(`Nginx proxy does not map authenticated ${label} identity`);
}
}
NODE NODE
echo "Nginx reverse-proxy guide contract passed" echo "Nginx reverse-proxy guide contract passed"
} }
@@ -637,10 +697,7 @@ const source = fs.readFileSync(process.argv[2], "utf8");
const block = [...source.matchAll(/```caddyfile\n([\s\S]*?)```/g)].map((match) => match[1]).join("\n"); const block = [...source.matchAll(/```caddyfile\n([\s\S]*?)```/g)].map((match) => match[1]).join("\n");
const tokens = [ const tokens = [
"thoth.example.com {", "route {", "thoth.example.com {", "route {",
"request_header -X-Thoth-Principal-Subject",
"request_header -X-Thoth-Trusted-Principal-Subject",
"forward_auth auth-gateway:4180 {", "uri /verify", "copy_headers {", "forward_auth auth-gateway:4180 {", "uri /verify", "copy_headers {",
"X-Thoth-Principal-Subject>X-Thoth-Trusted-Principal-Subject",
"reverse_proxy 127.0.0.1:8080 {", "flush_interval -1", "reverse_proxy 127.0.0.1:8080 {", "flush_interval -1",
]; ];
if (/127\.0\.0\.1:8787|\bcore:8787\b/.test(block) || !block.includes("127.0.0.1:8080")) { if (/127\.0\.0\.1:8787|\bcore:8787\b/.test(block) || !block.includes("127.0.0.1:8080")) {
@@ -649,6 +706,22 @@ if (/127\.0\.0\.1:8787|\bcore:8787\b/.test(block) || !block.includes("127.0.0.1:
for (const token of tokens) { for (const token of tokens) {
if (!block.includes(token)) throw new Error(`Caddy proxy lacks structural token: ${token}`); if (!block.includes(token)) throw new Error(`Caddy proxy lacks structural token: ${token}`);
} }
for (const [label, publicName, trustedName] of [
["issuer", "X-Thoth-Principal-Issuer", "X-Thoth-Trusted-Principal-Issuer"],
["subject", "X-Thoth-Principal-Subject", "X-Thoth-Trusted-Principal-Subject"],
["display", "X-Thoth-Principal-Display-Name", "X-Thoth-Trusted-Principal-Display-Name"],
["admin", "X-Thoth-Is-Admin", "X-Thoth-Trusted-Is-Admin"],
]) {
if (!block.includes(`request_header -${publicName}`)) {
throw new Error(`Caddy proxy does not clear inbound ${label} identity`);
}
if (!block.includes(`request_header -${trustedName}`)) {
throw new Error(`Caddy proxy does not clear inbound trusted ${label} identity`);
}
if (!block.includes(`${publicName}>${trustedName}`)) {
throw new Error(`Caddy proxy does not map authenticated ${label} identity`);
}
}
NODE NODE
echo "Caddy reverse-proxy guide contract passed" echo "Caddy reverse-proxy guide contract passed"
} }
@@ -821,7 +894,7 @@ verify_server_installation_example() {
return 1 return 1
} }
local fixture source_copy operator_dir copied_example connector_override env_file local fixture source_copy operator_dir copied_example connector_override env_file backup_root
fixture="$(mktemp -d "${TMPDIR%/}/thoth server install.XXXXXX")" fixture="$(mktemp -d "${TMPDIR%/}/thoth server install.XXXXXX")"
trap 'rm -rf "$fixture"' RETURN trap 'rm -rf "$fixture"' RETURN
[[ "$fixture" == *" "* ]] || { [[ "$fixture" == *" "* ]] || {
@@ -830,8 +903,9 @@ verify_server_installation_example() {
} }
source_copy="$fixture/ThothII server source" source_copy="$fixture/ThothII server source"
operator_dir="$fixture/server operator files" operator_dir="$fixture/server operator files"
backup_root="$fixture/server backups"
mkdir -p "$source_copy/deploy/pi" "$source_copy/deploy/workspaces" \ mkdir -p "$source_copy/deploy/pi" "$source_copy/deploy/workspaces" \
"$operator_dir/data" "$operator_dir/pi-state" "$operator_dir/workspace-registry" "$operator_dir/data" "$operator_dir/pi-state" "$operator_dir/workspace-registry" "$backup_root"
cp "$root/compose.yaml" "$source_copy/compose.yaml" cp "$root/compose.yaml" "$source_copy/compose.yaml"
cp "$root/deploy/compose.server.yaml" "$source_copy/deploy/compose.server.yaml" cp "$root/deploy/compose.server.yaml" "$source_copy/deploy/compose.server.yaml"
cp "$root/deploy/compose.session-server.yaml.example" \ cp "$root/deploy/compose.session-server.yaml.example" \
@@ -869,6 +943,7 @@ verify_server_installation_example() {
"THT_DATA_ROOT=$operator_dir/data" \ "THT_DATA_ROOT=$operator_dir/data" \
"THT_PI_STATE_ROOT=$operator_dir/pi-state" \ "THT_PI_STATE_ROOT=$operator_dir/pi-state" \
"THT_WORKSPACE_REGISTRY_ROOT=$operator_dir/workspace-registry" \ "THT_WORKSPACE_REGISTRY_ROOT=$operator_dir/workspace-registry" \
"THT_BACKUP_ROOT=$backup_root" \
"THT_SERVER_WORKSPACE_CONFIG=$source_copy/deploy/workspaces/server-sessions.yaml.example" \ "THT_SERVER_WORKSPACE_CONFIG=$source_copy/deploy/workspaces/server-sessions.yaml.example" \
'THT_LLM_URL=https://llm.example.invalid' \ 'THT_LLM_URL=https://llm.example.invalid' \
'THT_SESSION_DB_HOST=sessions.example.invalid' \ 'THT_SESSION_DB_HOST=sessions.example.invalid' \
@@ -942,6 +1017,62 @@ for (const secret of [
} }
NODE NODE
echo "server installation example rendered from path with spaces passed" echo "server installation example rendered from path with spaces passed"
local migration_rendered="$fixture/server-migration.json"
"$root/scripts/compose-with-preflight.sh" --env-file "$descriptor_env" \
"${files[@]}" --profile session-migrate config --format json >"$migration_rendered"
node - "$migration_rendered" <<'NODE'
const fs = require("fs");
const config = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const services = config.services || {};
if (!services.core || !services["session-migrate"]) throw new Error("server migration profile is missing core or session-migrate");
if (services.core.image !== services["session-migrate"].image) throw new Error("source migration image differs from core");
if (services["session-migrate"].build) throw new Error("source migration service unexpectedly declares a build");
NODE
local pinned_template="$fixture/pinned-template.yaml" pinned_override="$operator_dir/pinned-images.yaml"
awk '
/^## Build locally or select pinned images$/ { section=1; next }
section && /^```yaml$/ { code=1; next }
code && /^```$/ { exit }
code { print }
' "$root/docs/install/server.md" >"$pinned_template"
sed \
-e "s#registry.example.com/thothii/core@sha256:<64-lowercase-hex-digits>#registry.example.com/thothii/core@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa#g" \
-e "s#registry.example.com/thothii/frontend@sha256:<64-lowercase-hex-digits>#registry.example.com/thothii/frontend@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb#g" \
"$pinned_template" >"$pinned_override"
chmod 0600 "$pinned_override"
local pinned_rendered="$fixture/server-pinned-migration.json"
"$root/scripts/compose-with-preflight.sh" --env-file "$descriptor_env" \
"${files[@]}" -f "$pinned_override" --profile session-migrate config --format json >"$pinned_rendered"
node - "$pinned_rendered" <<'NODE'
const fs = require("fs");
const config = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const core = config.services?.core;
const frontend = config.services?.frontend;
const migrator = config.services?.["session-migrate"];
if (!core || !frontend || !migrator) throw new Error("pinned migration profile lacks core, frontend, or session-migrate");
if (core.image !== migrator.image || !/@sha256:[a-f0-9]{64}$/.test(core.image)) {
throw new Error("pinned migration image does not equal the exact core digest");
}
if (!/@sha256:[a-f0-9]{64}$/.test(frontend.image)) throw new Error("frontend is not pinned by exact digest");
for (const [name, service] of Object.entries({core, frontend, migrator})) {
if (service.build) throw new Error(name + " retained a local build in pinned mode");
if (/:local$/.test(service.image || "")) throw new Error(name + " retained a local image in pinned mode");
}
NODE
echo "server pinned migration image fixture passed"
local checksum_root="$fixture/root-only-checksum"
mkdir -m 0700 "$checksum_root"
printf 'fixture backup bytes\n' >"$checksum_root/runtime-data.tgz"
/bin/sh -ceu 'cd "$1"; sha256sum runtime-data.tgz > SHA256SUMS; sha256sum --check SHA256SUMS' sh "$checksum_root" >/dev/null
printf 'corruption\n' >>"$checksum_root/runtime-data.tgz"
if (cd "$checksum_root" && sha256sum --check SHA256SUMS) >/dev/null 2>&1; then
echo "corrupted server backup checksum fixture was accepted" >&2
return 1
fi
echo "server backup checksum root-only fixture passed"
} }
write_private() { write_private() {
+60
View File
@@ -18,6 +18,7 @@ import (
"github.com/aritmolab/thothii/tools/thothctl/internal/config" "github.com/aritmolab/thothii/tools/thothctl/internal/config"
"github.com/aritmolab/thothii/tools/thothctl/internal/output" "github.com/aritmolab/thothii/tools/thothctl/internal/output"
"github.com/aritmolab/thothii/tools/thothctl/internal/pi" "github.com/aritmolab/thothii/tools/thothctl/internal/pi"
"github.com/aritmolab/thothii/tools/thothctl/internal/serverops"
) )
const usage = `Usage: thothctl --installation <absolute-path>/thothii-installation.yaml <command> const usage = `Usage: thothctl --installation <absolute-path>/thothii-installation.yaml <command>
@@ -29,6 +30,10 @@ Commands:
start Start the installation in the background. start Start the installation in the background.
stop Stop the installation. stop Stop the installation.
update --check-only Validate the current installation without changing containers. update --check-only Validate the current installation without changing containers.
sessions migrate --yes
Run only the server session migrator and verify pending=[] and drifted=[].
remove Display exact stopped app container IDs without mutation.
remove --yes ID... Remove only the stopped IDs copied from the preceding display.
pi status Show the Pi version embedded in core. pi status Show the Pi version embedded in core.
pi doctor Check Pi preconditions without changing the installation. pi doctor Check Pi preconditions without changing the installation.
pi test Run the temporary Pi/core smoke checks. pi test Run the temporary Pi/core smoke checks.
@@ -113,12 +118,67 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return doctor(ctx, installation, runner, secretValues, stdout, stderr) return doctor(ctx, installation, runner, secretValues, stdout, stderr)
case "pi": case "pi":
return piCommand(ctx, installation, runner, commandArgs, secretValues, stdout, stderr) return piCommand(ctx, installation, runner, commandArgs, secretValues, stdout, stderr)
case "sessions":
if len(commandArgs) != 2 || commandArgs[0] != "migrate" || commandArgs[1] != "--yes" {
return commandUsageError(stderr, "sessions migrate requires --yes")
}
status, operationErr := serverops.MigrateSessions(ctx, installation, runner, true)
if operationErr != nil {
return serverOperationFailure(stderr, operationErr, secretValues)
}
if encodeErr := json.NewEncoder(stdout).Encode(status); encodeErr != nil {
fmt.Fprintln(stderr, "thothctl: migration status could not be written")
return 1
}
return 0
case "remove":
var confirmedIDs []string
if len(commandArgs) > 0 {
if commandArgs[0] != "--yes" || len(commandArgs) < 2 {
return commandUsageError(stderr, "remove requires either no arguments or --yes followed by every displayed container ID")
}
confirmedIDs = commandArgs[1:]
}
removal, operationErr := serverops.Remove(ctx, installation, runner, confirmedIDs)
writeRemovalTargets(stdout, installation.ProjectName(), removal.Targets)
if errors.Is(operationErr, serverops.ErrConfirmationRequired) {
fmt.Fprint(stderr, "thothctl: inspect the exact targets above, then re-run with remove --yes")
for _, target := range removal.Targets {
fmt.Fprintf(stderr, " %s", target.ID)
}
fmt.Fprintln(stderr)
return 2
}
if operationErr != nil {
return serverOperationFailure(stderr, operationErr, secretValues)
}
fmt.Fprintf(stdout, "Removed %d stopped app containers; verified %d preserved paths.\n", len(removal.Targets), removal.Preserved)
return 0
default: default:
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command)) return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
} }
return writeResult(result, err, secretValues, stdout, stderr) return writeResult(result, err, secretValues, stdout, stderr)
} }
func writeRemovalTargets(outputWriter io.Writer, project string, targets []serverops.Container) {
fmt.Fprintf(outputWriter, "Removal targets for installation project %s:\n", project)
if len(targets) == 0 {
fmt.Fprintln(outputWriter, " (none)")
return
}
for _, target := range targets {
fmt.Fprintf(outputWriter, " service=%s name=%s id=%s state=%s\n", target.Service, target.Name, target.ID, target.State)
}
}
func serverOperationFailure(stderr io.Writer, err error, secretValues []string) int {
fmt.Fprintf(stderr, "thothctl: %s\n", output.Sanitize(err.Error(), secretValues))
if errors.Is(err, serverops.ErrConfirmationRequired) || errors.Is(err, serverops.ErrUnsafeState) {
return 2
}
return 1
}
// installationRunner transforms only Compose invocations into the installation's validated, // installationRunner transforms only Compose invocations into the installation's validated,
// profile-specific argument list. Direct Docker image commands remain host-side and use arguments. // profile-specific argument list. Direct Docker image commands remain host-side and use arguments.
type installationRunner struct { type installationRunner struct {
+58
View File
@@ -79,6 +79,8 @@ func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *tes
"--source pull --image IMAGE@sha256:DIGEST", "--source pull --image IMAGE@sha256:DIGEST",
"pi maintenance status", "pi maintenance status",
"pi maintenance recover --yes", "pi maintenance recover --yes",
"sessions migrate --yes",
"remove --yes ID...",
} { } {
if !strings.Contains(usage, required) { if !strings.Contains(usage, required) {
t.Errorf("usage missing %q", required) t.Errorf("usage missing %q", required)
@@ -86,6 +88,48 @@ func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *tes
} }
} }
func TestRunSessionsMigrateRequiresExplicitConfirmationBeforeDocker(t *testing.T) {
fixture := newCLIFixture(t, "")
fixture.setProfile(t, "server")
fixture.setEnvironment(t)
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{
"--installation", fixture.installationPath, "sessions", "migrate",
}, &stdout, &stderr)
if code != 2 || !strings.Contains(stderr.String(), "sessions migrate requires --yes") {
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
}
assertDockerNotInvoked(t, fixture)
}
func TestRunRemoveDisplaysExactInstallationTargetsBeforeConfirmation(t *testing.T) {
fixture := newCLIFixture(t, "")
fixture.setProfile(t, "server")
fixture.setEnvironment(t)
t.Setenv("THOTHCTL_FAKE_STOPPED_PS", `[{"ID":"core-id","Name":"exact-core","Service":"core","State":"exited"},{"ID":"front-id","Name":"exact-frontend","Service":"frontend","State":"exited"}]`)
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{
"--installation", fixture.installationPath, "remove",
}, &stdout, &stderr)
if code != 2 || !strings.Contains(stderr.String(), "re-run with remove --yes core-id front-id") {
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
}
for _, value := range []string{"exact-core", "core-id", "exact-frontend", "front-id", "exited"} {
if !strings.Contains(stdout.String(), value) {
t.Errorf("target display %q missing %q", stdout.String(), value)
}
}
calls := fixture.invocations(t)
if len(calls) != 1 {
t.Fatalf("Docker calls = %#v", calls)
}
assertInvocationContains(t, calls, "ps", "--all", "--format", "json", "core", "frontend")
}
type wizardRunner struct{ calls []string } type wizardRunner struct{ calls []string }
func (r *wizardRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { func (r *wizardRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
@@ -615,6 +659,7 @@ func newCLIFixture(t *testing.T, envTemplate string) cliFixture {
printf '%s\n' "$@" >> "$THOTHCTL_FAKE_ARGS" printf '%s\n' "$@" >> "$THOTHCTL_FAKE_ARGS"
printf '%s\n' -- >> "$THOTHCTL_FAKE_ARGS" printf '%s\n' -- >> "$THOTHCTL_FAKE_ARGS"
case " $* " in case " $* " in
*" ps --all --format json core frontend "*) printf '%s\n' "${THOTHCTL_FAKE_STOPPED_PS:-[]}" ;;
*" config --format json "*) printf '%s\n' '{"volumes":{"settings":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}' ;; *" config --format json "*) printf '%s\n' '{"volumes":{"settings":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}' ;;
*" ps --format json "*) printf '%s\n' '[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"}]' ;; *" ps --format json "*) printf '%s\n' '[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"}]' ;;
*"io.thothii.pi.version"*) printf '%s\n' '0.80.3' ;; *"io.thothii.pi.version"*) printf '%s\n' '0.80.3' ;;
@@ -661,6 +706,19 @@ func (f cliFixture) setEnvContents(t *testing.T, env string) {
t.Setenv("THOTHCTL_FAKE_LOG", "") t.Setenv("THOTHCTL_FAKE_LOG", "")
t.Setenv("THOTHCTL_FAKE_FAILURE", "") t.Setenv("THOTHCTL_FAKE_FAILURE", "")
t.Setenv("THOTHCTL_FAKE_FAIL_ON", "") t.Setenv("THOTHCTL_FAKE_FAIL_ON", "")
t.Setenv("THOTHCTL_FAKE_STOPPED_PS", "[]")
}
func (f cliFixture) setProfile(t *testing.T, profile string) {
t.Helper()
composePath := filepath.Join(f.projectDirectory, "deploy", "compose."+profile+".yaml")
if err := os.WriteFile(composePath, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
contents := "profile: " + profile + "\nprojectDirectory: " + f.projectDirectory + "\nenvFile: " + f.envFile + "\n"
if err := os.WriteFile(f.installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
} }
func (f cliFixture) invocations(t *testing.T) [][]string { func (f cliFixture) invocations(t *testing.T) [][]string {
+81 -4
View File
@@ -148,8 +148,25 @@ func (i Installation) ProjectName() string {
// ComposeArgs builds Docker Compose arguments without shell quoting or interpolation. // ComposeArgs builds Docker Compose arguments without shell quoting or interpolation.
func (i Installation) ComposeArgs(command ...string) []string { func (i Installation) ComposeArgs(command ...string) []string {
return i.composeArgs(i.ComposeFiles(), command...)
}
// ComposeArgsWithFinalOverride appends one validated, generated override after every durable
// installation selector and before the Compose command.
func (i Installation) ComposeArgsWithFinalOverride(override string, command ...string) ([]string, error) {
if filepath.Clean(override) != override || !filepath.IsAbs(override) {
return nil, errors.New("final Compose override must be an absolute canonical path")
}
if err := requireRegularFile(override, "final Compose override"); err != nil {
return nil, err
}
files := append(i.ComposeFiles(), override)
return i.composeArgs(files, command...), nil
}
func (i Installation) composeArgs(files []string, command ...string) []string {
args := []string{"compose", "--project-name", i.ProjectName(), "--project-directory", i.ProjectDirectory, "--env-file", i.EnvFile} args := []string{"compose", "--project-name", i.ProjectName(), "--project-directory", i.ProjectDirectory, "--env-file", i.EnvFile}
for _, composeFile := range i.ComposeFiles() { for _, composeFile := range files {
args = append(args, "-f", composeFile) args = append(args, "-f", composeFile)
} }
return append(args, command...) return append(args, command...)
@@ -193,15 +210,75 @@ func (i Installation) SecretFiles() ([]string, error) {
// EnvironmentValue returns one declared installation value without exposing dotenv parsing to // EnvironmentValue returns one declared installation value without exposing dotenv parsing to
// callers. It is used only for operator-visible file locations, never for secret content. // callers. It is used only for operator-visible file locations, never for secret content.
func (i Installation) EnvironmentValue(name string) (string, error) { func (i Installation) EnvironmentValue(name string) (string, error) {
values, err := i.environmentValues()
if err != nil {
return "", err
}
return values[name], nil
}
func (i Installation) environmentValues() (map[string]string, error) {
contents, err := safeio.ReadCanonicalRegular(i.EnvFile, maxEnvironmentFileBytes) contents, err := safeio.ReadCanonicalRegular(i.EnvFile, maxEnvironmentFileBytes)
if err != nil { if err != nil {
return "", errors.New("installation environment could not be read") return nil, errors.New("installation environment could not be read")
} }
values, err := parseComposeDotenv(contents) values, err := parseComposeDotenv(contents)
if err != nil { if err != nil {
return "", errors.New("installation environment could not be read") return nil, errors.New("installation environment could not be read")
} }
return values[name], nil return values, nil
}
// PreservationPaths returns the server bind roots, backup root, and declared secret files whose
// filesystem identities must survive a data-preserving removal.
func (i Installation) PreservationPaths() ([]string, error) {
if i.Profile != "server" {
return nil, errors.New("data-preserving removal requires a server installation")
}
values, err := i.environmentValues()
if err != nil {
return nil, err
}
paths := make([]string, 0)
seen := make(map[string]struct{})
for _, name := range []string{
"THT_DATA_ROOT", "THT_PI_STATE_ROOT", "THT_WORKSPACE_REGISTRY_ROOT", "THT_BACKUP_ROOT",
} {
path := values[name]
if err := requireCanonicalDirectory(path); err != nil {
return nil, fmt.Errorf("%s must identify an existing canonical directory", name)
}
if _, exists := seen[path]; !exists {
paths = append(paths, path)
seen[path] = struct{}{}
}
}
secretFiles, err := i.SecretFiles()
if err != nil {
return nil, err
}
for _, path := range secretFiles {
if _, exists := seen[path]; !exists {
paths = append(paths, path)
seen[path] = struct{}{}
}
}
return paths, nil
}
func requireCanonicalDirectory(path string) error {
if err := safeio.ValidateCanonicalPath(path); err != nil {
return err
}
resolved, err := filepath.EvalSymlinks(path)
if err != nil || resolved != path {
return errors.New("directory path is unavailable or contains a symlink")
}
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return errors.New("directory path is unavailable")
}
return nil
} }
func parseComposeDotenv(contents []byte) (map[string]string, error) { func parseComposeDotenv(contents []byte) (map[string]string, error) {
@@ -78,6 +78,76 @@ func TestComposeArgsAutomaticallyIncludeTheInstallationCurrentImageOverride(t *t
} }
} }
func TestComposeArgsWithFinalOverridePreservesCurrentImagePrecedence(t *testing.T) {
installationPath, _, _, _ := writeInstallation(t, "server")
seed, err := Load(installationPath)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(seed.ControlDirectory(), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(seed.CurrentImageOverridePath(), []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
final := filepath.Join(seed.ControlDirectory(), "migration.yaml")
if err := os.WriteFile(final, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
installation, err := Load(installationPath)
if err != nil {
t.Fatal(err)
}
args, err := installation.ComposeArgsWithFinalOverride(final, "--profile", "session-migrate", "config")
if err != nil {
t.Fatal(err)
}
want := []string{"-f", installation.CurrentImageOverridePath(), "-f", final, "--profile", "session-migrate", "config"}
if !containsSequence(args, want) {
t.Fatalf("ComposeArgsWithFinalOverride() = %#v, want %#v", args, want)
}
}
func TestPreservationPathsReturnsCanonicalBindRootsBackupsAndSecretFiles(t *testing.T) {
installationPath, _, envFile, _ := writeInstallation(t, "server")
root := filepath.Dir(envFile)
var wanted []string
var lines []string
for _, item := range []struct{ key, name string }{
{"THT_DATA_ROOT", "data"},
{"THT_PI_STATE_ROOT", "pi-state"},
{"THT_WORKSPACE_REGISTRY_ROOT", "workspace-registry"},
{"THT_BACKUP_ROOT", "backups"},
} {
path := filepath.Join(root, item.name)
if err := os.Mkdir(path, 0o700); err != nil {
t.Fatal(err)
}
wanted = append(wanted, path)
lines = append(lines, item.key+"="+path)
}
secret := filepath.Join(root, "secret")
if err := os.WriteFile(secret, []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
wanted = append(wanted, secret)
lines = append(lines, "APP_TOKEN_FILE="+secret)
if err := os.WriteFile(envFile, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatal(err)
}
installation, err := Load(installationPath)
if err != nil {
t.Fatal(err)
}
got, err := installation.PreservationPaths()
if err != nil {
t.Fatal(err)
}
assertStringsEqual(t, got, wanted)
}
func TestInstallationControlPathsAreIsolatedForDescriptorsSharingOneCheckout(t *testing.T) { func TestInstallationControlPathsAreIsolatedForDescriptorsSharingOneCheckout(t *testing.T) {
projectDirectory := t.TempDir() projectDirectory := t.TempDir()
first := Installation{Path: filepath.Join(t.TempDir(), installationFileName), ProjectDirectory: projectDirectory} first := Installation{Path: filepath.Join(t.TempDir(), installationFileName), ProjectDirectory: projectDirectory}
@@ -0,0 +1,333 @@
// Package serverops implements bounded, installation-aware server maintenance operations.
package serverops
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
)
var (
ErrConfirmationRequired = errors.New("explicit confirmation is required")
ErrUnsafeState = errors.New("server operation refused in the current state")
)
type Runner interface {
Run(context.Context, []string, io.Reader) (compose.Result, error)
}
type MigrationStatus struct {
Applied []string `json:"applied"`
Drifted []string `json:"drifted"`
Pending []string `json:"pending"`
}
type Container struct {
ID string `json:"ID"`
Name string `json:"Name"`
Service string `json:"Service"`
State string `json:"State"`
}
type RemovalResult struct {
Targets []Container
Preserved int
}
// MigrateSessions runs only the one-shot migration service and proves the resulting schema state.
func MigrateSessions(ctx context.Context, installation config.Installation, runner Runner, confirmed bool) (MigrationStatus, error) {
if !confirmed {
return MigrationStatus{}, ErrConfirmationRequired
}
if installation.Profile != "server" {
return MigrationStatus{}, fmt.Errorf("%w: session migration requires a server installation", ErrUnsafeState)
}
containers, err := inspectContainers(ctx, installation, runner)
if err != nil {
return MigrationStatus{}, err
}
if err := requireStopped(containers); err != nil {
return MigrationStatus{}, err
}
rendered, err := runCompose(ctx, runner, installation.ComposeArgs("--profile", "session-migrate", "config", "--format", "json"))
if err != nil {
return MigrationStatus{}, err
}
coreImage, err := selectedCoreImage(rendered.Stdout)
if err != nil {
return MigrationStatus{}, err
}
override, cleanup, err := migrationOverride(installation, coreImage)
if err != nil {
return MigrationStatus{}, err
}
defer cleanup()
configArgs, err := installation.ComposeArgsWithFinalOverride(override, "--profile", "session-migrate", "config", "--format", "json")
if err != nil {
return MigrationStatus{}, err
}
finalConfig, err := runCompose(ctx, runner, configArgs)
if err != nil {
return MigrationStatus{}, err
}
if err := requireMigrationImage(finalConfig.Stdout, coreImage); err != nil {
return MigrationStatus{}, err
}
runArgs, err := installation.ComposeArgsWithFinalOverride(
override, "--profile", "session-migrate", "run", "--rm", "--no-deps", "--no-TTY", "session-migrate",
)
if err != nil {
return MigrationStatus{}, err
}
result, err := runCompose(ctx, runner, runArgs)
if err != nil {
return MigrationStatus{}, err
}
status, err := parseMigrationStatus(result.Stdout)
if err != nil {
return MigrationStatus{}, err
}
if len(status.Pending) != 0 || len(status.Drifted) != 0 {
return status, fmt.Errorf("%w: session migration did not finish cleanly", ErrUnsafeState)
}
return status, nil
}
// Remove deletes only the exact stopped core/frontend container IDs displayed by the command.
// A nil confirmation performs inspection only; a non-nil confirmation must equal every target ID.
func Remove(ctx context.Context, installation config.Installation, runner Runner, confirmedIDs []string) (RemovalResult, error) {
if installation.Profile != "server" {
return RemovalResult{}, fmt.Errorf("%w: removal requires a server installation", ErrUnsafeState)
}
targets, err := inspectContainers(ctx, installation, runner)
result := RemovalResult{Targets: targets}
if err != nil {
return result, err
}
if err := requireStopped(targets); err != nil {
return result, err
}
if confirmedIDs == nil {
return result, ErrConfirmationRequired
}
if !sameTargetIDs(targets, confirmedIDs) {
return result, fmt.Errorf("%w: confirmed container IDs differ from current targets", ErrUnsafeState)
}
paths, err := installation.PreservationPaths()
if err != nil {
return result, fmt.Errorf("%w: preservation paths could not be verified", ErrUnsafeState)
}
snapshots, err := snapshotPaths(paths)
if err != nil {
return result, err
}
if len(targets) > 0 {
args := []string{"rm"}
for _, target := range targets {
args = append(args, target.ID)
}
if _, err := runDocker(ctx, runner, args); err != nil {
return result, err
}
}
remaining, err := inspectContainers(ctx, installation, runner)
if err != nil {
return result, err
}
if len(remaining) != 0 {
return result, fmt.Errorf("%w: installation containers changed during removal", ErrUnsafeState)
}
if err := verifySnapshots(snapshots); err != nil {
return result, err
}
result.Preserved = len(snapshots)
return result, nil
}
func sameTargetIDs(targets []Container, confirmed []string) bool {
if len(targets) != len(confirmed) {
return false
}
wanted := make(map[string]struct{}, len(confirmed))
for _, id := range confirmed {
if strings.TrimSpace(id) == "" {
return false
}
if _, duplicate := wanted[id]; duplicate {
return false
}
wanted[id] = struct{}{}
}
for _, target := range targets {
if _, exists := wanted[target.ID]; !exists {
return false
}
}
return true
}
func inspectContainers(ctx context.Context, installation config.Installation, runner Runner) ([]Container, error) {
result, err := runCompose(ctx, runner, installation.ComposeArgs("ps", "--all", "--format", "json", "core", "frontend"))
if err != nil {
return nil, err
}
var containers []Container
if err := json.Unmarshal([]byte(result.Stdout), &containers); err != nil {
return nil, fmt.Errorf("%w: Compose returned invalid container status", ErrUnsafeState)
}
seen := make(map[string]struct{})
for _, container := range containers {
if (container.Service != "core" && container.Service != "frontend") || container.ID == "" || container.Name == "" {
return nil, fmt.Errorf("%w: Compose returned an unexpected removal target", ErrUnsafeState)
}
if _, exists := seen[container.ID]; exists {
return nil, fmt.Errorf("%w: Compose returned duplicate container IDs", ErrUnsafeState)
}
seen[container.ID] = struct{}{}
}
return containers, nil
}
func requireStopped(containers []Container) error {
for _, container := range containers {
if strings.ToLower(container.State) != "exited" {
return fmt.Errorf("%w: %s is not stopped", ErrUnsafeState, container.Service)
}
}
return nil
}
func selectedCoreImage(document string) (string, error) {
services, err := renderedServices(document)
if err != nil {
return "", err
}
core, exists := services["core"]
if !exists || strings.TrimSpace(core.Image) == "" {
return "", fmt.Errorf("%w: rendered core image is missing", ErrUnsafeState)
}
if _, exists := services["session-migrate"]; !exists {
return "", fmt.Errorf("%w: rendered migration service is missing", ErrUnsafeState)
}
return core.Image, nil
}
type renderedService struct {
Image string `json:"image"`
Build json.RawMessage `json:"build"`
}
func renderedServices(document string) (map[string]renderedService, error) {
var configDocument struct {
Services map[string]renderedService `json:"services"`
}
if err := json.Unmarshal([]byte(document), &configDocument); err != nil {
return nil, fmt.Errorf("%w: Compose returned invalid rendered configuration", ErrUnsafeState)
}
return configDocument.Services, nil
}
func requireMigrationImage(document, coreImage string) error {
services, err := renderedServices(document)
if err != nil {
return err
}
migrator, exists := services["session-migrate"]
if !exists || migrator.Image != coreImage {
return fmt.Errorf("%w: migration image differs from selected core image", ErrUnsafeState)
}
if len(migrator.Build) != 0 && strings.TrimSpace(string(migrator.Build)) != "null" {
return fmt.Errorf("%w: migration service unexpectedly declares a build", ErrUnsafeState)
}
return nil
}
func migrationOverride(installation config.Installation, image string) (string, func(), error) {
control := installation.ControlDirectory()
if err := os.MkdirAll(control, 0o700); err != nil {
return "", func() {}, errors.New("migration control directory could not be created")
}
info, err := os.Lstat(control)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return "", func() {}, errors.New("migration control directory is unsafe")
}
directory, err := os.MkdirTemp(control, "session-migrate-")
if err != nil {
return "", func() {}, errors.New("migration override directory could not be created")
}
cleanup := func() {
_ = os.Remove(filepath.Join(directory, "override.yaml"))
_ = os.Remove(directory)
}
path := filepath.Join(directory, "override.yaml")
contents := "services:\n session-migrate:\n build: !reset null\n image: " + strconv.Quote(image) + "\n"
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
cleanup()
return "", func() {}, errors.New("migration override could not be written")
}
return path, cleanup, nil
}
func parseMigrationStatus(document string) (MigrationStatus, error) {
var status MigrationStatus
decoder := json.NewDecoder(strings.NewReader(document))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&status); err != nil || status.Applied == nil || status.Drifted == nil || status.Pending == nil {
return MigrationStatus{}, fmt.Errorf("%w: migration did not return verified JSON status", ErrUnsafeState)
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return MigrationStatus{}, fmt.Errorf("%w: migration returned trailing output", ErrUnsafeState)
}
return status, nil
}
type pathSnapshot struct {
path string
info os.FileInfo
}
func snapshotPaths(paths []string) ([]pathSnapshot, error) {
snapshots := make([]pathSnapshot, 0, len(paths))
for _, path := range paths {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("%w: preservation target is unavailable", ErrUnsafeState)
}
snapshots = append(snapshots, pathSnapshot{path: path, info: info})
}
return snapshots, nil
}
func verifySnapshots(snapshots []pathSnapshot) error {
for _, snapshot := range snapshots {
info, err := os.Stat(snapshot.path)
if err != nil || !os.SameFile(snapshot.info, info) {
return fmt.Errorf("%w: a preserved path changed during removal", ErrUnsafeState)
}
}
return nil
}
func runCompose(ctx context.Context, runner Runner, args []string) (compose.Result, error) {
return runDocker(ctx, runner, args)
}
func runDocker(ctx context.Context, runner Runner, args []string) (compose.Result, error) {
result, err := runner.Run(ctx, args, nil)
if err != nil {
return result, errors.New("Docker operation failed")
}
return result, nil
}
@@ -0,0 +1,314 @@
package serverops
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
)
type fakeRunner struct {
run func(args []string) (compose.Result, error)
all [][]string
}
func (r *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
r.all = append(r.all, append([]string(nil), args...))
return r.run(args)
}
func TestMigrateSessionsUsesOnlyTheMigrationProfileAndSelectedCoreImage(t *testing.T) {
for _, image := range []string{
"thothii-core:local",
"registry.example.invalid/thothii/core@sha256:" + strings.Repeat("a", 64),
} {
t.Run(image, func(t *testing.T) {
installation := testInstallation(t)
if err := os.MkdirAll(installation.ControlDirectory(), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(installation.CurrentImageOverridePath(), []byte("services:\n core:\n image: "+image+"\n"), 0o600); err != nil {
t.Fatal(err)
}
var temporaryOverride string
configCalls := 0
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
switch {
case contains(args, "ps", "--all", "--format", "json", "core", "frontend"):
return compose.Result{Stdout: `[{"ID":"core-id","Name":"core-name","Service":"core","State":"exited"},{"ID":"front-id","Name":"front-name","Service":"frontend","State":"exited"}]`}, nil
case contains(args, "--profile", "session-migrate", "config", "--format", "json"):
configCalls++
if configCalls == 1 {
return compose.Result{Stdout: `{"services":{"core":{"image":"` + image + `"},"session-migrate":{"image":"thothii-core:local"}}}`}, nil
}
temporaryOverride = lastComposeFile(args)
contents, err := os.ReadFile(temporaryOverride)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(contents), "image: "+strconv.Quote(image)) || !strings.Contains(string(contents), "build: !reset null") {
t.Fatalf("migration override = %q", contents)
}
if indexOf(args, installation.CurrentImageOverridePath()) >= indexOf(args, temporaryOverride) {
t.Fatalf("temporary override does not follow durable selector: %#v", args)
}
return compose.Result{Stdout: `{"services":{"core":{"image":"` + image + `"},"session-migrate":{"image":"` + image + `"}}}`}, nil
case contains(args, "--profile", "session-migrate", "run", "--rm", "--no-deps", "--no-TTY", "session-migrate"):
return compose.Result{Stdout: `{"applied":["0001"],"drifted":[],"pending":[]}` + "\n"}, nil
default:
t.Fatalf("unexpected Docker invocation: %#v", args)
return compose.Result{}, nil
}
}}
status, err := MigrateSessions(context.Background(), installation, runner, true)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(status.Pending, []string{}) || !reflect.DeepEqual(status.Drifted, []string{}) {
t.Fatalf("status = %#v", status)
}
if temporaryOverride == "" {
t.Fatal("migration override was not inspected")
}
if _, err := os.Stat(temporaryOverride); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("temporary override remains after migration: %v", err)
}
})
}
}
func TestMigrateSessionsFailsClosedBeforeMutation(t *testing.T) {
installation := testInstallation(t)
for name, spec := range map[string]struct {
confirmed bool
ps string
migrationJSON string
}{
"confirmation missing": {false, `[]`, `{"applied":[],"drifted":[],"pending":[]}`},
"service running": {true, `[{"ID":"core-id","Name":"core","Service":"core","State":"running"}]`, `{"applied":[],"drifted":[],"pending":[]}`},
"pending migration": {true, `[]`, `{"applied":[],"drifted":[],"pending":["0002"]}`},
"drifted migration": {true, `[]`, `{"applied":[],"drifted":["0001"],"pending":[]}`},
} {
t.Run(name, func(t *testing.T) {
runCalled := false
configCalls := 0
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
switch {
case contains(args, "ps", "--all"):
return compose.Result{Stdout: spec.ps}, nil
case contains(args, "config", "--format", "json"):
configCalls++
return compose.Result{Stdout: `{"services":{"core":{"image":"thothii-core:local"},"session-migrate":{"image":"thothii-core:local"}}}`}, nil
case contains(args, "run", "--rm", "--no-deps", "--no-TTY", "session-migrate"):
runCalled = true
return compose.Result{Stdout: spec.migrationJSON}, nil
default:
t.Fatalf("unexpected Docker invocation: %#v", args)
return compose.Result{}, nil
}
}}
_, err := MigrateSessions(context.Background(), installation, runner, spec.confirmed)
if err == nil {
t.Fatal("MigrateSessions() error = nil")
}
if !spec.confirmed && len(runner.all) != 0 {
t.Fatalf("Docker invoked without confirmation: %#v", runner.all)
}
if strings.Contains(name, "service running") && (runCalled || configCalls != 0) {
t.Fatalf("migration advanced while app was running: %#v", runner.all)
}
})
}
}
func TestRemovePreservesEveryDeclaredBindSecretAndBackup(t *testing.T) {
installation, preserved := removalInstallation(t)
psCalls := 0
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
switch {
case contains(args, "ps", "--all", "--format", "json", "core", "frontend"):
psCalls++
if psCalls == 1 {
return compose.Result{Stdout: `[{"ID":"core-id","Name":"project-core-1","Service":"core","State":"exited"},{"ID":"frontend-id","Name":"project-frontend-1","Service":"frontend","State":"exited"}]`}, nil
}
return compose.Result{Stdout: `[]`}, nil
case reflect.DeepEqual(args, []string{"rm", "core-id", "frontend-id"}):
return compose.Result{Stdout: "core-id\nfrontend-id\n"}, nil
default:
t.Fatalf("unexpected Docker invocation: %#v", args)
return compose.Result{}, nil
}
}}
result, err := Remove(context.Background(), installation, runner, []string{"core-id", "frontend-id"})
if err != nil {
t.Fatal(err)
}
if result.Preserved != len(preserved) {
t.Fatalf("preserved = %d, want %d", result.Preserved, len(preserved))
}
if got := result.Targets; len(got) != 2 || got[0].ID != "core-id" || got[1].ID != "frontend-id" {
t.Fatalf("targets = %#v", got)
}
for _, args := range runner.all {
joined := strings.Join(args, " ")
if strings.Contains(joined, " -v") || strings.Contains(joined, "volume") || strings.Contains(joined, "down") || strings.Contains(joined, "prune") {
t.Fatalf("destructive removal invocation: %q", joined)
}
}
for _, path := range preserved {
if _, err := os.Stat(path); err != nil {
t.Errorf("preserved path %q: %v", path, err)
}
}
}
func TestRemoveDisplaysTargetsButDoesNotMutateWithoutConfirmation(t *testing.T) {
installation, _ := removalInstallation(t)
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
if !contains(args, "ps", "--all") {
t.Fatalf("mutation without confirmation: %#v", args)
}
return compose.Result{Stdout: `[{"ID":"core-id","Name":"project-core-1","Service":"core","State":"exited"}]`}, nil
}}
result, err := Remove(context.Background(), installation, runner, nil)
if !errors.Is(err, ErrConfirmationRequired) {
t.Fatalf("Remove() error = %v, want confirmation", err)
}
if len(result.Targets) != 1 || result.Targets[0].ID != "core-id" {
t.Fatalf("targets = %#v", result.Targets)
}
if len(runner.all) != 1 {
t.Fatalf("Docker calls = %#v", runner.all)
}
}
func TestRemoveRejectsRunningOrReplacedContainers(t *testing.T) {
for name, spec := range map[string]struct{ first, second string }{
"running": {`[{"ID":"core-id","Name":"core","Service":"core","State":"running"}]`, `[]`},
"replaced": {`[{"ID":"core-id","Name":"core","Service":"core","State":"exited"}]`, `[{"ID":"new-id","Name":"core","Service":"core","State":"exited"}]`},
} {
t.Run(name, func(t *testing.T) {
installation, _ := removalInstallation(t)
psCalls := 0
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
if contains(args, "ps", "--all") {
psCalls++
if psCalls == 1 {
return compose.Result{Stdout: spec.first}, nil
}
return compose.Result{Stdout: spec.second}, nil
}
if reflect.DeepEqual(args, []string{"rm", "core-id"}) {
return compose.Result{}, nil
}
t.Fatalf("unexpected Docker invocation: %#v", args)
return compose.Result{}, nil
}}
_, err := Remove(context.Background(), installation, runner, []string{"core-id"})
if err == nil {
t.Fatal("Remove() error = nil")
}
if name == "running" && len(runner.all) != 1 {
t.Fatalf("running container was mutated: %#v", runner.all)
}
})
}
}
func TestRemoveRejectsConfirmationForDifferentContainerIDs(t *testing.T) {
installation, _ := removalInstallation(t)
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
if !contains(args, "ps", "--all") {
t.Fatalf("mismatched confirmation caused mutation: %#v", args)
}
return compose.Result{Stdout: `[{"ID":"replacement-id","Name":"core","Service":"core","State":"exited"}]`}, nil
}}
result, err := Remove(context.Background(), installation, runner, []string{"previously-displayed-id"})
if !errors.Is(err, ErrUnsafeState) || len(result.Targets) != 1 {
t.Fatalf("Remove() = %#v, %v", result, err)
}
if len(runner.all) != 1 {
t.Fatalf("Docker calls = %#v", runner.all)
}
}
func testInstallation(t *testing.T) config.Installation {
t.Helper()
root := t.TempDir()
project := filepath.Join(root, "project")
if err := os.Mkdir(project, 0o700); err != nil {
t.Fatal(err)
}
return config.Installation{
Path: filepath.Join(root, "thothii-installation.yaml"), Profile: "server",
ProjectDirectory: project, EnvFile: filepath.Join(root, "server.env"),
}
}
func removalInstallation(t *testing.T) (config.Installation, []string) {
t.Helper()
installation := testInstallation(t)
paths := make([]string, 0, 5)
values := map[string]string{}
for _, name := range []string{"data", "pi-state", "workspace-registry", "backups"} {
path := filepath.Join(filepath.Dir(installation.Path), name)
if err := os.Mkdir(path, 0o700); err != nil {
t.Fatal(err)
}
paths = append(paths, path)
values[name] = path
}
secret := filepath.Join(filepath.Dir(installation.Path), "secret")
if err := os.WriteFile(secret, []byte("never-log-this"), 0o600); err != nil {
t.Fatal(err)
}
paths = append(paths, secret)
env := "THT_DATA_ROOT=" + values["data"] + "\n" +
"THT_PI_STATE_ROOT=" + values["pi-state"] + "\n" +
"THT_WORKSPACE_REGISTRY_ROOT=" + values["workspace-registry"] + "\n" +
"THT_BACKUP_ROOT=" + values["backups"] + "\n" +
"APP_TOKEN_FILE=" + secret + "\n"
if err := os.WriteFile(installation.EnvFile, []byte(env), 0o600); err != nil {
t.Fatal(err)
}
return installation, paths
}
func contains(values []string, sequence ...string) bool {
for start := range values {
if start+len(sequence) <= len(values) && reflect.DeepEqual(values[start:start+len(sequence)], sequence) {
return true
}
}
return false
}
func indexOf(values []string, value string) int {
for index, candidate := range values {
if candidate == value {
return index
}
}
return -1
}
func lastComposeFile(args []string) string {
last := ""
for index := 0; index+1 < len(args); index++ {
if args[index] == "-f" {
last = args[index+1]
}
}
return last
}