Use .env.example as the env template and cover it with tests

Generate INSTALL_DIR/.env from .env.example via envsubst, drop
templates/envsubst.env, and add a unit test that keeps the template
keys aligned with setup. Tighten related docs and test helpers.
This commit is contained in:
soulis-1256 2026-08-05 14:01:04 +03:00
parent 3ddedb4d15
commit 1fd188a5c4
No known key found for this signature in database
12 changed files with 167 additions and 148 deletions

View file

@ -1,36 +1,32 @@
# Assemblrr Environment Configuration
# This file documents all available environment variables.
# Actual values are generated by setup.sh using envsubst.
# Generated into INSTALL_DIR/.env by setup (envsubst).
# Credentials go under secrets/, not here.
# User/Group IDs — must match the user that owns the media files
PUID=1000
PGID=1000
# Host user/group that owns media files
PUID=${PUID}
PGID=${PGID}
# Paths (defaults are home-based; set during setup)
MEDIA_DIRECTORY=/home/<user>/assemblrr-media
INSTALL_DIRECTORY=/home/<user>/assemblrr
MEDIA_DIRECTORY=${MEDIA_DIRECTORY}
INSTALL_DIRECTORY=${INSTALL_DIRECTORY}
# Media service: jellyfin, emby, or plex
MEDIA_SERVICE=jellyfin
# jellyfin | emby | plex
MEDIA_SERVICE=${MEDIA_SERVICE}
# Timezone (IANA format)
TZ=UTC
# IANA timezone (e.g. Europe/Athens)
TZ=${TZ}
# VPN Configuration
VPN_ENABLED=n
VPN_SERVICE=protonvpn
VPN_TYPE=openvpn
# y/n — secrets under secrets/ when enabled
VPN_ENABLED=${VPN_ENABLED}
# Gluetun provider id (e.g. protonvpn)
VPN_SERVICE=${VPN_SERVICE}
# openvpn | wireguard
VPN_TYPE=${VPN_TYPE}
# OpenVPN credentials are stored in secrets/openvpn_user.txt and secrets/openvpn_password.txt
# WireGuard private key is stored in secrets/wireguard_private_key.txt
# These are NOT in .env — they are mounted as Docker secrets so docker inspect won't expose them.
# WireGuard only (not secret)
WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
# WireGuard addresses (not secret, just an internal IP)
WIREGUARD_ADDRESSES=
# on/off — only if your provider supports it
PORT_FORWARD_ONLY=${PORT_FORWARD_ONLY}
VPN_PORT_FORWARDING=${VPN_PORT_FORWARDING}
# Port forwarding (only supported by some VPN providers)
PORT_FORWARD_ONLY=off
VPN_PORT_FORWARDING=off
# API Host for configuration script (advanced users)
API_HOST=127.0.0.1
# configure.sh API host
API_HOST=${API_HOST}

View file

@ -1,4 +1,3 @@
# Assemblrr developer targets
.PHONY: test test-unit test-compose test-integration lint help
help:

View file

@ -32,7 +32,7 @@ A lightweight media server stack via Docker. Cross-platform, VPN-aware, and buil
- **Subtitles:** Bazarr
- **Update Notifications:** Watchtower
These are deployed only — no auto-configuration like the core stack. Wire them up in each service’s UI after enabling.
Deployed only — not auto-configured. Set them up in each service’s UI.
## Installation
@ -90,10 +90,11 @@ See [docs/uninstall.md](docs/uninstall.md) for the complete removal guide, inclu
Configuration lives in your installation directory (default: `~/assemblrr`).
- `.env`: Environment overrides (ASSEMBLRR_HOST for remote proxying, PUID/PGID, API variables).
- `compose/base.yaml`: Immutable core services.
- `compose/vpn.yaml`: Gluetun routing overlays.
- `config/`: Persistent data safe from container teardowns.
- `.env`: Generated at install from [`.env.example`](.env.example) (all variables are documented there). Credentials live under `secrets/`, not in `.env`.
- `compose/base.yaml`: Core services.
- `compose/vpn.yaml` / `compose/direct-access.yaml`: Networking overlays.
- `compose/custom.yaml`: Optional services (from the example under `compose/examples/`).
- `config/`: Persistent service data.
## Development

