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
@@ -0,0 +1,40 @@
# Transmission — the daemon Officer's transmission sidecar drives over RPC.
#
# Rendered by setup.sh; ${...} are substituted before this reaches disk. Everything the container keeps
# lives beside this file, so `ls` answers "what is this service storing" without docker involved.
name: officer-transmission
services:
transmission:
image: lscr.io/linuxserver/transmission:latest
container_name: officer-transmission
restart: unless-stopped
# As the installing user, so completed downloads are not root-owned — the single most annoying
# thing about a container that writes to a shared folder.
user: '${OFFICER_UID}:${OFFICER_GID}'
environment:
- PUID=${OFFICER_UID}
- PGID=${OFFICER_GID}
- TZ=${OFFICER_TZ}
# Blank means no RPC auth, which is Transmission's normal posture and is safe HERE specifically
# because the RPC port is bound to loopback below. setup.sh fills these only if the user asked for
# credentials; an empty USER is not the same as the variable being unset.
- USER=${TRANSMISSION_USER}
- PASS=${TRANSMISSION_PASS}
ports:
# Loopback only. Officer talks to this over 127.0.0.1; nothing else has any business reaching the
# RPC endpoint, and publishing it on all interfaces would put an unauthenticated control API on the
# network because "no auth" is the default above.
- '127.0.0.1:${TRANSMISSION_PORT}:9091'
# Peer traffic, and the exception to the loopback rule: BitTorrent peers must be able to connect
# inbound or the client is crippled to outbound-only connections.
- '${TRANSMISSION_PEER_PORT}:51413'
- '${TRANSMISSION_PEER_PORT}:51413/udp'
volumes:
- ./config:/config
- ./downloads:/downloads
- ./watch:/watch
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Provision Transmission for Officer.
#
# Answers come from the environment; a human running this by hand is prompted for what is missing, but
# only when stdin is a TTY. See ../README.md for the contract every one of these obeys.
#
# OFFICER_SERVICE_DIR where to write. Everything this script creates is inside it.
# OFFICER_UID/GID who the container runs as
# TRANSMISSION_PORT RPC port on loopback (default 9091)
# TRANSMISSION_PEER_PORT BitTorrent listen port (default 51413)
# TRANSMISSION_USER/PASS optional RPC credentials (default: none, loopback-only)
#
# Idempotent: safe to re-run, which is what makes a resumed install work rather than duplicate.
set -euo pipefail
SERVICE_DIR="${OFFICER_SERVICE_DIR:?OFFICER_SERVICE_DIR is required}"
TEMPLATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OFFICER_UID="${OFFICER_UID:-$(id -u)}"
OFFICER_GID="${OFFICER_GID:-$(id -g)}"
OFFICER_TZ="${OFFICER_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
# ── Asking ────────────────────────────────────────────────────────────────────────────────────────────
# A prompt behind a web form is a hang with no output. When the caller says it cannot answer, fail with
# the reason instead of blocking forever on a read nobody will ever satisfy.
ask() {
local var="$1" prompt="$2" default="${3:-}"
local current="${!var:-}"
if [ -n "$current" ]; then return 0; fi
if [ "${OFFICER_NONINTERACTIVE:-0}" = "1" ] || [ ! -t 0 ]; then
if [ -n "$default" ]; then printf -v "$var" '%s' "$default"; return 0; fi
echo "error: $var is required and this is a non-interactive run" >&2
exit 2
fi
local answer
read -r -p "$prompt${default:+ [$default]}: " answer
printf -v "$var" '%s' "${answer:-$default}"
}
ask TRANSMISSION_PORT 'Transmission RPC port (loopback only)' '9091'
ask TRANSMISSION_PEER_PORT 'BitTorrent peer port' '51413'
TRANSMISSION_USER="${TRANSMISSION_USER:-}"
TRANSMISSION_PASS="${TRANSMISSION_PASS:-}"
# ── Render ────────────────────────────────────────────────────────────────────────────────────────────
echo "==> Preparing $SERVICE_DIR"
mkdir -p "$SERVICE_DIR/config" "$SERVICE_DIR/downloads" "$SERVICE_DIR/watch"
# envsubst with an explicit variable list, never the bare form: unrestricted envsubst would also expand
# anything in the template that merely looks like a variable, and a compose file is full of $ that
# belongs to other tools.
export OFFICER_UID OFFICER_GID OFFICER_TZ TRANSMISSION_PORT TRANSMISSION_PEER_PORT TRANSMISSION_USER TRANSMISSION_PASS
envsubst '${OFFICER_UID} ${OFFICER_GID} ${OFFICER_TZ} ${TRANSMISSION_PORT} ${TRANSMISSION_PEER_PORT} ${TRANSMISSION_USER} ${TRANSMISSION_PASS}' \
< "$TEMPLATE_DIR/docker-compose.yaml" > "$SERVICE_DIR/docker-compose.yaml"
echo "==> Starting the container"
# `up -d` is already idempotent: an unchanged compose file against a running container is a no-op, and a
# changed one recreates. That is the whole reason a re-run is safe.
docker compose --project-directory "$SERVICE_DIR" up -d
# ── Wait ──────────────────────────────────────────────────────────────────────────────────────────────
# Reporting success the moment `up -d` returns would be a lie: the container exists, the RPC endpoint is
# not listening yet, and the platform's first call would fail against a service we just said was ready.
echo "==> Waiting for the RPC endpoint"
for i in $(seq 1 60); do
# 409 is the correct healthy answer here — Transmission demands a session id and rejects the first
# request by design. Treating only 200 as healthy would wait out the full timeout on a working daemon.
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${TRANSMISSION_PORT}/transmission/rpc" || true)"
if [ "$code" = "409" ] || [ "$code" = "200" ] || [ "$code" = "401" ]; then
echo " up after ${i}s (HTTP $code)"
break
fi
if [ "$i" = "60" ]; then
echo "error: Transmission did not answer on 127.0.0.1:${TRANSMISSION_PORT} within 60s" >&2
echo " check: docker compose --project-directory '$SERVICE_DIR' logs" >&2
exit 1
fi
sleep 1
done
# ── Hand the connection back ──────────────────────────────────────────────────────────────────────────
# Shape 1 from the README: we set the credentials, so nothing has to be asked for after the fact. These
# lines are the installer's only interface to this script's results — parsed by prefix, never by
# scraping the log above.
echo "OFFICER_RESULT_URL=http://127.0.0.1:${TRANSMISSION_PORT}"
echo "OFFICER_RESULT_PATH=/transmission/rpc"
echo "OFFICER_RESULT_USERNAME=${TRANSMISSION_USER}"
echo "OFFICER_RESULT_SECRET=${TRANSMISSION_PASS}"
echo "==> Done"