diff --git a/build/config/ansible/inventory/host_vars/nas01.yml b/build/config/ansible/inventory/host_vars/nas01.yml index dcacd1a..d79c9d8 100644 --- a/build/config/ansible/inventory/host_vars/nas01.yml +++ b/build/config/ansible/inventory/host_vars/nas01.yml @@ -17,3 +17,36 @@ stacks: # with no DATABASE_URL support) and Prowlarr keeps its own SQLite under # /config, so there is nothing to provision on the shared Postgres. Both # databases live in the appdata bind mounts along with everything else. + + # Trialling alongside an existing Plex install to compare the two — see + # src/jellyfin/ansible/unraid/vars.yml, which points its media paths at the + # same library Plex already reads. No `db:` block: Jellyfin is SQLite-only, + # same reasoning as arr above. + - name: jellyfin + src: jellyfin + vault_path: homelab/jellyfin + appdata: + - path: /mnt/user/Media/JellyBridge + owner: "99" + group: "100" + mode: "0777" + recurse: false + + # Syncs watch state between the Plex and Jellyfin instances above — the + # reason both are running side by side. SQLite-only, no `db:` block, same + # as jellyfin. Its `homelab/watchstate` Vault path holds nothing real yet + # (first-run wizard + per-backend tokens in /config), but compose_stack + # looks it up unconditionally so it still has to exist — see + # docs/vault-secrets.md. + - name: watchstate + src: watchstate + vault_path: homelab/watchstate + # WatchState runs rootless (container `user: 99:100`) and exits if it + # can't write /config. Docker would create the missing bind path as + # root:root on first `up`, so pre-create it owned by nobody:users. Keep + # this path in step with WATCHSTATE_CONFIG_PATH in + # src/watchstate/ansible/unraid/vars.yml. + appdata: + - path: /mnt/user/appdata/watchstate/config + owner: "99" + group: "100" diff --git a/build/config/ansible/roles/compose_stack/tasks/deploy.yml b/build/config/ansible/roles/compose_stack/tasks/deploy.yml index 6093dcb..7fe9e42 100644 --- a/build/config/ansible/roles/compose_stack/tasks/deploy.yml +++ b/build/config/ansible/roles/compose_stack/tasks/deploy.yml @@ -23,6 +23,31 @@ state: directory mode: "0750" +# Bind-mount source directories the stack needs to exist *with a specific +# owner* before `docker compose up`. Opt-in per stack (`appdata:` on the +# host_vars entry) and normally unset: the LinuxServer images (arr, jellyfin) +# start as root and chown their own /config on boot, and postgres/forgejo run +# as root outright, so for those Docker creating a missing bind path as +# root:root is fine. A rootless image (watchstate runs as uid 99 and refuses +# to start if it can't write /config) has nothing to do the chown, so the +# directory has to arrive already owned correctly — otherwise the first +# `up` creates it as root:root and the container exits. +# +# `recurse` defaults on, matching upstream's `chown -R`, to also fix a +# directory that a previous run already created wrong. Keep the list to small +# state/config dirs; don't point it at a media share. +- name: Ensure stack appdata directories exist with the right ownership + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ item.owner | default(omit) }}" + group: "{{ item.group | default(omit) }}" + mode: "{{ item.mode | default('0750') }}" + recurse: "{{ item.recurse | default(true) }}" + loop: "{{ stack.appdata | default([]) }}" + loop_control: + label: "{{ item.path }}" + - name: Copy docker-compose.yml ansible.builtin.copy: src: "{{ stack_local_dir }}/ansible/unraid/docker-compose.yml" diff --git a/docs/vault-secrets.md b/docs/vault-secrets.md index 25ad4e0..e5ee5d6 100644 --- a/docs/vault-secrets.md +++ b/docs/vault-secrets.md @@ -230,6 +230,24 @@ an unrecognised variable is ignored rather than fatal, so the symptom of the wrong form is a key in the UI that doesn't match Vault, not a container that won't start. +## `homelab/watchstate` + +No real secrets. WatchState creates its admin through a first-run web wizard, +and each backend (Plex, Jellyfin) is added afterwards from the WebUI or +`docker exec -ti watchstate console`, with its API token kept in `/config` — +nothing is injected as an env var. But `compose_stack` fetches `vault_path` +unconditionally, so the path still has to exist or the deploy fails at the +lookup: + +```sh +vault kv put kv/homelab/watchstate placeholder=unused +``` + +Same situation as `homelab/jellyfin`. Revisit if a future WatchState release +grows a pre-settable API key or an OIDC client secret — add the key here and +name it in `src/watchstate/ansible/unraid/docker-compose.yml`'s `environment:` +(a Vault key that nothing references never reaches the container). + ## `homelab/k3s-homelab-utils` | Key | Purpose | diff --git a/src/jellyfin/ansible/proxmox/README.md b/src/jellyfin/ansible/proxmox/README.md new file mode 100644 index 0000000..e624243 --- /dev/null +++ b/src/jellyfin/ansible/proxmox/README.md @@ -0,0 +1,21 @@ +# ansible/proxmox/ — jellyfin + +Not built. Jellyfin runs as a Compose stack on nas1 (`../unraid/`) — this +folder exists because every app keeps the same shape whether or not both +platforms are in use. + +It's also a poor fit for a move today, for reasons specific to what this +deployment is for: it's evaluated against Plex, which reads Unraid's array +directly, and it needs read access to the same media shares (see +`../unraid/vars.yml`). Reaching those from a Proxmox LXC means an NFS/SMB +mount into the guest instead of a native Unraid share path — solvable, but +not free, and there is no reason to solve it while this is still a +side-by-side trial. Hardware transcoding (see the note in +`../unraid/vars.yml`) is a second complication if it's ever enabled: a GPU +passed through to nas1 for Jellyfin isn't available to a Proxmox guest at +the same time. + +What wouldn't change: `../../common/vars.yml` already holds the portable +config (version, published port), and the Vault path stays +`homelab/jellyfin`. Only paths and PUID/PGID are Unraid-specific, and those +live in `../unraid/vars.yml`. diff --git a/src/jellyfin/ansible/unraid/.env.example b/src/jellyfin/ansible/unraid/.env.example new file mode 100644 index 0000000..7579bbb --- /dev/null +++ b/src/jellyfin/ansible/unraid/.env.example @@ -0,0 +1,49 @@ +# Reference only — real values are rendered by Ansible from vars.yml (non- +# secret) and Vault path homelab/jellyfin (see ../../common/vars.yml — there +# are no real secrets yet, but the path still has to exist). Do not fill this +# in and deploy it directly. + +# Non-secret (see ../../common/vars.yml) +JELLYFIN_VERSION=latest +JELLYFIN_HTTP_PORT=8096 + +# Non-secret (see vars.yml) — check the media paths against your own shares. +# Point these at the same library Plex already reads. +JELLYFIN_CONFIG_PATH=/mnt/user/appdata/jellyfin/config +JELLYFIN_TRANSCODE_PATH=/mnt/user/appdata/jellyfin/transcode +JELLYFIN_MOVIES_PATH=/mnt/user/Media/Movies +JELLYFIN_TV_PATH=/mnt/user/Media/TV +PUID=99 +PGID=100 +TZ=Europe/London + +# Non-secret (see vars.yml) — NVENC hardware transcoding. `all`/`all` is +# upstream's own recommended value, not a placeholder. Requires ich777's +# Nvidia-Driver plugin on the host; without it these reach the container but +# there's no GPU device inside it, and Jellyfin falls back to the CPU. +NVIDIA_DRIVER_CAPABILITIES=all +NVIDIA_VISIBLE_DEVICES=all + +# Docker network to join, and it must already exist on the target host — +# Compose declares it `external`, so it will not create it. Defined in +# Unraid's Docker settings, not by any stack in this repo. +JELLYFIN_NETWORK=caddy-net + +# Non-secret (see vars.yml) — LAN DNS server the container forwards to, so +# split-horizon names the SSO plugin needs (auth.turtlesystems.uk) resolve +# from inside the container. caddy-net's embedded resolver otherwise hands +# these to nas1's public DNS, which has no record for them. +JELLYFIN_DNS=192.168.50.251 + +# Non-secret (see vars.yml) — SSO provider hostname pinned to an IPv4-only +# /etc/hosts entry (extra_hosts in docker-compose.yml), so Jellyfin's SSO +# plugin doesn't trip over a public AAAA record that has no split-horizon +# override. Drop once internal DNS is authoritative (A-only) for this name. +JELLYFIN_SSO_HOST=auth.turtlesystems.uk +JELLYFIN_SSO_HOST_IP=192.168.50.80 + +# Non-secret, supplied by the compose_stack role rather than either vars.yml — +# the path it copies ansible/unraid/icon.png to on the host, for the +# net.unraid.docker.icon label. Empty until an icon.png is committed here, +# which just leaves Unraid's question-mark placeholder. +STACK_ICON=/mnt/user/appdata/icons/jellyfin.png diff --git a/src/jellyfin/ansible/unraid/docker-compose.yml b/src/jellyfin/ansible/unraid/docker-compose.yml new file mode 100644 index 0000000..53d87ac --- /dev/null +++ b/src/jellyfin/ansible/unraid/docker-compose.yml @@ -0,0 +1,111 @@ +services: + jellyfin: + image: lscr.io/linuxserver/jellyfin:${JELLYFIN_VERSION} + container_name: jellyfin + restart: unless-stopped + # NVENC hardware transcoding needs the container actually started under + # nvidia-container-runtime, not just the NVIDIA_* vars below — the + # runtime is what creates /dev/nvidia* inside the container and puts + # nvidia-smi/CUDA on its PATH. ich777's Nvidia-Driver plugin registers + # `nvidia` as an available Docker runtime, it does NOT make it the + # daemon's default (checked with `docker info --format + # '{{.DefaultRuntime}}'` on nas1: `runc`) — every ich777 dockerMan + # template sets `--runtime=nvidia` explicitly per-container for the same + # reason. Without this line the container silently runs on runc, the two + # env vars reach a container with no GPU to see, and ffmpeg's + # `-init_hw_device cuda=...` fails. + runtime: nvidia + # Unraid's Docker page normally reads icon, WebUI link and console shell + # from the dockerMan template that created the container. A Compose stack + # has no template, so 6.10+ falls back to these labels; without them the + # container renders as a question mark with no WebUI or Console entry. + # See README.md → "Icons on the Unraid Docker page". + labels: + net.unraid.docker.icon: ${STACK_ICON} + net.unraid.docker.webui: "http://[IP]:${JELLYFIN_HTTP_PORT}/" + net.unraid.docker.shell: bash + # Joins Caddy's network by the fixed `proxy` alias; the real network it + # maps to is a per-host decision in vars.yml (JELLYFIN_NETWORK → + # caddy-net). Same alias/override shape as arr and forgejo. + # + # Two wrinkles of running Jellyfin on caddy-net rather than a LAN-bridged + # interface (br0): its NetworkManager can log "only loopback returned" + # because it can't classify the bridge subnet, and the SSO plugin's + # discovery fetch to a private LAN address (Authentik on 192.168.50.80) + # only succeeds with the plugin's "insecure" allow-private-addresses + # option enabled. A br0 container with its own 192.168.50.0/24 address + # needs neither. + networks: + - proxy + # caddy-net's embedded resolver (127.0.0.11) only knows container names + # and forwards everything else to nas1's daemon-level (public) DNS, which + # has no record for the split-horizon name the SSO plugin needs + # (auth.turtlesystems.uk). Point the container at the LAN resolver that + # does. See vars.yml (JELLYFIN_DNS). + dns: + - ${JELLYFIN_DNS} + # Split-horizon DNS answers auth.turtlesystems.uk with the right A record, + # but the public zone also publishes an AAAA with no internal override, so + # the container still sees an unreachable IPv6 address for it and Jellyfin + # can fail closed on that instead of using the A. An IPv4-only /etc/hosts + # entry is read before DNS. Drop this and JELLYFIN_SSO_HOST* once the + # internal zone is authoritative for the name (A-only). See vars.yml. + extra_hosts: + - "${JELLYFIN_SSO_HOST}:${JELLYFIN_SSO_HOST_IP}" + environment: + PUID: ${PUID} + PGID: ${PGID} + TZ: ${TZ} + # NVENC hardware transcoding — paired with `runtime: nvidia` above and + # the note in vars.yml. + NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES} + NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES} + volumes: + - ${JELLYFIN_CONFIG_PATH}:/config + - ${JELLYFIN_TRANSCODE_PATH}:/transcode + # Read-only: Jellyfin only ever reads the library, same as Plex does + # against the same paths. + - ${JELLYFIN_MOVIES_PATH}:/data/movies:ro + - ${JELLYFIN_TV_PATH}:/data/tvshows:ro + - ${JELLYFIN_MUSIC_PATH}:/data/music:ro + - ${JELLYFIN_BOOKS_PATH}:/data/books:ro + - ${JELLYFIN_JELLYBRIDGE_PATH}:/data/JellyBridge + ports: + - "${JELLYFIN_HTTP_PORT}:8096" + # Uncomment for local network discovery (Jellyfin apps auto-finding the + # server) and DLNA. Left off by default — on caddy-net these UDP + # broadcasts don't cross onto the LAN anyway; they only work with the + # container on a LAN-bridged interface. + # - "7359:7359/udp" # auto-discovery + # - "1900:1900/udp" # DLNA + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8096/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + # `external: true` means "attach to a network that already exists; don't + # create it, don't delete it on `down`". Without it Compose would create a + # *new* network named `jellyfin_` — the deploy would still succeed and + # Caddy would silently fail to resolve `jellyfin`. `name:` is what stops the + # project-name prefix being applied. + # + # The local alias is a fixed `proxy` so the compose file stays static; which + # real network it maps to is a per-host decision in vars.yml + # (JELLYFIN_NETWORK). Same shape as arr's `proxy` → `caddy-net` and + # forgejo's `shared` → `unraid_shared`. + # + # caddy-net is defined in Unraid's Docker settings on nas1 rather than by a + # stack in this repo, so nothing here has to deploy before Jellyfin does. + # It must exist on the target host — an absent external network fails the + # deploy outright. + proxy: + name: ${JELLYFIN_NETWORK} + external: true + +# Reaching Jellyfin through Caddy means proxying to `jellyfin:8096` — the +# container name on caddy-net — rather than nas1's LAN IP and the published +# port. The `ports:` mapping above is kept for direct access that bypasses +# the proxy; drop it if you don't want 8096 reachable on the LAN. diff --git a/src/jellyfin/ansible/unraid/vars.yml b/src/jellyfin/ansible/unraid/vars.yml new file mode 100644 index 0000000..9cbc2fa --- /dev/null +++ b/src/jellyfin/ansible/unraid/vars.yml @@ -0,0 +1,97 @@ +--- +# Unraid-specific overrides, merged over ../../common/vars.yml (these win). +env_defaults: + # Jellyfin's database (SQLite), plugins and library metadata/images. This + # directory *is* the application state, same standing as + # SHELFARR_DATA_PATH in ../../arr — back it up accordingly. + JELLYFIN_CONFIG_PATH: /mnt/user/appdata/jellyfin/config + + # Scratch space for transcoding, kept apart from JELLYFIN_CONFIG_PATH. + # Every transcode not served direct writes here, so it's worth pointing at + # something faster than the array if you have a cache pool — unlike + # config, nothing here needs backing up, so it's fine to lose. + JELLYFIN_TRANSCODE_PATH: /mnt/user/appdata/jellyfin/transcode + + # CHECK THESE AGAINST YOUR OWN SHARES before the first deploy. Since this + # is running alongside Plex to compare the two, point these at the *same* + # library Plex already reads rather than a second copy — both apps only + # need read access, hence `:ro` in docker-compose.yml. + JELLYFIN_MOVIES_PATH: /mnt/user/Media/Films + JELLYFIN_TV_PATH: /mnt/user/Media/TV + JELLYFIN_MUSIC_PATH: /mnt/user/Media/Music + JELLYFIN_BOOKS_PATH: /mnt/user/Media/Books + JELLYFIN_JELLYBRIDGE_PATH: /mnt/user/Media/JellyBridge + + # 99:100 is nobody:users — what Unraid owns share content as. Upstream + # defaults to 1000:1000, which is a normal Linux desktop user and wrong + # here; files written as 1000 are invisible to everything else on the box. + PUID: "99" + PGID: "100" + + TZ: Europe/London + + # NVENC hardware transcoding. `all` for both is upstream's own recommended + # value, not a placeholder to narrow down — NVIDIA_VISIBLE_DEVICES=all + # exposes every GPU nas1 has to the container (fine for a single-card box) + # and NVIDIA_DRIVER_CAPABILITIES=all pulls in `video`/`compute` alongside + # everything else the base image doesn't otherwise ask for. On their own + # these two vars do nothing — confirmed on nas1 (`docker info --format + # '{{.DefaultRuntime}}'` → `runc`): ich777's Nvidia-Driver plugin registers + # `nvidia` as an available Docker runtime, it does not make it the + # daemon's default, so docker-compose.yml sets `runtime: nvidia` on the + # jellyfin service explicitly — same as every ich777 dockerMan template + # does per-container. Without that line the container runs on runc with no + # GPU device inside it, and ffmpeg's `-init_hw_device cuda=...` fails + # rather than silently falling back to CPU. + NVIDIA_DRIVER_CAPABILITIES: all + NVIDIA_VISIBLE_DEVICES: all + + # The stack's Docker network, via the `proxy` alias in docker-compose.yml. + # caddy-net puts Jellyfin behind the reverse proxy and reachable by + # container name; same alias/override pattern as ../../arr and forgejo. + # Two caveats versus a LAN-bridged interface (br0): Jellyfin's + # NetworkManager may log "only loopback returned" here because it can't + # classify the bridge subnet, and the SSO plugin's discovery fetch to + # Authentik's private LAN address (192.168.50.80) only succeeds with the + # plugin's "insecure" allow-private-addresses option turned on. + JELLYFIN_NETWORK: caddy-net + + # LAN resolver the container forwards DNS to, via `dns:` in + # docker-compose.yml. caddy-net's embedded resolver only knows container + # names and hands everything else to nas1's daemon-level (public) DNS, + # which has no record for the split-horizon name the SSO plugin needs + # (auth.turtlesystems.uk). Point it at the internal resolver instead. + JELLYFIN_DNS: 192.168.50.251 + + # SSO provider (Authentik) hostname, pinned to an IPv4-only /etc/hosts + # entry via `extra_hosts` in docker-compose.yml. Split-horizon DNS already + # answers this name with the right A record (JELLYFIN_SSO_HOST_IP), but the + # public zone still publishes an AAAA with no internal override, and + # Jellyfin's SSO plugin can fail closed on the unreachable IPv6 address + # rather than falling back to the A. Pinning it here sidesteps that. Remove + # both vars (and the extra_hosts block) once the internal zone is + # authoritative for this name — A-only, no AAAA fall-through. + JELLYFIN_SSO_HOST: auth.turtlesystems.uk + JELLYFIN_SSO_HOST_IP: 192.168.50.80 + + # --- Unraid Docker page presentation (net.unraid.docker.* labels) -------- + # + # STACK_ICON is normally left to the compose_stack role, which points it at + # /mnt/user/appdata/icons/.png when this directory contains an + # icon.png — none is committed here yet (see ../../common/vars.yml). + # Uncomment to use a hosted icon instead: + # + # STACK_ICON: https://example.org/jellyfin.png + # + # PNG only — Unraid renders its placeholder for SVG and nothing at all for + # WebP, and a URL it can't reach when the Docker page renders fails the + # same way, which is the argument for committing the file. + + # --- Hardware transcoding --------------------------------------------- + # + # nas1 has an NVIDIA card, so it's NVENC via the two vars above rather than + # Intel/AMD VAAPI. If Jellyfin ever moves to a box without one, VAAPI is + # the equivalent path there: drop the two NVIDIA_* vars, add + # `devices: [/dev/dri:/dev/dri]` to the jellyfin service in + # docker-compose.yml, and add the render group's GID + # (`getent group render`) via `group_add:`. diff --git a/src/jellyfin/common/vars.yml b/src/jellyfin/common/vars.yml new file mode 100644 index 0000000..80e62a7 --- /dev/null +++ b/src/jellyfin/common/vars.yml @@ -0,0 +1,36 @@ +--- +# Portable config — values that hold regardless of where Jellyfin runs. +# Platform-specific values (filesystem paths, the PUID/PGID the container +# writes as) live in ../ansible//vars.yml and are merged over these +# at deploy time. +env_defaults: + # LinuxServer tags are the upstream Jellyfin version plus their own build + # suffix (e.g. `10.10.7-ls145`), which can't be derived from the release + # number — read it off the registry and replace this with a full tag before + # relying on this in anything but a throwaway test. `latest` for now, same + # as PROWLARR_VERSION in ../../arr/common/vars.yml — check + # https://github.com/linuxserver/docker-jellyfin/pkgs/container/jellyfin + JELLYFIN_VERSION: latest + + # Published port. 8096 is upstream's default and what the container listens + # on internally; kept the same on the host so the WebUI label, the LAN URL + # and the docs all read alike. + JELLYFIN_HTTP_PORT: "8096" + + # No Vault secrets today — Jellyfin's admin account is set up through its + # own first-run web wizard rather than an env var, and there's no API key + # or session secret this deployment pins the way Shelfarr's + # SECRET_KEY_BASE or Prowlarr's PROWLARR_API_KEY are. The compose_stack + # role still looks up `homelab/jellyfin` unconditionally (see + # ../../arr/ansible/unraid/vars.yml's equivalent note — there isn't one, + # because arr has real secrets; this is the first app that doesn't), so the + # KV path has to exist before the first deploy even if it holds nothing — + # `vault kv put kv/homelab/jellyfin placeholder=unused` is enough. Revisit + # if a plugin (hardware-key SSO, a remote-access token) needs one later. + + # STACK_ICON is supplied by the compose_stack role, from a committed + # ../ansible/unraid/icon.png — not set here, and none is committed yet, so + # the Docker page shows Unraid's placeholder until one is added. Uncomment + # to use a hosted icon instead: + # + # STACK_ICON: https://example.org/jellyfin.png diff --git a/src/jellyfin/terraform/README.md b/src/jellyfin/terraform/README.md new file mode 100644 index 0000000..8eb5928 --- /dev/null +++ b/src/jellyfin/terraform/README.md @@ -0,0 +1,11 @@ +# terraform/ — jellyfin + +Not built, and not expected to be. Terraform's only job in this repo is +creating Proxmox LXCs; Jellyfin runs as a Compose stack on nas1, where the +host already exists. + +This folder is a placeholder so the app keeps the same +`common/` + `ansible/` + `terraform/` shape as every other one. If Jellyfin +ever moves to Proxmox it would need an LXC defined here and wired into +`build/config/terraform/main.tf` — see `../ansible/proxmox/README.md` first +for why that move is less straightforward than it looks. diff --git a/src/watchstate/ansible/proxmox/README.md b/src/watchstate/ansible/proxmox/README.md new file mode 100644 index 0000000..80e8dfc --- /dev/null +++ b/src/watchstate/ansible/proxmox/README.md @@ -0,0 +1,20 @@ +# ansible/proxmox/ — watchstate + +Not built. WatchState runs as a Compose stack on nas1 (`../unraid/`) — this +folder exists because every app keeps the same shape whether or not both +platforms are in use. + +It's also a poor fit for a move today for the same reason as Jellyfin (see +`../../jellyfin/ansible/proxmox/README.md`): WatchState is here to sync watch +state between the Plex and Jellyfin instances that are being compared +side-by-side on nas1, so it wants to sit on the same `caddy-net` as Jellyfin +and next to Plex on the LAN. From a Proxmox LXC that's an extra routing hop +for no benefit while the trial is still running. Unlike Jellyfin there's no +media-share or GPU complication — WatchState only ever talks to the two media +servers over HTTP — so a later move would be a straight `install.yml` + +`terraform/` build. + +What wouldn't change: `../../common/vars.yml` already holds the portable +config (version, published port, timezone), and the Vault path stays +`homelab/watchstate`. Only the appdata path and the UID/GID are +Unraid-specific, and those live in `../unraid/vars.yml`. diff --git a/src/watchstate/ansible/unraid/.env.example b/src/watchstate/ansible/unraid/.env.example new file mode 100644 index 0000000..7e53c3b --- /dev/null +++ b/src/watchstate/ansible/unraid/.env.example @@ -0,0 +1,37 @@ +# Reference only — real values are rendered by Ansible from vars.yml (non- +# secret) and Vault path homelab/watchstate (see ../../common/vars.yml — there +# are no real secrets yet, but the path still has to exist). Do not fill this +# in and deploy it directly. + +# Non-secret (see ../../common/vars.yml) +WATCHSTATE_VERSION=latest +# Host-side published port. The container is always 8080 internally. Not 8080 +# here on purpose — pick something free on nas1. +WATCHSTATE_HTTP_PORT=8580 + +# Non-secret (see vars.yml). This directory is the whole of WatchState's +# state — SQLite DB, backend configs, logs. Must be owned by 99:100. +WATCHSTATE_CONFIG_PATH=/mnt/user/appdata/watchstate/config +PUID=99 +PGID=100 +TZ=Europe/London + +# Docker network to join, and it must already exist on the target host — +# Compose declares it `external`, so it will not create it. Defined in +# Unraid's Docker settings, not by any stack in this repo. +WATCHSTATE_NETWORK=caddy-net + +# Non-secret, supplied by the compose_stack role rather than either vars.yml — +# the path it copies ansible/unraid/icon.png to on the host, for the +# net.unraid.docker.icon label. Empty until an icon.png is committed here, +# which just leaves Unraid's question-mark placeholder. +STACK_ICON=/mnt/user/appdata/icons/watchstate.png + +# --- Optional, all commented out by default ------------------------------- +# Turn off the task scheduler / built-in cache server (both on by default). +# DISABLE_CRON=1 +# DISABLE_CACHE=1 +# Only if WatchState is ever fronted by Caddy + Authentik forward-auth. +# Setting both disables all of WatchState's internal auth. +# WS_TRUST_PROXY=true +# WS_TRUST_LOCAL=true diff --git a/src/watchstate/ansible/unraid/docker-compose.yml b/src/watchstate/ansible/unraid/docker-compose.yml new file mode 100644 index 0000000..44cd0df --- /dev/null +++ b/src/watchstate/ansible/unraid/docker-compose.yml @@ -0,0 +1,100 @@ +services: + watchstate: + image: ghcr.io/arabcoders/watchstate:${WATCHSTATE_VERSION} + container_name: watchstate + restart: unless-stopped + # The image runs rootless and drops privileges to this uid:gid — it is a + # `user:` directive, not PUID/PGID env vars like the LinuxServer images + # (arr, jellyfin). It must match the owner of the appdata bind below or + # the container exits on start, unable to write /config. On Unraid that is + # 99:100 (nobody:users) — upstream's own Unraid note says the same. + # + # WatchState won't chown /config itself the way the LinuxServer images do, + # so the `appdata:` block on this stack's host_vars entry pre-creates the + # bind directory owned by 99:100 before `docker compose up` — otherwise + # Docker makes it root:root on first start and the container exits. + user: "${PUID}:${PGID}" + # Unraid's Docker page normally reads icon, WebUI link and console shell + # from the dockerMan template that created the container. A Compose stack + # has no template, so 6.10+ falls back to these labels; without them the + # container renders as a question mark with no WebUI or Console entry. + # See README.md → "Icons on the Unraid Docker page". + # + # `sh`, not `bash`: the image is Alpine-based (frankenphp on Alpine). + labels: + net.unraid.docker.icon: ${STACK_ICON} + net.unraid.docker.webui: "http://[IP]:${WATCHSTATE_HTTP_PORT}/" + net.unraid.docker.shell: sh + # Joins Caddy's network by the fixed `proxy` alias, same shape as arr and + # jellyfin; the real network it maps to is a per-host decision in vars.yml + # (WATCHSTATE_NETWORK → caddy-net). Caddy then proxies to + # `watchstate:8080` — the container name on caddy-net — rather than nas1's + # LAN IP and the published port. + # + # This also puts Jellyfin one hop away: WatchState reaches it as + # `http://jellyfin:8096` when adding it as a backend. Plex and anything + # else off caddy-net is addressed by nas1's LAN IP and its published port + # (e.g. http://192.168.50.1:32400) — a runtime concern configured in the + # WebUI, not here. + networks: + - proxy + environment: + # Image default is UTC; TZ comes from vars.yml. Both are named here + # because Compose reads .env only to interpolate `${...}` in this file — + # a var that nothing references never reaches the container. + WS_TZ: ${TZ} + TZ: ${TZ} + # The task scheduler (import every hour, export every 90 min) and the + # built-in cache server are both on by default. Uncomment to turn either + # off — e.g. DISABLE_CRON while doing the initial one-off backend sync by + # hand, before enabling the tasks on the Tasks page. + # DISABLE_CRON: "1" + # DISABLE_CACHE: "1" + # --- Reverse-proxy auth (optional) -------------------------------------- + # WatchState has its own internal auth (the first-run system user). If it + # is ever fronted by Caddy + Authentik forward-auth instead, set these so + # it trusts the proxy rather than double-prompting. Setting BOTH disables + # all internal auth — only do that once the external layer is definitely + # in front of it. + # WS_TRUST_PROXY: "true" + # WS_TRUST_LOCAL: "true" + volumes: + # SQLite database (db/watchstate_v01.db), backend configs, guid.yaml and + # logs — this directory *is* the application state, same standing as + # arr's SHELFARR_DATA_PATH. Back it up accordingly. + - ${WATCHSTATE_CONFIG_PATH}:/config + ports: + - "${WATCHSTATE_HTTP_PORT}:8080" + # Restated from the image's own HEALTHCHECK. The port here is the fixed + # internal 8080, never WATCHSTATE_HTTP_PORT. + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/v1/api/system/healthcheck"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + # `external: true` means "attach to a network that already exists; don't + # create it, don't delete it on `down`". Without it Compose would create a + # *new* network named `watchstate_` — the deploy would still succeed and + # Caddy would silently fail to resolve `watchstate`. `name:` is what stops + # the project-name prefix being applied. + # + # The local alias is a fixed `proxy` so the compose file stays static; which + # real network it maps to is a per-host decision in vars.yml + # (WATCHSTATE_NETWORK). Same shape as arr's `proxy` → `caddy-net` and + # jellyfin's. + # + # caddy-net is defined in Unraid's Docker settings on nas1 rather than by a + # stack in this repo, so nothing here has to deploy before WatchState does. + # It must exist on the target host — an absent external network fails the + # deploy outright. + proxy: + name: ${WATCHSTATE_NETWORK} + external: true + +# Reaching WatchState through Caddy means proxying to `watchstate:8080` — the +# container name on caddy-net — rather than nas1's LAN IP and the published +# port. The `ports:` mapping above is kept for direct access that bypasses the +# proxy; drop it if you don't want WATCHSTATE_HTTP_PORT reachable on the LAN. diff --git a/src/watchstate/ansible/unraid/vars.yml b/src/watchstate/ansible/unraid/vars.yml new file mode 100644 index 0000000..d6922f2 --- /dev/null +++ b/src/watchstate/ansible/unraid/vars.yml @@ -0,0 +1,40 @@ +--- +# Unraid-specific overrides, merged over ../../common/vars.yml (these win). +env_defaults: + # WatchState's SQLite database, backend configuration, guid.yaml and logs. + # This directory *is* the application state, same standing as arr's + # SHELFARR_DATA_PATH — back it up accordingly. Nothing else lives under + # /mnt/user/appdata/watchstate, so the stack folder and this one line up. + WATCHSTATE_CONFIG_PATH: /mnt/user/appdata/watchstate/config + + # 99:100 is nobody:users — what Unraid owns share content as, and what the + # container must run as to write WATCHSTATE_CONFIG_PATH (see the `user:` + # note in docker-compose.yml). Upstream defaults to 1000:1000, a normal + # Linux desktop user, which can't write appdata here. + # + # These feed the `user:` directive, not PUID/PGID env vars — WatchState is + # not a LinuxServer image. Named PUID/PGID only to match arr and jellyfin. + PUID: "99" + PGID: "100" + + TZ: Europe/London + + # The stack's Docker network, via the `proxy` alias in docker-compose.yml. + # caddy-net puts WatchState behind the reverse proxy and reachable by + # container name, and puts Jellyfin one hop away (`http://jellyfin:8096`) + # for adding it as a backend. Same alias/override pattern as ../../arr and + # ../../jellyfin. + WATCHSTATE_NETWORK: caddy-net + + # --- Unraid Docker page presentation (net.unraid.docker.* labels) -------- + # + # STACK_ICON is normally left to the compose_stack role, which points it at + # /mnt/user/appdata/icons/.png when this directory contains an + # icon.png — none is committed here yet (see ../../common/vars.yml). + # Uncomment to use a hosted icon instead: + # + # STACK_ICON: https://example.org/watchstate.png + # + # PNG only — Unraid renders its placeholder for SVG and nothing at all for + # WebP, and a URL it can't reach when the Docker page renders fails the + # same way, which is the argument for committing the file. diff --git a/src/watchstate/common/vars.yml b/src/watchstate/common/vars.yml new file mode 100644 index 0000000..cae6e19 --- /dev/null +++ b/src/watchstate/common/vars.yml @@ -0,0 +1,45 @@ +--- +# Portable config — values that hold regardless of where WatchState runs. +# Platform-specific values (filesystem paths, the UID/GID the container writes +# as) live in ../ansible//vars.yml and are merged over these at +# deploy time. +env_defaults: + # Upstream publishes a rolling `latest` plus dated `YYYYMMDD` tags; there is + # no semver release tag to pin to. `latest` for now, same as + # JELLYFIN_VERSION in ../../jellyfin/common/vars.yml — read a dated tag off + # https://github.com/ArabCoders/watchstate/pkgs/container/watchstate and + # replace this before relying on it for anything but a throwaway test. + WATCHSTATE_VERSION: latest + + # Published port on the host. The container always listens on 8080 + # internally (EXPOSE 8080, and the image's own HEALTHCHECK hard-codes it), + # so that side of the mapping in docker-compose.yml is fixed — only this + # one is configurable. + # + # Deliberately NOT 8080, unlike Jellyfin keeping 8096 on both sides: 8080 is + # a high-collision port on an Unraid box (qBittorrent, SABnzbd and friends + # default to it), and this repo has no view of what else nas1 publishes. Set + # to whatever is free; the WebUI label and the LAN URL follow it. + WATCHSTATE_HTTP_PORT: "8580" + + # Timezone WatchState stamps its scheduled-task run times and logs with. + # The image defaults WS_TZ to UTC; the real value is Unraid-specific and set + # in ../ansible/unraid/vars.yml (TZ), read here via WS_TZ in + # docker-compose.yml's environment block. + + # No Vault secrets today — WatchState's first-run flow creates a system user + # through its own web wizard, and every backend (Plex, Jellyfin) is added + # afterwards from the WebUI or `docker exec -ti watchstate console`, with its + # API token stored in /config, not injected as an env var. Same situation as + # Jellyfin: the compose_stack role still looks up `homelab/watchstate` + # unconditionally, so the KV path has to exist before the first deploy even + # if it holds nothing — `vault kv put kv/homelab/watchstate placeholder=unused` + # is enough. Revisit if a future version grows a pre-settable API key or an + # OIDC secret (see docs/vault-secrets.md). + + # STACK_ICON is supplied by the compose_stack role, from a committed + # ../ansible/unraid/icon.png — not set here, and none is committed yet, so + # the Docker page shows Unraid's placeholder until one is added. Uncomment + # to use a hosted icon instead: + # + # STACK_ICON: https://example.org/watchstate.png diff --git a/src/watchstate/terraform/README.md b/src/watchstate/terraform/README.md new file mode 100644 index 0000000..174f07f --- /dev/null +++ b/src/watchstate/terraform/README.md @@ -0,0 +1,10 @@ +# terraform/ — watchstate + +Not built, and not expected to be. Terraform's only job in this repo is +creating Proxmox LXCs; WatchState runs as a Compose stack on nas1, where the +host already exists. + +This folder is a placeholder so the app keeps the same +`common/` + `ansible/` + `terraform/` shape as every other one. If WatchState +ever moves to Proxmox it would need an LXC defined here and wired into +`build/config/terraform/main.tf` — see `../ansible/proxmox/README.md` first.