View file

@ -238,7 +238,7 @@ copy_configuration_files() {
["compose/direct-access.yaml"]="compose/direct-access.yaml"
["compose/vpn.yaml"]="compose/vpn.yaml"
["compose/examples/custom.yaml.example"]="compose/examples/custom.yaml.example"
["templates/envsubst.env"]="templates/envsubst.env"
[".env.example"]=".env.example"
["templates/recyclarr-ultra_hd.yml"]="templates/recyclarr-ultra_hd.yml"
["templates/recyclarr-full_hd.yml"]="templates/recyclarr-full_hd.yml"
["branding.conf"]="branding.conf"
@ -322,8 +322,13 @@ generate_env_file() {
export PORT_FORWARD_ONLY VPN_PORT_FORWARDING
export API_HOST
# Generate .env from template using envsubst
envsubst < "$APP_ROOT/templates/envsubst.env" > "$env_file" || \
local env_template
if ! env_template=$(resolve_project_file ".env.example"); then
log_error "Failed to locate .env.example"
fi
# Explicit key list so envsubst does not pull in unrelated shell variables
envsubst '${PUID} ${PGID} ${MEDIA_DIRECTORY} ${INSTALL_DIRECTORY} ${MEDIA_SERVICE} ${TZ} ${VPN_ENABLED} ${VPN_SERVICE} ${VPN_TYPE} ${WIREGUARD_ADDRESSES} ${PORT_FORWARD_ONLY} ${VPN_PORT_FORWARDING} ${API_HOST}' \
< "$env_template" > "$env_file" || \
log_error "Failed to generate .env file"
chmod 600 "$env_file"

View file

@ -249,7 +249,8 @@ cp /tmp/$AppName/lib/*.sh "`$INSTALL_DIR/lib/"
cp /tmp/$AppName/compose/base.yaml /tmp/$AppName/compose/vpn.yaml /tmp/$AppName/compose/direct-access.yaml "`$INSTALL_DIR/compose/"
mkdir -p "`$INSTALL_DIR/compose/examples"
cp /tmp/$AppName/compose/examples/custom.yaml.example "`$INSTALL_DIR/compose/examples/" 2>/dev/null || true
cp /tmp/$AppName/templates/envsubst.env /tmp/$AppName/templates/recyclarr-full_hd.yml /tmp/$AppName/templates/recyclarr-ultra_hd.yml "`$INSTALL_DIR/templates/"
cp /tmp/$AppName/.env.example "`$INSTALL_DIR/" 2>/dev/null || true
cp /tmp/$AppName/templates/recyclarr-full_hd.yml /tmp/$AppName/templates/recyclarr-ultra_hd.yml "`$INSTALL_DIR/templates/"
mkdir -p "`$INSTALL_DIR/scripts"
cp /tmp/$AppName/scripts/* "`$INSTALL_DIR/scripts/"
echo "Files synced to install directory"

View file

@ -1,19 +0,0 @@
# Base configuration
PUID=${PUID}
PGID=${PGID}
MEDIA_DIRECTORY=${MEDIA_DIRECTORY}
INSTALL_DIRECTORY=${INSTALL_DIRECTORY}
MEDIA_SERVICE=${MEDIA_SERVICE}
TZ=${TZ}
# VPN configuration
VPN_ENABLED=${VPN_ENABLED}
VPN_SERVICE=${VPN_SERVICE}
VPN_TYPE=${VPN_TYPE}
WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
PORT_FORWARD_ONLY=${PORT_FORWARD_ONLY}
VPN_PORT_FORWARDING=${VPN_PORT_FORWARDING}
# API Host for configuration script
API_HOST=${API_HOST}

View file

@ -1,55 +1,20 @@
# Tests
Runtime scripts live in `scripts/` (installed with the stack). **All tests live here.**
## Layout
```text
tests/
helpers.sh # assertions, install discovery, qBittorrent helpers
run.sh # entrypoint
unit/
test_compose.sh # lib/compose.sh
test_core.sh # expand_path, safe_source, rm guards
test_vpn_secrets.sh # validate_vpn_secrets
test_compose_config.sh # docker compose config (needs Docker)
integration/
watchdog_stall.sh # live qBittorrent + vpn-watchdog (opt-in)
```
## Quick start
Runtime hooks stay in `scripts/`. Tests live here.
```bash
# From repo root
./tests/run.sh # unit tests (no Docker required)
./tests/run.sh compose # docker compose config validation
./tests/run.sh # unit
./tests/run.sh compose # docker compose config
./tests/run.sh all # unit + compose + shellcheck (if installed)
make test # same as ./tests/run.sh all
make test
```
## Live integration (watchdog)
## Live integration
Mutates a **running** qBittorrent (sets `max_connecs=0`, adds a public Ubuntu magnet, runs `vpn-watchdog.sh`). Always restores settings on exit.
Hits a running qBittorrent (stalls connections, runs `vpn-watchdog`). Opt-in only:
```bash
ASSEMBLRR_ALLOW_LIVE_TEST=1 ./tests/run.sh integration
# Or point at a non-default install:
ASSEMBLRR_DIR=~/assemblrr ASSEMBLRR_ALLOW_LIVE_TEST=1 \
./tests/integration/watchdog_stall.sh
```
Without `ASSEMBLRR_ALLOW_LIVE_TEST=1` the script exits with code 2 and does nothing.
Config resolution order:
1. `ASSEMBLRR_DIR`
2. `ASSEMBLRR_CONFIG` (path to `.assemblrr-config`)
3. `find_install_directory` from `lib/core.sh`
4. `$HOME/assemblrr`
## Notes
- Unit tests use a tiny bash assertion helper (no BATS dependency).
- Compose checks create a temporary fixture with dummy secret files so `docker compose config` succeeds.
- Install [shellcheck](https://www.shellcheck.net/) for `./tests/run.sh lint`.
Install dir: `ASSEMBLRR_DIR`, `ASSEMBLRR_CONFIG`, then `~/assemblrr`.

View file

@ -1,13 +1,9 @@
#!/bin/bash
# Shared helpers for Assemblrr unit and integration tests.
# shellcheck shell=bash
# Repo root (parent of tests/)
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$TESTS_DIR/.." && pwd)"
# --- Assertion helpers ---
_TEST_FAILURES=0
_TEST_PASSES=0
_TEST_CURRENT=""
@ -124,8 +120,7 @@ assert_false() {
return 0
}
# Soft log_error for unit tests — core.sh's log_error exits the process.
# Call after sourcing lib/core.sh so we can exercise guard paths.
# core.sh log_error exits; unit tests need a non-fatal stub
stub_log_error_no_exit() {
log_error() {
echo -e "${RED:-}$1${NC:-}" >&2
@ -145,10 +140,6 @@ test_summary() {
return 0
}
# --- Install / config discovery (integration) ---
# Resolve Assemblrr install directory.
# Priority: ASSEMBLRR_DIR, ASSEMBLRR_CONFIG parent, find_install_directory, ~/assemblrr
resolve_install_dir() {
if [ -n "${ASSEMBLRR_DIR:-}" ]; then
echo "$ASSEMBLRR_DIR"
@ -195,8 +186,6 @@ load_assemblrr_config() {
source "$config_file"
}
# --- qBittorrent API helpers (integration) ---
QBIT_HOST="${QBIT_HOST:-127.0.0.1:8081}"
QBIT_API="http://${QBIT_HOST}/api/v2"
@ -219,7 +208,6 @@ qbit_api() {
curl -s -b "$cookie_jar" -H "Referer: http://${QBIT_HOST}" "$@"
}
# Create a temporary install-like tree for compose validation.
make_compose_fixture() {
local dest="$1"
mkdir -p "$dest/compose" "$dest/config" "$dest/scripts" "$dest/secrets" "$dest/media"
@ -228,7 +216,7 @@ make_compose_fixture() {
mkdir -p "$dest/compose/examples"
cp "$REPO_ROOT/compose/examples/"* "$dest/compose/examples/" 2>/dev/null || true
fi
# Secret files must exist for `docker compose config` with vpn overlay
# vpn overlay expects these files to exist
: >"$dest/secrets/openvpn_user.txt"
: >"$dest/secrets/openvpn_password.txt"
: >"$dest/secrets/wireguard_private_key.txt"

View file

@ -1,20 +1,13 @@
#!/bin/bash
# Live integration test: stall qBittorrent connections and verify vpn-watchdog reacts.
#
# SAFETY: mutates a running qBittorrent instance (max connections → 0, test torrent).
# Requires ASSEMBLRR_ALLOW_LIVE_TEST=1 and a configured Assemblrr install.
#
# Usage:
# ASSEMBLRR_ALLOW_LIVE_TEST=1 ./tests/integration/watchdog_stall.sh
# ASSEMBLRR_DIR=~/assemblrr ASSEMBLRR_ALLOW_LIVE_TEST=1 ./tests/integration/watchdog_stall.sh
#
# Live test: stall qBittorrent and check vpn-watchdog.
# Mutates a real install — requires ASSEMBLRR_ALLOW_LIVE_TEST=1.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/../helpers.sh"
# Ubuntu 22.04.4 desktop ISO infohash + public trackers (no content hosted by us)
# Ubuntu 22.04 desktop infohash + public trackers
TARGET_HASH="3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
MAGNET="magnet:?xt=urn:btih:${TARGET_HASH}&dn=ubuntu-22.04.4-desktop-amd64.iso&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A6969%2Fannounce"
@ -46,7 +39,6 @@ EOF
fi
}
# Fail fast before any trap/cleanup noise
require_live_opt_in
cleanup() {
@ -78,7 +70,6 @@ resolve_watchdog_script() {
return 1
}
# Watchdog in containers reads /run/secrets; for host runs, stage secrets or env.
prepare_watchdog_env() {
local install_dir="$1"
local secrets="$install_dir/secrets"
@ -87,7 +78,6 @@ prepare_watchdog_env() {
export AUTH_PASSWORD_FILE="$secrets/auth_password.txt"
return 0
fi
# Fallback: materialize from config credentials into temp secret files
if [ -n "${AUTH_USERNAME:-}" ] && [ -n "${AUTH_PASSWORD:-}" ]; then
local staged
staged=$(mktemp -d)
@ -117,7 +107,6 @@ install_dir=$(resolve_install_dir) || die "Could not resolve install directory.
load_assemblrr_config "$install_dir" || die "Could not load config from $install_dir"
if [ -z "${AUTH_USERNAME:-}" ] || [ -z "${AUTH_PASSWORD:-}" ]; then
# Try secret files
if [ -f "$install_dir/secrets/auth_username.txt" ]; then
AUTH_USERNAME=$(cat "$install_dir/secrets/auth_username.txt")
AUTH_PASSWORD=$(cat "$install_dir/secrets/auth_password.txt")
@ -152,7 +141,6 @@ echo " $(torrent_summary || echo 'torrent not listed yet')"
echo "[4] Sabotaging max_connecs=0..."
qbit_api "$COOKIE_JAR" -d 'json={"max_connecs": 0}' "${QBIT_API}/app/setPreferences" >/dev/null
# Bounce torrent to drop existing peer connections quickly
qbit_api "$COOKIE_JAR" -d "hashes=${TARGET_HASH}" "${QBIT_API}/torrents/stop" >/dev/null 2>&1 || true
sleep 2
qbit_api "$COOKIE_JAR" -d "hashes=${TARGET_HASH}" "${QBIT_API}/torrents/start" >/dev/null 2>&1 || true
@ -176,19 +164,15 @@ failures=0
if [ "$watchdog_rc" -ne 0 ]; then
echo " PASS: watchdog exited non-zero (rc=$watchdog_rc) — expected under stall"
else
# Watchdog only restarts gluetun when specific tracker-failure symptoms match.
# If symptoms are not severe enough yet, surface a soft failure with diagnostics.
if echo "$watchdog_out" | grep -qiE 'DETECTED|routing failure|VPN routing'; then
echo " PASS: watchdog reported routing failure"
else
echo " FAIL: watchdog exited 0 without a detection message"
echo " Symptoms may not have matched (needs has_metadata=false, 0 connections,"
echo " and ≥3 failed trackers with Operation not permitted / timed out / Host not found)."
echo " (needs stalled torrent + failed trackers — see vpn-watchdog.sh)"
failures=$((failures + 1))
fi
fi
# API still reachable after watchdog
if qbit_api "$COOKIE_JAR" "${QBIT_API}/app/version" >/dev/null 2>&1 \
|| qbit_login "$COOKIE_JAR" "$AUTH_USERNAME" "$AUTH_PASSWORD"; then
echo " PASS: qBittorrent API still reachable after watchdog"

View file

@ -1,14 +1,5 @@
#!/bin/bash
# Assemblrr test runner
#
# Usage:
# ./tests/run.sh # unit tests only
# ./tests/run.sh unit
# ./tests/run.sh compose # docker compose config checks
# ./tests/run.sh integration # live stack (requires ASSEMBLRR_ALLOW_LIVE_TEST=1)
# ./tests/run.sh all # unit + compose (not live integration)
# ./tests/run.sh lint # shellcheck if installed
#
# Usage: ./tests/run.sh [unit|compose|integration|lint|all]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@ -44,10 +35,10 @@ run_scripts() {
run_unit() {
echo "Running unit tests..."
# Pure unit tests first (no docker required)
for f in \
"$SCRIPT_DIR/unit/test_compose.sh" \
"$SCRIPT_DIR/unit/test_core.sh" \
"$SCRIPT_DIR/unit/test_env_example.sh" \
"$SCRIPT_DIR/unit/test_vpn_secrets.sh"
do
echo ""
@ -101,7 +92,6 @@ run_lint() {
return 0
fi
# SC1091: sourced paths resolved at runtime; SC2034: libs export for callers
if shellcheck -x -e SC1091,SC2034 "${files[@]}"; then
echo "shellcheck: OK"
else

View file

@ -44,10 +44,8 @@ run_config "base + vpn" \
-f "$tmp/compose/base.yaml" \
-f "$tmp/compose/vpn.yaml"
# examples/custom.yaml.example is documentation (all services commented), so
# `services:` is empty and invalid for compose. Test a minimal real overlay instead.
# example custom.yaml has only commented services; use a valid empty overlay
cat >"$tmp/compose/custom.yaml" <<'EOF'
# Minimal custom overlay for config validation
services: {}
EOF
run_config "base + direct-access + custom overlay" \

111
tests/unit/test_env_example.sh Executable file
View file

@ -0,0 +1,111 @@
#!/bin/bash
# .env.example stays aligned with setup's envsubst key list and actually substitutes
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/../helpers.sh"
example="$REPO_ROOT/.env.example"
setup="$REPO_ROOT/bin/setup.sh"
test_suite ".env.example exists"
assert_true "repo has .env.example" "[ -f \"$example\" ]"
assert_true "setup.sh exists" "[ -f \"$setup\" ]"
mapfile -t template_keys < <(
grep -E '^[A-Za-z_][A-Za-z0-9_]*=\$\{[A-Za-z_][A-Za-z0-9_]*\}$' "$example" \
| sed 's/=.*//' \
| sort -u
)
test_suite "template has expected keys"
if [ "${#template_keys[@]}" -gt 0 ]; then
_TEST_PASSES=$((_TEST_PASSES + 1))
echo " PASS: found ${#template_keys[@]} KEY=\${KEY} lines"
else
_TEST_FAILURES=$((_TEST_FAILURES + 1))
echo " FAIL: no KEY=\${KEY} lines in .env.example"
fi
# First envsubst '...' argument in setup (literal key list)
envsubst_arg=$(
grep -E "envsubst '" "$setup" | head -1 \
| sed -n "s/.*envsubst '\\([^']*\\)'.*/\\1/p"
)
test_suite "setup envsubst list"
if [ -n "$envsubst_arg" ]; then
_TEST_PASSES=$((_TEST_PASSES + 1))
echo " PASS: found envsubst key list in setup.sh"
else
_TEST_FAILURES=$((_TEST_FAILURES + 1))
echo " FAIL: no envsubst '...' list in setup.sh"
test_summary
exit 1
fi
mapfile -t envsubst_keys < <(
# Parse ${NAME} tokens without expanding them
printf '%s\n' "$envsubst_arg" \
| grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\}' \
| sed 's/^\${//;s/}$//' \
| sort -u
)
test_suite "template keys match envsubst list"
for key in "${template_keys[@]}"; do
if printf '%s\n' "${envsubst_keys[@]}" | grep -qx "$key"; then
_TEST_PASSES=$((_TEST_PASSES + 1))
echo " PASS: $key in envsubst list"
else
_TEST_FAILURES=$((_TEST_FAILURES + 1))
echo " FAIL: $key missing from setup envsubst list"
fi
done
for key in "${envsubst_keys[@]}"; do
if printf '%s\n' "${template_keys[@]}" | grep -qx "$key"; then
_TEST_PASSES=$((_TEST_PASSES + 1))
echo " PASS: $key present in .env.example"
else
_TEST_FAILURES=$((_TEST_FAILURES + 1))
echo " FAIL: envsubst lists $key but .env.example has no matching assignment"
fi
done
test_suite "envsubst leaves no placeholders"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
export PUID=1000 PGID=1000 \
MEDIA_DIRECTORY=/data/media INSTALL_DIRECTORY=/opt/assemblrr \
MEDIA_SERVICE=jellyfin TZ=UTC \
VPN_ENABLED=n VPN_SERVICE= VPN_TYPE=openvpn \
WIREGUARD_ADDRESSES= \
PORT_FORWARD_ONLY=off VPN_PORT_FORWARDING=off \
API_HOST=127.0.0.1
envsubst "$envsubst_arg" < "$example" > "$tmp"
if grep -E '\$\{[A-Za-z_][A-Za-z0-9_]*\}' "$tmp" >/dev/null; then
_TEST_FAILURES=$((_TEST_FAILURES + 1))
echo " FAIL: unsubstituted placeholders remain:"
grep -E '\$\{[A-Za-z_][A-Za-z0-9_]*\}' "$tmp" | sed 's/^/ /' || true
else
_TEST_PASSES=$((_TEST_PASSES + 1))
echo " PASS: no leftover placeholders"
fi
for key in PUID INSTALL_DIRECTORY MEDIA_SERVICE API_HOST; do
val=$(grep -E "^${key}=" "$tmp" | head -1 | cut -d= -f2- || true)
if [ -n "$val" ]; then
_TEST_PASSES=$((_TEST_PASSES + 1))
echo " PASS: $key set ($val)"
else
_TEST_FAILURES=$((_TEST_FAILURES + 1))
echo " FAIL: $key empty or missing in generated .env"
fi
done
test_summary