Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions deploy/deploy.sh
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 11 additions & 0 deletions deploy/faucet.secrets.env.example
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions deploy/stack.yml
Original file line number Diff line number Diff line change
@@ -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
Loading