Stage 2 of the voice stack. Repo state only: nothing deployed, no host touched, no container started. - `frank-tts` is now registered everywhere a dankclaw service has to be: `SERVICE_ORDER` (after `frank-tts-engine`, before the consumers), the planner's dankclaw set, `deploy-remote.sh`'s allowlist + dispatch + a `deploy_frank_tts` handler, and `deploy.sh`'s dispatch regex — plus the `build_compose_image` arm naming `dankclaw/voiceservice/Containerfile`, without which the build silently returns 0 and the unit's `Pull=never` tag never exists. - The unit joins TWO networks, in order: `dankclaw_default` first (default route, where consumers and open-notebook live), then the voice bridge. `TTS_UPSTREAM` moves from `host.containers.internal:8880` to `http://frank-tts-engine:8880` — the actual fix for a shape that could never work, since a bridge container cannot reach a host-loopback bind and the engine publishes nothing at all. - `Wants=`/`After=` on the engine, never `Requires=`: the `llama-tts-cli` rollback backend needs no upstream, so a broken engine may delay this endpoint but must not take its unit down. - The handler installs the engine's quadlet pair too, because a referenced `Network=` file the generator cannot find means no façade unit is produced at all, and `--only frank-tts` has no other way to get it. It creates `/home/dank/voices` as the deploy user (rootless podman would auto-create a missing bind source root-owned, which reads as "no voices"), and gates on podman's recorded health. - Voice naming is now written down where it can be found: a voice is a lowercase directory name matching `^[a-z0-9][a-z0-9._-]*$`, at most 64 chars. That shape is the intersection of two rules that disagree — the shim would accept mixed case, the engine lowercases names — so a mixed-case pack resolves on one side only, and the boot warmer refuses it loudly. `narrator1/2/3` are load-bearing for open-notebook. - Homepage card for the pair, `wip: down` and hidden, probing `127.0.0.1:7772`; the engine gets no card because it publishes nothing and could never be probed. - **No Prometheus job, deliberately.** No `/metrics` exists on the shim, this Prometheus 3 refuses HTML/JSON bodies (the existing `comfyui` and `services` probes are documented down for exactly that), and `LlmEndpointDown` is scoped `up{job=~"frank-.*"}` — so a `/health` job would page on Signal for a healthy endpoint. Recorded in the page instead of quietly skipped. - Vault: new [[Frank TTS]] page linked from the index; [[Frank TTS Engine]] now points at it for the pair's public signal; log entry for the day. - Seam grows to 25 tests: the generator's rendering of the pair (both attachments, first-network order, one publish, Wants-not-Requires, bounded per-unit blocks), the env file's upstream checked as a network JOIN rather than a string, planner separation and order, the handler's steps, the card's gating, routing that selects both seams for either deployable.
291 lines
10 KiB
Bash
Executable File
291 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# deploy.sh — push main, pull one target, and deploy only selected services.
|
|
#
|
|
# Planning is path-aware and happens before the push. The target-side runner
|
|
# then applies only those services, with per-service deployment revisions kept
|
|
# outside the Git checkout. A docs-only commit is a no-op. An LLM service is
|
|
# restarted only when its own files (or the shared chat template) are selected.
|
|
#
|
|
# Usage:
|
|
# ./scripts/deploy.sh <dankclaw|dankserver|frankbook-pro> [--service NAME]... [--dry-run]
|
|
#
|
|
# Without --service, the planner selects services changed since origin/main and
|
|
# the remote runner also applies pending service changes from its state store.
|
|
# --service intentionally restricts the run to the named services; it is useful
|
|
# for a safe, explicit retry after another service failed.
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
|
PLANNER="$SCRIPT_DIR/deploy-plan.py"
|
|
REMOTE_USER="dank"
|
|
REMOTE_DIR="/home/dank/serverAppConfigs"
|
|
REMOTE_SHELL="bash"
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: deploy.sh <dankclaw|dankserver|frankbook-pro> [options]
|
|
|
|
Options:
|
|
--service NAME deploy only NAME (repeatable; bypasses automatic planning)
|
|
--dry-run show the plan without pushing or connecting to a target
|
|
--skip-e2e skip the mandatory Playwright live-fleet gate (emergency only)
|
|
-h, --help show this help
|
|
|
|
Examples:
|
|
./scripts/deploy.sh dankclaw
|
|
./scripts/deploy.sh dankclaw --service searxng
|
|
./scripts/deploy.sh dankserver --service frank-archon
|
|
./scripts/deploy.sh dankclaw --dry-run
|
|
EOF
|
|
}
|
|
|
|
TARGET="${1:-}"
|
|
if [[ "$TARGET" == "-h" || "$TARGET" == "--help" ]]; then
|
|
usage
|
|
exit 0
|
|
fi
|
|
if [[ -z "$TARGET" ]]; then
|
|
usage >&2
|
|
exit 2
|
|
fi
|
|
shift
|
|
|
|
case "$TARGET" in
|
|
dankclaw)
|
|
REMOTE_HOST="dankclaw"
|
|
;;
|
|
dankserver)
|
|
REMOTE_HOST="dankserver"
|
|
;;
|
|
frankbook-pro)
|
|
REMOTE_HOST="frankbook-pro"
|
|
REMOTE_USER="dank"
|
|
REMOTE_DIR="/Users/dank/Projects/serverAppConfigs"
|
|
# macOS ships bash 3.2; the runner needs bash >= 4 (associative
|
|
# arrays). /usr/local/bin/bash is the Intel-homebrew interpreter,
|
|
# provisioned with the Mac checkout.
|
|
REMOTE_SHELL="/usr/local/bin/bash"
|
|
;;
|
|
*)
|
|
echo "Unknown target: $TARGET" >&2
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
DRY_RUN=0
|
|
SKIP_E2E=0
|
|
EXPLICIT_SERVICES=()
|
|
while [[ "$#" -gt 0 ]]; do
|
|
case "$1" in
|
|
--dry-run)
|
|
DRY_RUN=1
|
|
shift
|
|
;;
|
|
--skip-e2e)
|
|
SKIP_E2E=1
|
|
shift
|
|
;;
|
|
--service)
|
|
[[ "$#" -ge 2 ]] || { echo "--service requires a name" >&2; exit 2; }
|
|
EXPLICIT_SERVICES+=("$2")
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "Unknown option: $1" >&2
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
cd "$REPO_ROOT"
|
|
if [[ ! -x "$PLANNER" ]]; then
|
|
echo "FAIL: missing executable planner: $PLANNER" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# A deployment is a release operation, not a way to publish an arbitrary
|
|
# worktree. Include untracked files in this guard; the old diff-only check let
|
|
# an untracked secret or half-written config pass the pre-flight.
|
|
if [[ -n "$(git status --porcelain)" ]]; then
|
|
echo "FAIL: Local working tree has uncommitted changes." >&2
|
|
echo "Commit or remove them before deploying." >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$(git branch --show-current)" != "main" ]]; then
|
|
echo "FAIL: deploy.sh must run from the main branch." >&2
|
|
exit 1
|
|
fi
|
|
|
|
HEAD_REVISION="$(git rev-parse HEAD)"
|
|
if BASE_REVISION="$(git rev-parse origin/main 2>/dev/null)"; then
|
|
:
|
|
else
|
|
BASE_REVISION="$(git rev-parse HEAD^ 2>/dev/null || git rev-parse HEAD)"
|
|
fi
|
|
|
|
valid_service() {
|
|
local service="$1"
|
|
case "$TARGET:$service" in
|
|
dankclaw:caddy|dankclaw:gluetun|dankclaw:searxng-network|dankclaw:searxng-valkey|\
|
|
dankclaw:searxng|dankclaw:marreta|dankclaw:pihole|dankclaw:open-webui|dankclaw:gitea-postgres|\
|
|
dankclaw:gitea-server|dankclaw:homepage|\
|
|
dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\
|
|
dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\
|
|
dankclaw:alertmanager|\
|
|
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
|
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
|
|
dankclaw:open-notebook|\
|
|
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
|
|
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
|
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
|
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
|
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
|
|
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|\
|
|
dankserver:frank-archon|\
|
|
frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\
|
|
frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\
|
|
frankbook-pro:readarr)
|
|
return 0
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
for service in "${EXPLICIT_SERVICES[@]}"; do
|
|
if ! valid_service "$service"; then
|
|
echo "Service '$service' is not deployed on $TARGET." >&2
|
|
exit 2
|
|
fi
|
|
done
|
|
|
|
PLAN=()
|
|
if [[ "${#EXPLICIT_SERVICES[@]}" -gt 0 ]]; then
|
|
PLAN=("${EXPLICIT_SERVICES[@]}")
|
|
else
|
|
mapfile -t PLAN < <(
|
|
python3 "$PLANNER" --target "$TARGET" --repo "$REPO_ROOT" \
|
|
--base "$BASE_REVISION" --head "$HEAD_REVISION"
|
|
)
|
|
fi
|
|
|
|
# Preserve planner order while removing duplicate explicit service names.
|
|
UNIQUE_PLAN=()
|
|
declare -A SEEN=()
|
|
for service in "${PLAN[@]}"; do
|
|
[[ -n "$service" ]] || continue
|
|
if [[ -z "${SEEN[$service]:-}" ]]; then
|
|
UNIQUE_PLAN+=("$service")
|
|
SEEN[$service]=1
|
|
fi
|
|
done
|
|
PLAN=("${UNIQUE_PLAN[@]}")
|
|
|
|
printf '=== Deployment plan: %s ===\n' "$TARGET"
|
|
printf ' checkout: %s\n' "$HEAD_REVISION"
|
|
if [[ "${#PLAN[@]}" -eq 0 ]]; then
|
|
echo " changed services: (none; the remote will check pending service state)"
|
|
else
|
|
printf ' changed services:\n'
|
|
printf ' - %s\n' "${PLAN[@]}"
|
|
fi
|
|
|
|
# --- frankbook e2e trigger --------------------------------------------------
|
|
# The *arr/qBittorrent e2e units walk links that terminate on FrankBook-Pro
|
|
# (192.168.0.112), which sleeps when idle. The [3/3] e2e gate therefore runs
|
|
# the frankbook Playwright project ONLY when something in its blast radius
|
|
# changed since origin/main: the frankbook spec itself, the frankbook-pro
|
|
# stack, the homepage registry, or the Caddyfile serving the arr vhosts.
|
|
# Every other deploy skips it (tests/e2e/run.sh excludes the project by
|
|
# default). See tests/e2e/specs/19-frankbook.spec.ts.
|
|
FB_E2E_TRIGGER=0
|
|
while IFS= read -r fb_path; do
|
|
case "$fb_path" in
|
|
tests/e2e/specs/19-frankbook.spec.ts|frankbook-pro/*|shared/homepage/services.json|dankclaw/Caddyfile) FB_E2E_TRIGGER=1 ;;
|
|
esac
|
|
done < <(git diff --name-only "$BASE_REVISION" "$HEAD_REVISION")
|
|
|
|
REMOTE="${REMOTE_USER}@${REMOTE_HOST}"
|
|
REMOTE_ARGS=("$TARGET" "$HEAD_REVISION" "$REMOTE_DIR" "$REMOTE_SHELL")
|
|
if [[ "${#EXPLICIT_SERVICES[@]}" -eq 0 ]]; then
|
|
REMOTE_ARGS+=(--pending)
|
|
fi
|
|
REMOTE_ARGS+=("${PLAN[@]}")
|
|
|
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
|
echo "[dry-run] Would push main to origin."
|
|
echo "[dry-run] Would SSH to $REMOTE, pull with --ff-only, and run the service-scoped runner."
|
|
printf '[dry-run] Runner arguments:'
|
|
printf ' %q' "${REMOTE_ARGS[@]}"
|
|
printf '\n'
|
|
if [[ "$FB_E2E_TRIGGER" -eq 1 ]]; then
|
|
echo "[dry-run] e2e gate: frankbook project INCLUDED (frankbook trigger paths changed)."
|
|
else
|
|
echo "[dry-run] e2e gate: frankbook project EXCLUDED (no frankbook trigger paths)."
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
echo "[1/2] Pushing main to origin..."
|
|
git push origin main
|
|
|
|
echo "[2/2] Pulling and deploying on $REMOTE_HOST..."
|
|
# The target runner computes any pending revisions from its own state after
|
|
# the pull. This keeps a target that missed an earlier service deployment
|
|
# recoverable without restarting services that are already current.
|
|
ssh "$REMOTE" "$REMOTE_SHELL" -s -- "${REMOTE_ARGS[@]}" <<'REMOTE_EOF'
|
|
set -euo pipefail
|
|
TARGET="$1"
|
|
EXPECTED_REVISION="$2"
|
|
REPO_DIR="$3"
|
|
REMOTE_SHELL="$4"
|
|
shift 4
|
|
export REMOTE_DIR="$REPO_DIR"
|
|
cd "$REPO_DIR"
|
|
OLD_REVISION="$(git rev-parse HEAD)"
|
|
echo " pulling origin/main..."
|
|
git pull --ff-only origin main
|
|
NEW_REVISION="$(git rev-parse HEAD)"
|
|
if [[ "$NEW_REVISION" != "$EXPECTED_REVISION" ]]; then
|
|
echo "FAIL: target checkout is $NEW_REVISION, expected $EXPECTED_REVISION" >&2
|
|
exit 1
|
|
fi
|
|
exec "$REMOTE_SHELL" ./scripts/deploy-remote.sh "$TARGET" "$NEW_REVISION" "$@" \
|
|
--from-revision "$OLD_REVISION"
|
|
REMOTE_EOF
|
|
|
|
# --- Playwright e2e gate (MANDATORY after behavior-affecting deploys) ---
|
|
# The unit seams in tests/run.sh cannot see the live guard chain, LAN DNS, or
|
|
# the rendered homepage — exactly the layers that broke on 2026-09-12. This gate
|
|
# drives the LIVE fleet (Anubis -> Authentik outpost -> apps) and fails the
|
|
# deploy on any regression. It needs tests/e2e/.env.local with the qa-e2e IdP
|
|
# credentials (see .env.local.example). Use --skip-e2e for an emergency only.
|
|
if [[ "$SKIP_E2E" -eq 1 ]]; then
|
|
echo "[3/3] e2e gate SKIPPED (--skip-e2e): live fleet NOT verified." >&2
|
|
else
|
|
echo "[3/3] Running Playwright e2e gate against the live fleet..."
|
|
E2E_ENV=()
|
|
if [[ "$FB_E2E_TRIGGER" -eq 1 ]]; then
|
|
echo " frankbook trigger paths changed: including the frankbook e2e project."
|
|
E2E_ENV=(E2E_INCLUDE_FRANKBOOK=1)
|
|
fi
|
|
if env "${E2E_ENV[@]}" bash "$REPO_ROOT/tests/e2e/run.sh"; then
|
|
echo "e2e gate PASSED."
|
|
else
|
|
echo "=== DEPLOY INCOMPLETE: e2e gate FAILED on $TARGET ===" >&2
|
|
echo "The live-fleet Playwright suite did not pass. Do NOT treat this" >&2
|
|
echo "deploy as good. Re-run manually: tests/e2e/run.sh" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
echo "=== Deploy complete: $TARGET ==="
|