Deploy services independently by changed paths

This commit is contained in:
Frank
2026-08-25 20:13:33 -07:00
parent 2277a2e9ac
commit 1ba47e4d22
18 changed files with 1462 additions and 894 deletions
+10 -100
View File
@@ -1,104 +1,14 @@
#!/usr/bin/env bash
# deploy-open-webui-quadlet.sh — Install and (re)start open-webui on dankclaw.
#
# open-webui is boot-persistent via Quadlet (see AGENTS.md). This script:
# 1. Pushes main to origin
# 2. Pulls the repo on the target
# 3. Creates the data dir and copies the unit + network into ~/.config/containers/systemd/
# 4. daemon-reloads and starts open-webui (first run pulls the image)
#
# It is idempotent: it stops any running unit and removes any container with the
# name (a prior run or a bare-container leftover) before reinstalling and
# starting. The data dir is preserved across reruns, so existing chats survive.
#
# Usage:
# ./scripts/deploy-open-webui-quadlet.sh <target> [--dry-run]
# ./scripts/deploy-open-webui-quadlet.sh dankclaw
#!/usr/bin/env bash
# Compatibility wrapper for the service-scoped deployment dispatcher.
set -euo pipefail
REMOTE_USER="dank"
REMOTE_HOST="dankclaw"
REMOTE_DIR="/home/dank/serverAppConfigs"
QDIR="dankclaw/open-webui/quadlet"
UNIT="open-webui.service"
DATA_DIR="/home/dank/Containers/open-webui/data"
TARGET="${1:-}"
DRY_RUN=0
if [ "${2:-}" = "--dry-run" ]; then
DRY_RUN=1
fi
if [ -z "${TARGET:-}" ]; then
echo "Usage: $0 <target> [--dry-run]"
exit 1
fi
if [ "${TARGET}" != "dankclaw" ]; then
echo "This stack runs only on dankclaw (got: ${TARGET})"
exit 1
fi
# ---------------------------------------------------------------------------
# Local pre-flight
# ---------------------------------------------------------------------------
cd "$(dirname "$0")/.."
if ! git diff --quiet HEAD; then
echo "FAIL: Local working tree has uncommitted changes. Commit first."
exit 1
fi
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would push main, pull on ${REMOTE_HOST}, install ${UNIT}, and start it (data at ${DATA_DIR})."
exit 0
fi
echo "=== Deploying open-webui quadlet to ${REMOTE_HOST} ==="
echo "[1/3] Pushing local main to origin..."
git push origin main
REMOTE="${REMOTE_USER}@${REMOTE_HOST}"
echo "[2/3] Installing and starting ${UNIT} on ${REMOTE_HOST}..."
ssh "$REMOTE" bash -s <<REMOTE_EOF
set -euo pipefail
cd '${REMOTE_DIR}'
echo ' Pulling repo...'
git pull origin main
echo ' Stopping any running unit (idempotent)...'
systemctl --user stop '${UNIT}' 2>/dev/null || true
echo ' Removing any container with the name (prior run or bare-container leftover)...'
podman rm -f open-webui 2>/dev/null || true
echo ' Creating the data dir (preserved across reruns)...'
mkdir -p '${DATA_DIR}'
echo ' Copying the quadlet unit + network into ~/.config/containers/systemd/ ...'
mkdir -p ~/.config/containers/systemd
cp ${QDIR}/open-webui.container ~/.config/containers/systemd/
cp ${QDIR}/open-webui.network ~/.config/containers/systemd/
echo ' daemon-reload (generates the unit)...'
# Quadlet units are generator-managed: systemctl enable fails on them. The
# [Install] WantedBy=default.target in the .container file is itself the
# enablement (is-enabled reports "generated"); with user linger on it starts at
# boot. So: daemon-reload, then start.
systemctl --user daemon-reload
echo ' Starting (first run pulls the image; this can take a while)...'
systemctl --user start '${UNIT}'
echo ' Unit state:'
systemctl --user --no-pager status '${UNIT}' 2>&1 | grep -E "●|Active:" || true
REMOTE_EOF
echo "[3/3] Verifying open-webui is up..."
ssh "$REMOTE" '
echo " is-enabled: $(systemctl --user is-enabled open-webui.service 2>&1) (boot-persistent)"
echo " port 3005:"; (ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null) | grep -E ":3005\b" || echo " (not listening yet)"
echo " HTTP probe:"; curl -s -o /dev/null -w " http://127.0.0.1:3005 -> %{http_code}\n" -m 10 http://127.0.0.1:3005/ || echo " (no HTTP yet)"
' || true
echo "=== open-webui quadlet deployed to ${REMOTE_HOST} ==="
shift || true
case "$TARGET" in
dankclaw) exec "$(dirname "$0")/deploy.sh" dankclaw --service open-webui "$@" ;;
*)
echo "This stack runs only on dankclaw (got: ${TARGET:-<missing>})" >&2
exit 2
;;
esac
+9 -114
View File
@@ -1,118 +1,13 @@
#!/usr/bin/env bash
# deploy-pihole-quadlet.sh — Install and (re)start the Pi-hole LAN DNS on
# dankclaw. Pi-hole retired the dnsmasq quadlet (2026-08-24).
#
# Pi-hole is boot-persistent via Quadlet (see AGENTS.md). This script:
# 1. Pushes main to origin
# 2. Pulls the repo on the target
# 3. Creates the data dirs and copies the quadlet unit + env + addn-hosts
# 4. daemon-reloads and starts pihole (first run pulls the image)
#
# It is idempotent: it stops any running unit and removes any container with
# the name (a prior run or a bare-container leftover) before reinstalling and
# starting. The data dirs (etc-pihole, etc-dockers) are preserved across
# reruns, so the blocklist and web settings survive. The env file is created
# from env.example only if absent; fill the real WEBPASSWORD before the web
# UI is usable.
#
# Usage:
# ./scripts/deploy-pihole-quadlet.sh <target> [--dry-run]
# ./scripts/deploy-pihole-quadlet.sh dankclaw
# Compatibility wrapper for the service-scoped deployment dispatcher.
set -euo pipefail
REMOTE_USER="dank"
REMOTE_HOST="dankclaw"
REMOTE_DIR="/home/dank/serverAppConfigs"
QDIR="dankclaw/pihole"
UNIT="pihole.service"
DATA_DIR="/home/dank/Containers/pihole"
TARGET="${1:-}"
DRY_RUN=0
if [ "${2:-}" = "--dry-run" ]; then
DRY_RUN=1
fi
if [ -z "${TARGET:-}" ]; then
echo "Usage: $0 <target> [--dry-run]"
exit 1
fi
if [ "${TARGET}" != "dankclaw" ]; then
echo "This stack runs only on dankclaw (got: ${TARGET})"
exit 1
fi
# ---------------------------------------------------------------------------
# Local pre-flight
# ---------------------------------------------------------------------------
cd "$(dirname "$0")/.."
if ! git diff --quiet HEAD; then
echo "FAIL: Local working tree has uncommitted changes. Commit first."
exit 1
fi
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would push main, pull on ${REMOTE_HOST}, install ${UNIT}, and start it (data at ${DATA_DIR})."
exit 0
fi
echo "=== Deploying pihole quadlet to ${REMOTE_HOST} ==="
echo "[1/3] Pushing local main to origin..."
git push origin main
REMOTE="${REMOTE_USER}@${REMOTE_HOST}"
echo "[2/3] Installing and starting ${UNIT} on ${REMOTE_HOST}..."
ssh "$REMOTE" bash -s <<REMOTE_EOF
set -euo pipefail
cd '${REMOTE_DIR}'
echo ' Pulling repo...'
git pull origin main
echo ' Stopping any running unit (idempotent)...'
systemctl --user stop '${UNIT}' 2>/dev/null || true
echo ' Removing any container with the name (prior run or bare-container leftover)...'
podman rm -f pihole 2>/dev/null || true
echo ' Creating the data dirs (preserved across reruns)...'
mkdir -p '${DATA_DIR}/etc-pihole' '${DATA_DIR}/etc-dockers'
echo ' Creating the env file from env.example if absent...'
if [ ! -f '${DATA_DIR}/env' ]; then
cp ${QDIR}/env.example '${DATA_DIR}/env'
echo ' env created: set the real WEBPASSWORD before the web UI is usable'
else
echo ' env present, keeping it'
fi
echo ' Copying the record file (addn-hosts)...'
cp ${QDIR}/addn-hosts '${DATA_DIR}/addn-hosts'
echo ' Copying the quadlet unit into ~/.config/containers/systemd/ ...'
mkdir -p ~/.config/containers/systemd
cp ${QDIR}/quadlet/pihole.container ~/.config/containers/systemd/
echo ' daemon-reload (generates the unit)...'
# Quadlet units are generator-managed: systemctl enable fails on them. The
# [Install] WantedBy=default.target in the .container file is itself the
# enablement (is-enabled reports "generated"); with user linger on it starts
# at boot. So: daemon-reload, then start.
systemctl --user daemon-reload
echo ' Starting (first run pulls the image; this can take a while)...'
systemctl --user start '${UNIT}'
echo ' Unit state:'
systemctl --user --no-pager status '${UNIT}' 2>&1 | grep -E "●|Active:" || true
REMOTE_EOF
echo "[3/3] Verifying pihole is up..."
ssh "$REMOTE" '
echo " is-enabled: $(systemctl --user is-enabled pihole.service 2>&1) (boot-persistent)"
echo " port 53:"; (ss -ulpn 2>/dev/null) | grep -E ":53\b" || echo " (not listening yet)"
echo " resolution:"; dig @127.0.0.1 romm.franksdank.cloud +short 2>/dev/null || echo " (no answer yet)"
' || true
echo "=== pihole quadlet deployed to ${REMOTE_HOST} ==="
shift || true
case "$TARGET" in
dankclaw) exec "$(dirname "$0")/deploy.sh" dankclaw --service pihole "$@" ;;
*)
echo "This stack runs only on dankclaw (got: ${TARGET:-<missing>})" >&2
exit 2
;;
esac
+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env python3
"""Plan runtime services affected by repository changes.
The planner is intentionally side-effect free. It accepts either an explicit
path list or two Git revisions. The latter lets compose-file changes resolve
to only the service definitions that actually changed instead of restarting an
entire compose project.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import PurePosixPath
from typing import Any
import yaml
# Dependency-sensitive services come before their consumers. Independent
# compose services follow. LLM services are deliberately last and are never
# implied by a generic host, stack, documentation, or config change.
SERVICE_ORDER = (
"gluetun",
"searxng-valkey",
"searxng",
"pihole",
"open-webui",
"gitea-postgres",
"gitea-server",
"gitea-caddy",
"comfyui",
"hermes",
"hermes-dashboard",
"prometheus",
"grafana",
"loki",
"alloy",
"node-exporter",
"authentik-postgres",
"authentik-server",
"authentik-worker",
"caddy",
"homepage",
"mariadb",
"redis",
"romm",
"copyparty",
"jellyfin",
"calibre-web",
"prowlarr",
"qbittorrent",
"radarr",
"sonarr",
"lidarr",
"readarr",
"frank-default-large",
"frank-default-npu",
"frank-default-small",
)
DANKCLAW_SERVICES = {
"gluetun",
"searxng-valkey",
"searxng",
"pihole",
"open-webui",
"gitea-postgres",
"gitea-server",
"gitea-caddy",
"comfyui",
"hermes",
"hermes-dashboard",
"prometheus",
"grafana",
"loki",
"alloy",
"node-exporter",
"authentik-postgres",
"authentik-server",
"authentik-worker",
"caddy",
"homepage",
"frank-default-large",
"frank-default-npu",
}
DANKSERVER_SERVICES = {
"mariadb",
"redis",
"romm",
"copyparty",
"jellyfin",
"calibre-web",
"prowlarr",
"qbittorrent",
"radarr",
"sonarr",
"lidarr",
"readarr",
"homepage",
"frank-default-small",
}
DANKCLAW_COMPOSE_SERVICES = {
"comfyui",
"hermes",
"hermes-dashboard",
"prometheus",
"grafana",
"loki",
"alloy",
"node-exporter",
"authentik-postgres",
"authentik-server",
"authentik-worker",
"caddy",
}
DANKSERVER_COMPOSE_SERVICES = {
"mariadb",
"redis",
"romm",
"copyparty",
"jellyfin",
"calibre-web",
"prowlarr",
"qbittorrent",
"radarr",
"sonarr",
"lidarr",
"readarr",
}
def _add(services: set[str], *names: str) -> None:
services.update(names)
def target_services(target: str) -> set[str]:
if target == "dankclaw":
return DANKCLAW_SERVICES
if target == "dankserver":
return DANKSERVER_SERVICES
raise ValueError(f"unknown target: {target}")
def _compose_services(target: str) -> set[str]:
return DANKCLAW_COMPOSE_SERVICES if target == "dankclaw" else DANKSERVER_COMPOSE_SERVICES
def _git_show(repo: str, revision: str, path: str) -> str | None:
result = subprocess.run(
["git", "-C", repo, "show", f"{revision}:{path}"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
check=False,
)
return result.stdout if result.returncode == 0 else None
def _yaml_document(contents: str | None) -> dict[str, Any]:
if contents is None:
return {}
try:
data = yaml.safe_load(contents) or {}
except yaml.YAMLError:
return {}
return data if isinstance(data, dict) else {}
def _yaml_services(contents: str | None) -> dict[str, Any]:
services = _yaml_document(contents).get("services", {})
return services if isinstance(services, dict) else {}
def compose_changes(repo: str, target: str, base: str, head: str) -> set[str]:
"""Return only compose services whose YAML definitions or dependencies changed."""
path = f"{target}/compose.yaml"
old_doc = _yaml_document(_git_show(repo, base, path))
new_doc = _yaml_document(_git_show(repo, head, path))
old = old_doc.get("services", {}) if isinstance(old_doc.get("services", {}), dict) else {}
new = new_doc.get("services", {}) if isinstance(new_doc.get("services", {}), dict) else {}
names = set(old) | set(new)
allowed = _compose_services(target)
# An unavailable or malformed revision is safer as a broad compose plan
# than as a silent no-op. It still cannot include LLM units.
if (old_doc and not new_doc) or (new_doc and not old_doc and names):
return allowed
# Top-level networks/configuration can affect every compose service even
# when no individual service mapping changed. Ignore the services key while
# comparing the rest of the Compose document.
old_globals = {key: value for key, value in old_doc.items() if key != "services"}
new_globals = {key: value for key, value in new_doc.items() if key != "services"}
if old_globals != new_globals:
return allowed
return {name for name in names if name in allowed and old.get(name) != new.get(name)}
def services_for_path(path: str, target: str) -> set[str]:
"""Return services affected by one repository-relative path."""
path = path.replace("\\", "/")
p = PurePosixPath(path)
parts = p.parts
services: set[str] = set()
if not parts:
return services
# Non-runtime material is deliberately a no-op.
if parts[0] in {"docs", "tests", "old", ".githooks"}:
return services
if path in {"README.md", "AGENTS.md"} or parts[0] == ".obsidian":
return services
if parts[0] == "shared":
if len(parts) < 2:
return services
group = parts[1]
if group == "searxng":
_add(services, "searxng")
elif group == "prometheus":
_add(services, "prometheus")
elif group == "loki":
_add(services, "loki")
elif group == "alloy":
_add(services, "alloy")
elif group == "homepage":
# Homepage is a Dankclaw-owned deployment. Shared homepage files
# are intentionally not copied during a Dankserver-only run.
if target == "dankclaw":
_add(services, "homepage")
return services
if parts[0] == "dankclaw":
if len(parts) >= 2 and parts[1] == "compose.yaml":
# Revision-aware callers replace this broad fallback with the
# service-level YAML comparison in compose_changes().
_add(services, *DANKCLAW_COMPOSE_SERVICES)
return services
if len(parts) >= 2 and parts[1] == "Caddyfile":
_add(services, "caddy")
return services
if len(parts) >= 2 and parts[1] == "Containerfile.comfyui":
_add(services, "comfyui")
return services
if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes"}:
_add(services, parts[1])
return services
if len(parts) >= 2 and parts[1] == "searxng":
if len(parts) >= 4 and parts[2] == "quadlet":
name = parts[3]
if name == "gluetun.container":
# SearXNG shares Gluetun's network namespace. A Gluetun
# recreation requires both units to be restarted.
_add(services, "gluetun", "searxng")
elif name == "searxng.container":
_add(services, "searxng")
elif name == "searxng-valkey.container":
_add(services, "searxng-valkey", "searxng")
elif name == "searxng.network":
_add(services, "gluetun", "searxng-valkey", "searxng")
return services
if len(parts) >= 2 and parts[1] == "pihole":
_add(services, "pihole")
return services
if len(parts) >= 2 and parts[1] == "open-webui":
_add(services, "open-webui")
return services
if len(parts) >= 2 and parts[1] == "gitea":
if len(parts) >= 4 and parts[2] == "quadlet":
name = parts[3]
if name == "gitea-postgres.container":
_add(services, "gitea-postgres")
elif name == "gitea-server.container":
_add(services, "gitea-server")
elif name == "gitea-caddy.container":
_add(services, "gitea-caddy")
elif name == "gitea.network":
_add(services, "gitea-postgres", "gitea-server", "gitea-caddy")
return services
return services
if parts[0] == "dankserver":
if len(parts) >= 2 and parts[1] == "compose.yaml":
_add(services, *DANKSERVER_COMPOSE_SERVICES)
return services
if parts[0] == "scripts":
name = p.name
if name in {"frank-default-large.env", "frank-default-large.service", "serve-frank-large.sh"}:
_add(services, "frank-default-large")
elif name in {"frank-default-npu.env", "frank-default-npu.service", "serve-frank-default-npu.sh"}:
_add(services, "frank-default-npu")
elif name in {"frank-default-small.env", "frank-default-small.service", "serve-frank-small.sh"}:
_add(services, "frank-default-small")
elif name == "chat-template.jinja":
# Only the selected target's LLM consumes this shared template.
_add(services, "frank-default-large" if target == "dankclaw" else "frank-default-small")
elif name in {"homepage-status.py", "homepage-status.service", "homepage-status.timer"}:
_add(services, "homepage")
# Deployment/test/benchmark scripts and other repo tooling do not
# restart a runtime service.
return services
# Examples and env templates are not deployed runtime state.
if p.name in {".env.example", "env.example"}:
return services
return services
def changed_paths(repo: str, base: str, head: str) -> list[str]:
result = subprocess.run(
["git", "-C", repo, "diff", "--name-only", "--diff-filter=ACDMRTUXB", base, head],
text=True,
stdout=subprocess.PIPE,
check=True,
)
return [line for line in result.stdout.splitlines() if line]
def plan(paths: list[str], target: str, repo: str | None = None,
base: str | None = None, head: str | None = None) -> list[str]:
services: set[str] = set()
for path in paths:
if path == f"{target}/compose.yaml" and repo and base and head:
services.update(compose_changes(repo, target, base, head))
else:
services.update(services_for_path(path, target))
allowed = target_services(target)
return [name for name in SERVICE_ORDER if name in services and name in allowed]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", choices=("dankclaw", "dankserver"), required=True)
parser.add_argument("--paths", nargs="+")
parser.add_argument("--repo", help="repository path for revision-aware planning")
parser.add_argument("--base", help="base Git revision")
parser.add_argument("--head", help="head Git revision")
args = parser.parse_args()
if not args.paths and not (args.repo and args.base and args.head):
parser.error("provide --paths or all of --repo, --base, and --head")
paths = args.paths or changed_paths(args.repo, args.base, args.head)
print("\n".join(plan(paths, args.target, args.repo, args.base, args.head)))
return 0
if __name__ == "__main__":
sys.exit(main())
+474
View File
@@ -0,0 +1,474 @@
#!/usr/bin/env bash
# deploy-remote.sh — apply an explicit service plan on one target host.
#
# This script runs on the target checkout. It deliberately does not infer a
# plan, pull Git, or restart an unrelated service. deploy.sh computes the plan
# from the pushed revision and passes the selected service names here.
# Per-service revisions live in /var/lib/serverAppConfigs/deploy-state (or
# DEPLOY_STATE_DIR), outside the checkout, so an unselected change remains
# pending until its service is explicitly deployed.
set -euo pipefail
TARGET="${1:-}"
REVISION="${2:-}"
shift 2 || true
FROM_REVISION=""
PENDING=0
SERVICES=()
while [[ "$#" -gt 0 ]]; do
case "$1" in
--from-revision)
[[ "$#" -ge 2 ]] || { echo "--from-revision requires a revision" >&2; exit 2; }
FROM_REVISION="$2"
shift 2
;;
--pending)
PENDING=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
SERVICES+=("$1")
shift
;;
esac
done
REMOTE_DIR="${REMOTE_DIR:-/home/dank/serverAppConfigs}"
STATE_DIR="${DEPLOY_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/serverAppConfigs/deploy-state}"
run_privileged() {
if [[ -n "${FRANK_SUDO_PW:-}" ]]; then
printf '%s\n' "$FRANK_SUDO_PW" | sudo -S -- "$@"
else
sudo -- "$@"
fi
}
usage() {
cat <<'EOF'
Usage: deploy-remote.sh <dankclaw|dankserver> <git-revision> [--pending] [service]...
The service list is explicit. An empty list is a successful no-op.
EOF
}
if [[ "$TARGET" != dankclaw && "$TARGET" != dankserver ]]; then
usage >&2
exit 2
fi
if [[ -z "$REVISION" ]]; then
usage >&2
exit 2
fi
if [[ "$REVISION" == *[!0-9a-fA-F]* ]]; then
echo "invalid Git revision: $REVISION" >&2
exit 2
fi
if ! git -C "$REMOTE_DIR" cat-file -e "$REVISION^{commit}" 2>/dev/null; then
echo "revision is not a commit in the target checkout: $REVISION" >&2
exit 2
fi
cd "$REMOTE_DIR"
# Keep the state directory outside Git. The file is written only after a
# handler completes successfully; an interrupted handler remains pending.
mkdir -p "$STATE_DIR"
service_is_allowed() {
local service="$1"
case "$TARGET:$service" in
dankclaw:gluetun|dankclaw:searxng-valkey|dankclaw:searxng|dankclaw:pihole|\
dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\
dankclaw:gitea-caddy|dankclaw:comfyui|dankclaw:hermes|\
dankclaw:hermes-dashboard|dankclaw:prometheus|dankclaw:grafana|\
dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|\
dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:caddy|dankclaw:homepage|\
dankclaw:frank-default-large|dankclaw:frank-default-npu|\
dankserver:mariadb|dankserver:redis|dankserver:romm|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|\
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\
dankserver:frank-default-small)
return 0
;;
*)
return 1
;;
esac
}
mark_status() {
local service="$1" status="$2" tmp
tmp="$(mktemp "$STATE_DIR/.${service}.XXXXXX")"
printf 'revision=%s\nstatus=%s\n' "$REVISION" "$status" > "$tmp"
mv -f -- "$tmp" "$STATE_DIR/$service"
}
mark_deployed() {
mark_status "$1" deployed
}
state_revision() {
local service="$1" line value
if [[ -r "$STATE_DIR/$service" ]]; then
while IFS= read -r line; do
case "$line" in
revision=*) value="${line#revision=}" ;;
esac
done < "$STATE_DIR/$service"
printf '%s' "${value:-}"
fi
}
state_status() {
local service="$1" line value
if [[ -r "$STATE_DIR/$service" ]]; then
while IFS= read -r line; do
case "$line" in
status=*) value="${line#status=}" ;;
esac
done < "$STATE_DIR/$service"
printf '%s' "${value:-pending}"
fi
}
add_changed_services() {
local base="$1"
local service
[[ -n "$base" ]] || return 0
while IFS= read -r service; do
[[ -n "$service" ]] || continue
SERVICES+=("$service")
done < <(python3 scripts/deploy-plan.py --target "$TARGET" --repo "$REMOTE_DIR" \
--base "$base" --head "$REVISION")
}
add_pending_services() {
local service recorded changed
for state_file in "$STATE_DIR"/*; do
[[ -f "$state_file" ]] || continue
service="${state_file##*/}"
recorded="$(state_revision "$service")"
[[ -n "$recorded" && "$recorded" != "$REVISION" ]] || continue
[[ "$(state_status "$service")" != deployed ]] || continue
if [[ "$TARGET" == dankclaw && "$service" == frank-default-small ]]; then continue; fi
if [[ "$TARGET" == dankserver && "$service" == frank-default-large ]]; then continue; fi
if ! changed="$(python3 scripts/deploy-plan.py --target "$TARGET" \
--repo "$REMOTE_DIR" --base "$recorded" --head "$REVISION")"; then
echo "WARN: unable to compare state for $service; retaining it as pending" >&2
SERVICES+=("$service")
continue
fi
if grep -Fxq "$service" <<< "$changed"; then
SERVICES+=("$service")
fi
done
}
copy_shared_config() {
run_privileged mkdir -p /opt/searxng/config /opt/prometheus/config /opt/loki/config /opt/alloy/config
run_privileged cp shared/searxng/settings.yml /opt/searxng/config/settings.yml
run_privileged cp shared/searxng/limiter.toml /opt/searxng/config/limiter.toml
run_privileged cp shared/prometheus/prometheus.yml /opt/prometheus/config/prometheus.yml
run_privileged cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml
run_privileged cp shared/alloy/config.alloy /opt/alloy/config/config.alloy
}
render_caddyfile() {
local hash
hash="$(grep -E '^CADDY_BASICAUTH_HASH=' .env 2>/dev/null | tail -1 | cut -d= -f2- || true)"
if [[ -z "$hash" ]]; then
echo "WARN: CADDY_BASICAUTH_HASH is not set; refusing to overwrite rendered Caddyfile" >&2
return 1
fi
run_privileged mkdir -p /home/dank/Containers/caddy
sed -e "s|REPLACE_WITH_HASH|$hash|g" dankclaw/Caddyfile > /tmp/serverAppConfigs.Caddyfile
run_privileged cp /tmp/serverAppConfigs.Caddyfile /home/dank/Containers/caddy/Caddyfile
}
deploy_homepage() {
run_privileged mkdir -p /srv/homepage
run_privileged cp shared/homepage/index.html shared/homepage/admin.html shared/homepage/app.js \
shared/homepage/style.css shared/homepage/services.json /srv/homepage/
if [[ -d shared/homepage/fonts ]]; then
run_privileged cp -r shared/homepage/fonts /srv/homepage/
fi
local deployed_at
deployed_at="$(git log -1 --format=%cI)"
printf 'window.DEPLOYED_AT = "%s";\n' "$deployed_at" > /tmp/deployed.js
run_privileged cp /tmp/deployed.js /srv/homepage/deployed.js
run_privileged chmod -R a+rX /srv/homepage
run_privileged cp scripts/homepage-status.py /usr/local/bin/homepage-status.py
run_privileged cp scripts/homepage-status.service scripts/homepage-status.timer /etc/systemd/system/
run_privileged systemctl daemon-reload
run_privileged systemctl enable homepage-status.timer
run_privileged systemctl restart homepage-status.timer
run_privileged python3 /usr/local/bin/homepage-status.py /srv/homepage/services.json /srv/homepage/status.json
}
deploy_caddy() {
copy_shared_config
render_caddyfile
deploy_homepage
local cfhash oldhash image
cfhash="$(sha256sum dankclaw/caddy/Containerfile | cut -d' ' -f1)"
image=franksdank/caddy-porkbun
oldhash="$(podman image inspect "$image:latest" --format '{{index .Config.Labels "cfhash"}}' 2>/dev/null || echo none)"
if [[ "$oldhash" != "$cfhash" ]]; then
podman build --no-cache --label "cfhash=$cfhash" -t "$image:latest" \
-f dankclaw/caddy/Containerfile .
fi
# The canonical reverse proxy may be gitea-caddy on a host with the old
# split deployment. Never stop or replace it implicitly. Refuse the Caddy
# deployment until the operator has explicitly migrated port ownership.
if podman container exists gitea-caddy && ! podman container exists caddy; then
echo "FAIL: gitea-caddy owns the reverse-proxy deployment; refusing to replace it with caddy." >&2
return 1
fi
if podman container exists gitea-caddy && podman container exists caddy; then
local gitea_state caddy_state
gitea_state="$(podman inspect gitea-caddy --format '{{.State.Status}}' 2>/dev/null || true)"
caddy_state="$(podman inspect caddy --format '{{.State.Status}}' 2>/dev/null || true)"
if [[ "$gitea_state" == running && "$caddy_state" != running ]]; then
echo "FAIL: gitea-caddy currently owns ports 80/443; refusing to start caddy." >&2
return 1
fi
fi
if podman container exists caddy; then
podman-compose -f dankclaw/compose.yaml up -d --no-deps caddy
local reloaded=0
for _ in 1 2 3 4 5; do
if podman exec caddy caddy reload --config /etc/caddy/Caddyfile \
--adapter caddyfile >/dev/null 2>&1; then
reloaded=1
echo "Caddy reloaded."
break
fi
sleep 3
done
if [[ "$reloaded" -ne 1 ]]; then
echo "Caddy reload failed; restarting only caddy."
podman restart caddy
fi
else
echo "WARN: caddy container is absent; no proxy was stopped or replaced."
fi
}
deploy_compose_service() {
local service="$1"
if [[ "$TARGET" == dankserver ]]; then
# Dankserver's compose file is the source-of-truth inventory, but its
# production containers are individually systemd-managed/bare. Do not
# use compose up here: it can create a second container with a wrong
# name and can interfere with a running workload.
echo "WARN: $service is recorded in dankserver/compose.yaml; production container management is host-specific and no restart was attempted."
return 0
fi
case "$service" in
comfyui|hermes|hermes-dashboard|prometheus|grafana|loki|alloy|node-exporter|authentik-postgres|authentik-server|authentik-worker)
case "$service" in
comfyui|loki|alloy)
build_compose_image "$service"
;;
esac
podman-compose -f dankclaw/compose.yaml up -d --no-deps "$service"
;;
*)
echo "unknown compose service: $service" >&2
return 2
;;
esac
}
build_compose_image() {
local service="$1" image cfhash oldhash containerfile
case "$service" in
comfyui)
image=comfyui-rocm:gfx1151
containerfile=dankclaw/Containerfile.comfyui
;;
loki|alloy)
image="franksdank/$service:latest"
containerfile="dankclaw/$service/Containerfile"
;;
*)
return 0
;;
esac
cfhash="$(sha256sum "$containerfile" | cut -d' ' -f1)"
oldhash="$(podman image inspect "$image" --format '{{index .Config.Labels "cfhash"}}' 2>/dev/null || echo none)"
if [[ "$oldhash" != "$cfhash" ]]; then
podman build --no-cache --label "cfhash=$cfhash" -t "$image" -f "$containerfile" .
fi
}
quadlet_unit_dir="$HOME/.config/containers/systemd"
install_quadlet() {
local service="$1"
local source_dir="$2"
local unit="$3"
mkdir -p "$quadlet_unit_dir"
cp "$source_dir/$unit.container" "$quadlet_unit_dir/"
if [[ -f "$source_dir/$unit.network" ]]; then
cp "$source_dir/$unit.network" "$quadlet_unit_dir/"
fi
systemctl --user daemon-reload
}
deploy_searxng() {
copy_shared_config
install_quadlet searxng dankclaw/searxng/quadlet searxng
install_quadlet searxng-valkey dankclaw/searxng/quadlet searxng-valkey
install_quadlet gluetun dankclaw/searxng/quadlet gluetun
# The network is shared by all three units; install it even when only the
# SearXNG service was selected. These copies are user-owned Quadlet state.
cp dankclaw/searxng/quadlet/searxng.network "$quadlet_unit_dir/"
systemctl --user daemon-reload
systemctl --user start searxng-network.service
systemctl --user restart gluetun.service searxng-valkey.service searxng.service
}
deploy_gluetun() {
install_quadlet gluetun dankclaw/searxng/quadlet gluetun
cp dankclaw/searxng/quadlet/searxng.network "$quadlet_unit_dir/"
systemctl --user daemon-reload
systemctl --user restart gluetun.service searxng.service
}
deploy_searxng_valkey() {
install_quadlet searxng-valkey dankclaw/searxng/quadlet searxng-valkey
cp dankclaw/searxng/quadlet/searxng.network "$quadlet_unit_dir/"
systemctl --user daemon-reload
systemctl --user restart searxng-valkey.service searxng.service
}
deploy_open_webui() {
install_quadlet open-webui dankclaw/open-webui/quadlet open-webui
mkdir -p /home/dank/Containers/open-webui/data
systemctl --user daemon-reload
systemctl --user restart open-webui.service
}
deploy_pihole() {
mkdir -p /home/dank/Containers/pihole/etc-pihole /home/dank/Containers/pihole/etc-dockers
if [[ ! -f /home/dank/Containers/pihole/env ]]; then
cp dankclaw/pihole/env.example /home/dank/Containers/pihole/env
echo "WARN: created Pi-hole env from the example; set WEBPASSWORD before using its UI" >&2
fi
cp dankclaw/pihole/addn-hosts /home/dank/Containers/pihole/addn-hosts
mkdir -p "$quadlet_unit_dir"
cp dankclaw/pihole/quadlet/pihole.container "$quadlet_unit_dir/"
systemctl --user daemon-reload
systemctl --user restart pihole.service
}
deploy_gitea_unit() {
local service="$1"
local unit="$2"
mkdir -p "$quadlet_unit_dir"
cp "dankclaw/gitea/quadlet/$unit.container" "$quadlet_unit_dir/"
[[ -f dankclaw/gitea/quadlet/gitea.network ]] && cp dankclaw/gitea/quadlet/gitea.network "$quadlet_unit_dir/"
systemctl --user daemon-reload
systemctl --user restart "$service.service"
}
relink_unit() {
local service="$1"
run_privileged ln -sfn "$REMOTE_DIR/scripts/$service.service" "/etc/systemd/system/$service.service"
}
deploy_llm_large() {
relink_unit frank-default-large
# NPU is intentionally not restarted here. It is a separate, retired
# endpoint and its unit is only relinked when explicitly selected.
run_privileged systemctl daemon-reload
run_privileged systemctl enable frank-default-large
run_privileged systemctl restart frank-default-large
wait_for_health frank-default-large http://127.0.0.1:7770/health 1200
}
deploy_llm_small() {
relink_unit frank-default-small
run_privileged systemctl daemon-reload
run_privileged systemctl enable frank-default-small
run_privileged systemctl restart frank-default-small
wait_for_health frank-default-small http://127.0.0.1:7770/health 1200
}
deploy_llm_npu() {
relink_unit frank-default-npu
run_privileged systemctl daemon-reload
echo "Relinked frank-default-npu; it was not restarted."
}
wait_for_health() {
local service="$1" url="$2" limit="$3"
local i
for ((i=1; i<=limit; i++)); do
if curl -fsS "$url" >/dev/null 2>&1; then
echo "$service healthy after approximately $((i * 2))s"
return 0
fi
sleep 2
done
echo "FAIL: $service did not become healthy in $((limit * 2))s" >&2
return 1
}
deploy_service() {
local service="$1"
case "$service" in
caddy) deploy_caddy ;;
homepage) deploy_homepage ;;
searxng) deploy_searxng ;;
gluetun) deploy_gluetun ;;
searxng-valkey) deploy_searxng_valkey ;;
open-webui) deploy_open_webui ;;
pihole) deploy_pihole ;;
gitea-postgres) deploy_gitea_unit gitea-postgres gitea-postgres ;;
gitea-server) deploy_gitea_unit gitea-server gitea-server ;;
gitea-caddy) deploy_gitea_unit gitea-caddy gitea-caddy ;;
frank-default-large) deploy_llm_large ;;
frank-default-small) deploy_llm_small ;;
frank-default-npu) deploy_llm_npu ;;
*) deploy_compose_service "$service" ;;
esac
}
if [[ "$PENDING" -eq 1 ]]; then
add_changed_services "$FROM_REVISION"
add_pending_services
fi
# Remove duplicate service names while preserving caller/planner order.
UNIQUE_SERVICES=()
declare -A SEEN_SERVICES=()
for service in "${SERVICES[@]}"; do
[[ -n "$service" ]] || continue
if [[ -z "${SEEN_SERVICES[$service]:-}" ]]; then
UNIQUE_SERVICES+=("$service")
SEEN_SERVICES[$service]=1
fi
done
SERVICES=("${UNIQUE_SERVICES[@]}")
if [[ "${#SERVICES[@]}" -eq 0 ]]; then
echo "No services selected; nothing to deploy."
exit 0
fi
for service in "${SERVICES[@]}"; do
[[ "$service" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "invalid service: $service" >&2; exit 2; }
service_is_allowed "$service" || { echo "service $service is not deployed on $TARGET" >&2; exit 2; }
echo "=== Deploying $service at $REVISION ==="
mark_status "$service" pending
deploy_service "$service"
mark_deployed "$service"
echo " STATE[$service] = $REVISION (status=deployed)"
done
+12 -93
View File
@@ -1,97 +1,16 @@
#!/usr/bin/env bash
# deploy-searxng-quadlet.sh — Install and (re)start the SearXNG quadlet stack.
#
# The gluetun + searxng + valkey trio is boot-persistent via Quadlet, not
# podman-compose (see AGENTS.md). This script:
# 1. Pushes main to origin
# 2. Pulls the repo on the target
# 3. Copies the quadlet units into ~/.config/containers/systemd/
# 4. daemon-reloads, enables the units for boot, and starts them in order
#
# It is idempotent: it stops any running units and removes any containers with
# the trio's names (compose leftovers or the previous quadlet run) before
# reinstalling and starting, so it is safe to run on first migration and on
# every later config change.
#
# Usage:
# ./scripts/deploy-searxng-quadlet.sh <target> [--dry-run]
# ./scripts/deploy-searxng-quadlet.sh dankclaw
#!/usr/bin/env bash
# Compatibility wrapper for the service-scoped deployment dispatcher.
# Use deploy.sh for Git push/pull and planning; this name remains for operators
# and runbooks that historically deployed the Quadlet trio directly.
set -euo pipefail
REMOTE_USER="dank"
REMOTE_HOST="dankclaw"
REMOTE_DIR="/home/dank/serverAppConfigs"
QDIR="dankclaw/searxng/quadlet"
UNITS="searxng-network.service gluetun.service searxng-valkey.service searxng.service"
TARGET="${1:-}"
DRY_RUN=0
if [ "${2:-}" = "--dry-run" ]; then
DRY_RUN=1
fi
if [ -z "${TARGET:-}" ]; then
echo "Usage: $0 <target> [--dry-run]"
exit 1
fi
if [ "${TARGET}" != "dankclaw" ]; then
echo "This stack runs only on dankclaw (got: ${TARGET})"
exit 1
fi
# ---------------------------------------------------------------------------
# Local pre-flight
# ---------------------------------------------------------------------------
cd "$(dirname "$0")/.."
if ! git diff --quiet HEAD; then
echo "FAIL: Local working tree has uncommitted changes. Commit first."
exit 1
fi
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would push main, pull on ${REMOTE_HOST}, install quadlet units, and start: ${UNITS}"
exit 0
fi
echo "=== Deploying SearXNG quadlet stack to ${REMOTE_HOST} ==="
echo "[1/3] Pushing local main to origin..."
git push origin main
REMOTE="${REMOTE_USER}@${REMOTE_HOST}"
echo "[2/3] Installing and starting the quadlet units on ${REMOTE_HOST}..."
ssh "$REMOTE" bash -s <<REMOTE_EOF
set -euo pipefail
cd '${REMOTE_DIR}'
echo ' Pulling repo...'
git pull origin main
echo ' Stopping any running units (idempotent)...'
systemctl --user stop searxng.service gluetun.service searxng-valkey.service searxng-network.service 2>/dev/null || true
echo ' Removing any containers with the trio names (compose leftovers or prior run)...'
podman rm -f searxng searxng-valkey gluetun 2>/dev/null || true
echo ' Copying quadlet units into ~/.config/containers/systemd/ ...'
mkdir -p ~/.config/containers/systemd
cp ${QDIR}/*.container ${QDIR}/*.network ~/.config/containers/systemd/
echo ' daemon-reload (generates the units)...'
# Quadlet units are generator-managed: systemctl enable fails on them ("transient
# or generated"). The [Install] WantedBy=default.target in each .container file is
# itself the enablement (is-enabled reports "generated"), and with user linger on
# they start at boot. The .network unit is pulled in by the containers that set
# Wants=/After=searxng-network.service. So: daemon-reload, then start.
systemctl --user daemon-reload
echo ' Starting in dependency order...'
systemctl --user start searxng-network.service
systemctl --user start gluetun.service searxng-valkey.service
systemctl --user start searxng.service
REMOTE_EOF
echo "[3/3] Verifying unit state..."
ssh "$REMOTE" 'systemctl --user --no-pager status searxng-network.service gluetun.service searxng-valkey.service searxng.service 2>&1 | grep -E "●|Active:"' || true
echo "=== SearXNG quadlet stack deployed to ${REMOTE_HOST} ==="
shift || true
case "$TARGET" in
dankclaw) exec "$(dirname "$0")/deploy.sh" dankclaw --service searxng "$@" ;;
*)
echo "This stack runs only on dankclaw (got: ${TARGET:-<missing>})" >&2
exit 2
;;
esac
+199 -289
View File
@@ -1,304 +1,214 @@
#!/usr/bin/env bash
# deploy.sh — Deploy the serverAppConfigs repo to a target and restart its
# podman stack and LLM fleet services.
# deploy.sh — push main, pull one target, and deploy only selected services.
#
# Implements the git deployment workflow from AGENTS.md. Assumes local
# changes are committed and pushed, then:
# 1. Pushes main to origin
# 2. Pulls the repo on the target
# 3. Copies shared configs into place and restarts the podman stack
# (dankclaw only; the dankserver media stack runs as bare containers)
# 4. Relinks the LLM systemd units and restarts the active LLM service
# 5. Waits for the Caddy homepage and the LLM health endpoint
#
# Sudo: the fleet hosts allow passwordless sudo for the deploy user. For
# headless runs on a host without that, set FRANK_SUDO_PW so remote sudo
# reads the password from stdin (`sudo -S`). The password is never stored
# in this repo.
# 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 <target> [--dry-run]
# ./scripts/deploy.sh <dankclaw|dankserver> [--service NAME]... [--dry-run]
#
# Targets:
# dankclaw - AI/search/dev/observability stack + frank-default-large
# (CIRU ROCmFPX llama.cpp, Qwen3.8-27B, port 7770)
# dankserver - media/library stack (bare containers) + frank-default-small
# (upstream llama.cpp, Qwen3.6-35B-A3B, port 7770)
#
# Example:
# ./scripts/deploy.sh dankclaw
# ./scripts/deploy.sh dankserver --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
# ---------------------------------------------------------------------------
# Target configuration
# ---------------------------------------------------------------------------
declare -A REMOTE_USER REMOTE_HOST REMOTE_DIR COMPOSE HEALTH_URL COMPOSE_UP COPY_SHARED
declare -A LLM_SERVICES LLM_UNITS LLM_HEALTH LLM_WAIT
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"
# Caddy is consolidated on dankclaw: it fronts both hosts' services. The
# dankserver media stack runs as bare containers (not this repo's compose),
# so its stack deploy is pull-only; health is checked against the shared Caddy.
REMOTE_USER[dankclaw]=dank
REMOTE_HOST[dankclaw]=dankclaw
REMOTE_DIR[dankclaw]="/home/dank/serverAppConfigs"
COMPOSE[dankclaw]="dankclaw/compose.yaml"
HEALTH_URL[dankclaw]="http://127.0.0.1/"
COMPOSE_UP[dankclaw]=1
COPY_SHARED[dankclaw]=1
# LLM: frank-default-large is the active service (restart + health wait).
# frank-default-npu is relinked only (not enabled on the host); its unit
# picks up the new paths at its next manual start.
LLM_SERVICES[dankclaw]="frank-default-large"
LLM_UNITS[dankclaw]="frank-default-large frank-default-npu"
LLM_HEALTH[dankclaw]="http://127.0.0.1:7770/health"
# CIRU llama.cpp (ROCm 7.15) loads the v3 five-file payload (GGUF + FFN +
# GDN + Output-K8 .pfs + mmproj, ~36.7 GiB) + MTP draft/Output-K8 proxy and
# initializes PromptForge views; allow 20 minutes.
LLM_WAIT[dankclaw]=600
usage() {
cat <<'EOF'
Usage: deploy.sh <dankclaw|dankserver> [options]
REMOTE_USER[dankserver]=dank
REMOTE_HOST[dankserver]=dankserver
REMOTE_DIR[dankserver]="/home/dank/serverAppConfigs"
COMPOSE[dankserver]="dankserver/compose.yaml"
HEALTH_URL[dankserver]="http://192.168.0.143/"
COMPOSE_UP[dankserver]=0
COPY_SHARED[dankserver]=0
# LLM: frank-default-small is the active service (restart + health wait).
LLM_SERVICES[dankserver]="frank-default-small"
LLM_UNITS[dankserver]="frank-default-small"
LLM_HEALTH[dankserver]="http://127.0.0.1:7770/health"
# Cold load: --no-mmap reads the 30.6 GB Q6_K_P through RAM (MoE experts on
# CPU); allow 20 minutes.
LLM_WAIT[dankserver]=600
Options:
--service NAME deploy only NAME (repeatable; bypasses automatic planning)
--dry-run show the plan without pushing or connecting to a target
-h, --help show this help
Examples:
./scripts/deploy.sh dankclaw
./scripts/deploy.sh dankclaw --service searxng
./scripts/deploy.sh dankserver --service frank-default-small
./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" ;;
*)
echo "Unknown target: $TARGET" >&2
usage >&2
exit 2
;;
esac
DRY_RUN=0
if [ "${2:-}" = "--dry-run" ]; then
DRY_RUN=1
fi
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
if [ -z "${TARGET:-}" ]; then
echo "Usage: $0 <dankclaw|dankserver> [--dry-run]"
exit 1
fi
if [ -z "${REMOTE_HOST[$TARGET]:-}" ]; then
echo "Unknown target: $TARGET"
exit 1
fi
# ---------------------------------------------------------------------------
# Sudo wrapper (headless-friendly)
# ---------------------------------------------------------------------------
SUDO="sudo"
if [ -n "${FRANK_SUDO_PW:-}" ]; then
SUDO="printf '%s\n' '$FRANK_SUDO_PW' | sudo -S"
fi
# ---------------------------------------------------------------------------
# Local checks (pre-flight)
# ---------------------------------------------------------------------------
cd "$(dirname "$0")/.."
if ! git diff --quiet HEAD; then
echo "FAIL: Local working tree has uncommitted changes."
echo "Commit or stash them before deploying."
exit 1
fi
if [ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then
echo "WARN: Not on 'main'; deploying a non-main branch."
fi
echo "=== Deploying to ${TARGET} ==="
echo " host: ${REMOTE_HOST[$TARGET]}"
echo " dir: ${REMOTE_DIR[$TARGET]}"
echo " compose: ${COMPOSE[$TARGET]}"
echo " llm svc: ${LLM_SERVICES[$TARGET]}"
echo " llm health: ${LLM_HEALTH[$TARGET]}"
echo " homepage: ${HEALTH_URL[$TARGET]}"
echo ""
# ---------------------------------------------------------------------------
# Push (unless dry-run)
# ---------------------------------------------------------------------------
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would push local main to origin"
echo "[dry-run] Would SSH to ${REMOTE_HOST[$TARGET]} and git pull"
else
echo "[1/5] Pushing local main to origin..."
git push origin main
fi
REMOTE="$(
printf '%s@%s' "${REMOTE_USER[$TARGET]}" "${REMOTE_HOST[$TARGET]}"
)"
# ---------------------------------------------------------------------------
# Remote: pull + copy shared configs + restart stack + restart LLM service
# ---------------------------------------------------------------------------
REMOTE_SCRIPT="
set -euo pipefail
cd '${REMOTE_DIR[$TARGET]}'
echo '[2/5] Pulling repo on ${TARGET}...'
git pull origin main
if [ -f .env ]; then
echo ' .env present, using it'
else
echo ' WARN: no .env on target; copy the .env.example and fill it in'
fi
if [ '${COPY_SHARED[$TARGET]}' = '1' ]; then
echo '[3/5] Copying shared configs into place...'
mkdir -p /opt/searxng/config /opt/prometheus/config /opt/loki/config /opt/alloy/config
${SUDO} cp shared/searxng/settings.yml /opt/searxng/config/settings.yml
${SUDO} cp shared/searxng/limiter.toml /opt/searxng/config/limiter.toml
${SUDO} cp shared/prometheus/prometheus.yml /opt/prometheus/config/prometheus.yml
${SUDO} cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml
${SUDO} cp shared/alloy/config.alloy /opt/alloy/config/config.alloy
echo ' Rendering Caddyfile (basicauth hash from .env)...'
# Extract the hash with grep/cut rather than sourcing .env: sourcing a
# bcrypt value in a set -u shell dies on its dollar-sign expansion.
# '|| true' so a missing hash warns below instead of aborting under set -e.
# Only the shared family hash remains: the homepage's per-user hashes were
# retired in favor of Authentik forward_auth (the /admin/ page and the user
# page are now behind the same SSO session).
CADDY_BASICAUTH_HASH=\"\$(grep -E '^CADDY_BASICAUTH_HASH=' .env 2>/dev/null | tail -1 | cut -d= -f2- || true)\"
if [ -n \"\${CADDY_BASICAUTH_HASH}\" ]; then
# '|' delimiter: the bcrypt hash contains '/' characters.
mkdir -p /home/dank/Containers/caddy
sed -e \"s|REPLACE_WITH_HASH|\${CADDY_BASICAUTH_HASH}|g\" \\
dankclaw/Caddyfile > /home/dank/Containers/caddy/Caddyfile
else
echo ' WARN: CADDY_BASICAUTH_HASH is not set in .env; keeping the placeholder(s)'
fi
echo ' Deploying the homepage...'
${SUDO} mkdir -p /srv/homepage
${SUDO} cp shared/homepage/index.html shared/homepage/admin.html shared/homepage/app.js \\
shared/homepage/style.css shared/homepage/services.json /srv/homepage/
if [ -d shared/homepage/fonts ]; then ${SUDO} cp -r shared/homepage/fonts /srv/homepage/; fi
# The last master deployment datetime (Q43): the deployed commit's datetime.
DEPLOYED_AT=\"\$(git log -1 --format=%cI)\"
printf 'window.DEPLOYED_AT = \"%s\";\\n' \"\$DEPLOYED_AT\" > /tmp/deployed.js
${SUDO} cp /tmp/deployed.js /srv/homepage/deployed.js
${SUDO} chmod -R a+rX /srv/homepage
# The status checker: install the timer + service, run it once, enable it.
${SUDO} cp scripts/homepage-status.py /usr/local/bin/homepage-status.py
${SUDO} cp scripts/homepage-status.service scripts/homepage-status.timer /etc/systemd/system/
${SUDO} systemctl daemon-reload
${SUDO} systemctl enable homepage-status.timer
${SUDO} systemctl restart homepage-status.timer
${SUDO} python3 /usr/local/bin/homepage-status.py /srv/homepage/services.json /srv/homepage/status.json
fi
if [ '${COMPOSE_UP[$TARGET]}' = '1' ]; then
echo '[4/5] Restarting stack...'
# Locally built images: caddy + porkbun module, and loki/alloy + a static
# busybox (their upstream images lack the wget the healthchecks need).
for dir in caddy loki alloy; do
img=franksdank/\$dir
[ \"\$dir\" = caddy ] && img=franksdank/caddy-porkbun
# Rebuild when the Containerfile changed. buildah's layer cache can
# serve a stale COPY --from layer (keyed on stage name), so a rebuild
# always runs with --no-cache.
cfhash=\$(sha256sum dankclaw/\$dir/Containerfile | cut -d' ' -f1)
oldhash=\$(podman image inspect \$img:latest --format '{{index .Config.Labels \"cfhash\"}}' 2>/dev/null || echo none)
if [ \"\$oldhash\" != \"\$cfhash\" ]; then
echo \" \$img image missing or stale; building it...\"
podman build --no-cache --label cfhash=\$cfhash -t \$img:latest -f dankclaw/\$dir/Containerfile .
fi
done
if ! podman-compose -f '${COMPOSE[$TARGET]}' up -d; then
echo ' WARN: compose restart failed; continuing with LLM deployment.' >&2
fi
# A Caddyfile render is a bind-mount content change, not a container
# config change: the caddy container often survives 'up -d' and keeps
# serving its in-memory config. Reload explicitly; the file watcher
# (admin on) is the backstop if the API is not up yet. If the reload
# cannot be applied at all (e.g. the running config has the admin API
# disabled -- the fix cannot ride the API it is meant to enable),
# restart the container so it reads the file on start.
caddy_reloaded=0
for i in 1 2 3 4 5; do
if podman exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1; then
caddy_reloaded=1
echo \" Caddy reloaded.\"
break
fi
sleep 3
done
if [ \"\$caddy_reloaded\" -ne 1 ]; then
echo \" Caddy reload failed; restarting the container to load the file.\"
podman restart caddy || true
fi
else
echo '[4/5] Skipping compose restart (stack runs as bare containers on this host)'
fi
echo '[4/5] Relinking LLM units and restarting LLM services...'
for unit in ${LLM_UNITS[$TARGET]}; do
# The loop variable must expand on the remote, so it is escaped here and
# the path is unquoted (it has no spaces). Quoting the source path would
# ship a literal loop-variable token and create a dangling symlink
# (the 2026-08-22 relink bug).
${SUDO} ln -sf ${REMOTE_DIR[$TARGET]}/scripts/\${unit}.service /etc/systemd/system/\${unit}.service
done
${SUDO} systemctl daemon-reload
for svc in ${LLM_SERVICES[$TARGET]}; do
${SUDO} systemctl enable \"\${svc}\"
${SUDO} systemctl restart \"\${svc}\"
echo \" restarted \${svc}\"
done
"
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Remote commands would run:"
echo "$REMOTE_SCRIPT"
else
# shellcheck disable=SC2087
ssh "$REMOTE" bash -s <<REMOTE_EOF
${REMOTE_SCRIPT}
REMOTE_EOF
fi
# ---------------------------------------------------------------------------
# Health checks: homepage, then the LLM endpoint
# ---------------------------------------------------------------------------
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would wait for health at ${HEALTH_URL[$TARGET]} and ${LLM_HEALTH[$TARGET]}"
else
echo "[5/5] Waiting for homepage ${HEALTH_URL[$TARGET]} (via ssh)..."
HEALTH="${HEALTH_URL[$TARGET]}"
for i in $(seq 1 60); do
if ssh "$REMOTE" "curl -sf '$HEALTH'" > /dev/null 2>&1; then
echo " Homepage healthy after approx $((i * 2))s"
break
fi
sleep 2
done
if ! ssh "$REMOTE" "curl -sf '$HEALTH'" > /dev/null 2>&1; then
echo "FAIL: Homepage did not become healthy in 120s"
exit 1
fi
echo "[5/5] Waiting for LLM health ${LLM_HEALTH[$TARGET]} (via ssh)..."
LLM_URL="${LLM_HEALTH[$TARGET]}"
LLM_ITERS="${LLM_WAIT[$TARGET]}"
for i in $(seq 1 "$LLM_ITERS"); do
if ssh "$REMOTE" "curl -sf '$LLM_URL'" > /dev/null 2>&1; then
echo " LLM healthy after approx $((i * 2))s"
echo ""
echo "=== Deploy complete: ${TARGET} ==="
EXPLICIT_SERVICES=()
while [[ "$#" -gt 0 ]]; do
case "$1" in
--dry-run)
DRY_RUN=1
shift
;;
--service)
[[ "$#" -ge 2 ]] || { echo "--service requires a name" >&2; exit 2; }
EXPLICIT_SERVICES+=("$2")
shift 2
;;
-h|--help)
usage
exit 0
fi
sleep 2
done
echo "FAIL: LLM service did not become healthy in $((LLM_ITERS * 2))s"
;;
*)
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:gluetun|dankclaw:searxng-network|dankclaw:searxng-valkey|dankclaw:searxng|\
dankclaw:pihole|dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\
dankclaw:gitea-caddy|dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\
dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:caddy|dankclaw:homepage|\
dankclaw:frank-default-large|dankclaw:frank-default-npu|\
dankserver:mariadb|dankserver:redis|dankserver:romm|dankserver:copyparty|\
dankserver:jellyfin|dankserver:calibre-web|dankserver:prowlarr|\
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|\
dankserver:frank-default-small)
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
REMOTE="${REMOTE_USER}@${REMOTE_HOST}"
REMOTE_ARGS=("$TARGET" "$HEAD_REVISION")
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'
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" bash -s -- "${REMOTE_ARGS[@]}" <<'REMOTE_EOF'
set -euo pipefail
TARGET="$1"
EXPECTED_REVISION="$2"
shift 2
cd "/home/dank/serverAppConfigs"
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 ./scripts/deploy-remote.sh "$TARGET" "$NEW_REVISION" "$@"
REMOTE_EOF
echo "=== Deploy complete: $TARGET ==="
+16 -2
View File
@@ -82,9 +82,11 @@ for path in "${CHANGED[@]}"; do
dankserver/compose.yaml|dankclaw/compose.yaml)
add_test tests/compose_validity.bats
add_test tests/env_consistency.bats
add_test tests/deployment_scoping.bats
;;
dankserver/Caddyfile|dankclaw/Caddyfile)
add_test tests/caddyfile.bats
add_test tests/deployment_scoping.bats
;;
dankserver/.env.example|dankclaw/.env.example)
add_test tests/env_consistency.bats
@@ -95,15 +97,23 @@ for path in "${CHANGED[@]}"; do
add_test tests/homepage_deploy.bats
add_test tests/homepage_pages.bats
add_test tests/homepage_checker.bats
add_test tests/deployment_scoping.bats
;;
shared/searxng/*)
add_test tests/searxng_limiter.bats
add_test tests/searxng_quadlet.bats
add_test tests/deployment_scoping.bats
;;
dankclaw/*.container|dankclaw/*.network)
add_test tests/open_webui_quadlet.bats
add_test tests/pihole.bats
add_test tests/searxng_quadlet.bats
add_test tests/deployment_scoping.bats
;;
scripts/deploy.sh|scripts/deploy-remote.sh|scripts/deploy-plan.py|scripts/deploy-*-quadlet.sh)
add_test tests/deployment_flow.bats
add_test tests/deployment_scoping.bats
add_test tests/scripts_syntax.bats
;;
scripts/mount-entropy.sh)
add_test tests/mount_entropy.bats
@@ -121,19 +131,23 @@ for path in "${CHANGED[@]}"; do
scripts/*.service)
add_test tests/llm_units.bats
add_test tests/scripts_syntax.bats
add_test tests/deployment_scoping.bats
;;
scripts/*)
add_test tests/scripts_syntax.bats
;;
docs/*)
add_test tests/docs_completeness.bats
add_test tests/deployment_scoping.bats
;;
dankclaw/hermes/*)
add_test tests/docs_completeness.bats
;;
AGENTS.md)
# The authoring rulebook; validate the docs vault whenever it changes.
README.md|AGENTS.md)
# Project guidance changes can affect the documented deployment
# contract; validate the docs vault and scoped deployment seams.
add_test tests/docs_completeness.bats
add_test tests/deployment_scoping.bats
;;
*)
die "no focused-test mapping for '$path'; add one before committing"