diff --git a/deploy/README.md b/deploy/README.md index e70191b..bc9975a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -4,6 +4,77 @@ Files here run on the Docker **host**, not inside the faucet container, so they are not copied in by the Dockerfile and have to be installed by hand. They live in git so a host rebuild doesn't silently lose them. +## How config actually resolves + +Three layers, highest priority **last**: + +| Layer | Holds | Owner | +| --- | --- | --- | +| `deploy/stack.yml` → `environment:` | `EVENTS_FILE`, `HUNT_MAX_*`, `MEDIASORT_MODE`, `DEFAULT_LANGUAGE` | this repo | +| `/opt/cascade-config/faucet.secrets.env` | `SESSION_SECRET` | host, root:root 600 | +| `/opt/cascade-config/faucet.env` | everything in `config.py`'s `WIZARD_KEYS` | Faucet's Settings panel | + +The third layer is the one that surprises people. `config.py::_load_persisted()` +writes that file's keys **into `os.environ`** at import time, so it overrides +anything the container was started with. Setting `JACKETT_URL` or `CLIENT_PASS` +in the stack file does nothing while `faucet.env` defines them — change those in +Settings, not in compose. + +Corollary worth remembering when rotating credentials: rotating at the source +(Discord, Jackett, Transmission) and updating the stack file is **not enough**. +`faucet.env` still holds the old value and still wins. + +## deploy.sh + +Replaces the old manual sequence — `git pull`, `docker build` by hand, click +redeploy in Portainer — with one command. + +```bash +sudo install -m 0755 -o root -g root deploy/deploy.sh /usr/local/sbin/faucet-deploy +sudo /usr/local/sbin/faucet-deploy +``` + +**Install it root-owned; don't sudo it from the repo path.** The checkout is +owned by the ops account, so a `NOPASSWD` sudoers rule pointing at +`/opt/faucet/deploy/deploy.sh` would let that account rewrite the script and +run anything as root. The installed copy has to be somewhere it can't write. +Re-run the `install` after any change to the script — the repo copy is the +source, `/usr/local/sbin/faucet-deploy` is what actually runs. + +What it does: fast-forward the checkout (as the owning account, so the tree +doesn't turn root-owned), rebuild `faucet:local`, then +`docker compose -p cascade -f deploy/stack.yml up -d --force-recreate faucet`. + +Two details that are load-bearing: + +- **`--force-recreate`.** The image tag never changes, so compose otherwise + sees no reason to replace the running container and the deploy silently + succeeds while changing nothing. +- **`merge --ff-only`.** A diverged or dirty checkout stops the deploy instead + of producing a merge commit on a production box. + +It exits non-zero if the container isn't `running` afterward, so a failed +deploy reports as failed rather than looking clean. + +To deploy a specific tag rather than the head of `main`, check it out by hand +first — the script deliberately has no ref argument, because its whole job is +"make the box match `main`". + +## stack.yml + +The compose definition, previously living only in Portainer's data dir under +`/data/appdata/portainer/compose/18/` — root-only, untracked, and carrying four +live secrets inline. + +Portainer still shows the stack (the compose labels are unchanged), but +`deploy.sh` is what deploys now. Portainer's own "Update the stack" button +would redeploy from *its* stored copy, which no longer gets updated — so use +the script, not the button. + +Portainer can't run this deploy itself: it only mounts `/var/run/docker.sock` +and its own data directory, so neither a `build:` context pointing at +`/opt/faucet` nor an `env_file:` at a host path would resolve from inside it. + ## cascade-hook-wrapper.sh Transmission's completion hook. Transmission runs on the host and writes to diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..847bda1 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# faucet deploy — pull, rebuild the image, recreate the stack. +# +# Runs as root (docker + compose). Git operations drop to the account that +# owns the checkout so the working tree doesn't end up root-owned, which would +# break every subsequent non-root git command in it. +# +# Install root-owned to /usr/local/sbin/faucet-deploy -- NOT sudo'd from its +# path in the repo. A NOPASSWD sudoers rule pointing at a script the invoking +# account can write is a root shell with extra steps. +set -euo pipefail + +REPO_DIR="${FAUCET_REPO_DIR:-/opt/faucet}" +STACK_FILE="${FAUCET_STACK_FILE:-$REPO_DIR/deploy/stack.yml}" +PROJECT="${FAUCET_PROJECT:-cascade}" +IMAGE="${FAUCET_IMAGE:-faucet:local}" +REPO_USER="${FAUCET_REPO_USER:-mcpops}" +SERVICE="${FAUCET_SERVICE:-faucet}" + +log() { printf '==> %s\n' "$*"; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "must run as root (needs docker)" +[[ -d $REPO_DIR/.git ]] || die "$REPO_DIR is not a git checkout" +[[ -f $STACK_FILE ]] || die "stack file not found: $STACK_FILE" +command -v docker >/dev/null || die "docker not found" +docker compose version >/dev/null 2>&1 || die "docker compose plugin not available" + +git_as() { sudo -u "$REPO_USER" git -C "$REPO_DIR" "$@"; } + +# --- 1. update the checkout ------------------------------------------------ +log "fetching in $REPO_DIR" +before="$(git_as rev-parse HEAD)" +git_as fetch --prune + +# ff-only: a diverged or dirty checkout stops here rather than producing a +# merge commit nobody asked for on a production box. +if ! git_as merge --ff-only '@{u}'; then + die "cannot fast-forward $REPO_DIR -- diverged or has local commits. Resolve by hand." +fi + +after="$(git_as rev-parse HEAD)" +if [[ $before == "$after" ]]; then + log "already at $after (no new commits)" +else + log "$before -> $after" + git_as --no-pager log --oneline "$before..$after" +fi + +# --- 2. rebuild ------------------------------------------------------------ +log "building $IMAGE from $REPO_DIR" +docker build -t "$IMAGE" "$REPO_DIR" + +# --- 3. recreate ----------------------------------------------------------- +# --force-recreate is required: the tag is unchanged, so without it compose +# sees no reason to replace a running container and the deploy silently no-ops. +log "recreating $SERVICE in project $PROJECT" +docker compose -p "$PROJECT" -f "$STACK_FILE" up -d --force-recreate "$SERVICE" + +# --- 4. report ------------------------------------------------------------- +log "result" +docker ps -a --filter "name=^/${SERVICE}$" \ + --format 'name={{.Names}} image={{.Image}} state={{.State}} status={{.Status}}' + +state="$(docker inspect -f '{{.State.Status}}' "$SERVICE" 2>/dev/null || echo unknown)" +[[ $state == running ]] || die "$SERVICE is '$state' after deploy -- check: docker logs $SERVICE" + +log "deployed $after" diff --git a/deploy/faucet.secrets.env.example b/deploy/faucet.secrets.env.example new file mode 100644 index 0000000..68962c3 --- /dev/null +++ b/deploy/faucet.secrets.env.example @@ -0,0 +1,11 @@ +# Install to /opt/cascade-config/faucet.secrets.env, root-owned, mode 600. +# Read by deploy/stack.yml via env_file. Never commit the real file. +# +# Only SESSION_SECRET belongs here. Everything else the app needs is a +# WIZARD_KEY in config.py and lives in /opt/cascade-config/faucet.env, which is +# loaded over process env and would override anything set here anyway. +# +# Generate: +# python3 -c 'import secrets; print(secrets.token_urlsafe(48))' + +SESSION_SECRET=replace-me diff --git a/deploy/stack.yml b/deploy/stack.yml new file mode 100644 index 0000000..1cfaab0 --- /dev/null +++ b/deploy/stack.yml @@ -0,0 +1,50 @@ +# deploy/stack.yml — the faucet service. +# +# Deployed by deploy/deploy.sh: +# docker compose -p cascade -f deploy/stack.yml up -d --force-recreate +# +# There are deliberately no secrets in this file. +# +# Config reaches the app in three layers, highest priority last: +# +# 1. this file's `environment:` — structural settings the app reads +# directly from os.environ +# 2. env_file — SESSION_SECRET, which config.py excludes +# from the wizard on purpose +# 3. /config/faucet.env — everything in config.py's WIZARD_KEYS. +# _load_persisted() writes these INTO the +# environment at import, so they override +# anything set above. Faucet's Settings +# panel owns this file. +# +# That last point is the one that bites: putting JACKETT_URL or CLIENT_PASS +# here does nothing if faucet.env defines them. Change those in Settings. + +services: + faucet: + image: faucet:local + container_name: faucet + restart: unless-stopped + + ports: + # host 8090 -> container 8088. 8088 on the host is the old mediapipe app. + - "8090:8088" + + # SESSION_SECRET only. Root-owned 600 on the host; never in git. + env_file: + - /opt/cascade-config/faucet.secrets.env + + environment: + # Not WIZARD_KEYS — the Settings panel cannot write these, so here is + # their only home. + EVENTS_FILE: "/config/events.jsonl" + HUNT_MAX_ACTIVE: "5" + HUNT_MAX_PER_RUN: "3" + MEDIASORT_MODE: "move" + DEFAULT_LANGUAGE: "en" + + volumes: + - /mnt/nas:/nas + - /mnt/nas/media:/library + - /mnt/nas/torrents:/downloads + - /opt/cascade-config:/config