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:
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user