app store: compose templates and their setup scripts, with two proven end to end

Each provisionable service gets a directory holding a compose template and a setup.sh. Deliberately the
shape a sidecar needs once it lives in its own repository: metadata, compose, setup script, schema.

The contract (templates/README.md): answers come from the ENVIRONMENT, so the web form fills them in and
a person on a VPS is prompted only for what is missing, and only on a TTY — one script for both, not two
code paths. Idempotent, writes only inside its own directory, streams progress on stdout (the installer
pipes it to a terminal panel), and returns results as OFFICER_RESULT_<KEY>= lines so nothing has to
scrape a log.

House conventions throughout: relative bind mounts so data sits beside the compose file rather than
hiding behind `docker volume inspect`, containers running as the installing user so downloads are not
root-owned, loopback-only ports unless the service's whole job is inbound connections, and no external
networks — the owner's own composes attach to an `nginx` network that a fresh VPS does not have.

Transmission verified end to end on this machine, on non-conflicting ports, then torn down: renders,
starts, waits, reports. Its health check accepts 409 because Transmission rejects the first request by
design — only-200 would have waited out the full timeout against a working daemon. Re-run produced
exactly one container, and files landed owned by the user rather than root.

Vaultwarden covers the case where we GENERATE the credential rather than asking for one. An existing
token is reused, never rotated, because rotating during a resumed install would lock the owner out of
the admin page. The Argon2 hash has its `$` doubled or compose interpolation mangles it. The token is
not returned to the platform at all — the vault sidecar proxies the Bitwarden protocol and never needs
it, and a secret we do not hold is one we cannot leak.

Corrects the design doc, which assumed provisioning always knows the connection. Three shapes: we set
the credential, we generate it, or a human must mint it in the service's UI afterwards (Immich, Jellyfin,
Memos). The third makes "provisioned and running but not yet connected" a real state rather than a
failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:44:30 +00:00
co-authored by Claude Opus 5
parent 7359867f7f
commit 890f57a7a6
6 changed files with 351 additions and 0 deletions
+19
View File
@@ -227,6 +227,25 @@ What a plugin author is promised, and bound by. To be written properly; the shap
---
## Provisioning has three shapes, not one
This document originally said provisioning "writes the connection we already know". That is only true
some of the time, and the difference decides whether an install can finish unattended:
1. **We set the credentials.** Passed as container environment, so the connection is known the moment it
is up. Transmission (`USER`/`PASS`), Vaultwarden (`ADMIN_TOKEN`).
2. **We generate a secret into a file.** The bind mount lets us write it before first boot, so it is
still known without asking. slskd's API key lives in its `slskd.yml`.
3. **A human must mint a token in the service's own UI after it boots.** Immich, Jellyfin and Memos all
work this way — no environment variable pre-seeds an API key.
Shape 3 means an install can be **provisioned and running but not yet connected**. That is a real state,
not a failure: the container is up, the compose file is written, and we are waiting for a token. The
step machine stops there, and the UI asks for the key with a link to the page that mints it. Resuming
finishes the job — which is what `completedSteps` was for.
---
## What Phase 0 must not foreclose
Three things are coming, and each one constrains a decision that looks free today.