From be9dee8fb8467d75d2a73d05437be367a0778e82 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:23:49 -0400 Subject: [PATCH 1/4] deploy: one-shot deploy script (pull, build, recreate) Replaces the manual three-step: git pull, docker build by hand, click redeploy in Portainer. Fails loudly rather than half-deploying -- ff-only merge so a diverged checkout stops instead of merging, and set -e throughout. Install to /usr/local/sbin/faucet-deploy root-owned 755; see deploy/README.md for why it must not be sudo'd from its repo path. --- deploy/deploy.sh | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 deploy/deploy.sh 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" From 32d5cbd78b6cf77478404c7d08856120fb0c50db Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:24:05 -0400 Subject: [PATCH 2/4] deploy: track the compose stack, with no secrets in it The live stack lived only in Portainer's data dir and carried the Jackett key, Transmission password, Discord webhook and session secret inline -- unreadable without root and unsafe to paste anywhere. Everything in config.py's WIZARD_KEYS is dropped: faucet.env is loaded *over* process env, so those compose values were dead weight that only looked authoritative. What remains is the structural set the wizard deliberately excludes, plus SESSION_SECRET via env_file. --- deploy/stack.yml | 50 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 deploy/stack.yml 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 From 19715c028e09c500d2000a487b909739464bb523 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:24:13 -0400 Subject: [PATCH 3/4] deploy: template for the host-side secrets file --- deploy/faucet.secrets.env.example | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 deploy/faucet.secrets.env.example 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 From 6ffec0dbf3ccda7a733917b848a9e8c621f1c01b Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:24:45 -0400 Subject: [PATCH 4/4] deploy: document the deploy script, stack file and config layering --- deploy/README.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) 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