mv of a data root onto a non-empty directory fails (Directory not empty). The romm/copyparty cutover creates Containers/<svc>/ before migrating: the seed env file lands there first, and the romm anonymous-volume rescue extracts into it before the /opt move. Merge the old root's entries in without overwriting existing state (mv -n), warn and leave residue if a collision kept the new side, and keep the plain rename when the destination is absent or empty.
865 lines
35 KiB
Bash
Executable File
865 lines
35 KiB
Bash
Executable File
#!/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=()
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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:comfyui|dankclaw:hermes|\
|
|
dankclaw:hermes-dashboard|dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|\
|
|
dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|\
|
|
dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
|
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:anubis|\
|
|
dankclaw:caddy|dankclaw:homepage|\
|
|
dankclaw:frank-default-large|dankclaw:frank-default-npu|\
|
|
dankclaw:frank-default-sightsound|\
|
|
dankserver:romm-mariadb|dankserver:romm-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=pending
|
|
if [[ -r "$STATE_DIR/$service" ]]; then
|
|
while IFS= read -r line; do
|
|
case "$line" in
|
|
status=*) value="${line#status=}" ;;
|
|
esac
|
|
done < "$STATE_DIR/$service"
|
|
fi
|
|
printf '%s' "$value"
|
|
}
|
|
|
|
register_changed_services() {
|
|
local base="$1" service recorded status
|
|
[[ -n "$base" ]] || return 0
|
|
while IFS= read -r service; do
|
|
[[ -n "$service" ]] || continue
|
|
recorded="$(state_revision "$service")"
|
|
status="$(state_status "$service")"
|
|
if [[ "$status" != deployed || "$recorded" != "$REVISION" ]]; then
|
|
mark_status "$service" pending
|
|
fi
|
|
done < <(python3 scripts/deploy-plan.py --target "$TARGET" --repo "$REMOTE_DIR" \
|
|
--base "$base" --head "$REVISION")
|
|
}
|
|
|
|
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 status changed
|
|
for state_file in "$STATE_DIR"/*; do
|
|
[[ -f "$state_file" ]] || continue
|
|
service="${state_file##*/}"
|
|
recorded="$(state_revision "$service")"
|
|
status="$(state_status "$service")"
|
|
if [[ "$status" != deployed ]]; then
|
|
# A pending service was intentionally skipped or its handler
|
|
# failed. Retry it even when the checkout has not advanced.
|
|
SERVICES+=("$service")
|
|
continue
|
|
fi
|
|
[[ -n "$recorded" && "$recorded" != "$REVISION" ]] || 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_searxng_config() {
|
|
run_privileged mkdir -p /opt/searxng/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
|
|
}
|
|
|
|
copy_prometheus_config() {
|
|
run_privileged mkdir -p /opt/prometheus/config
|
|
run_privileged cp shared/prometheus/prometheus.yml /opt/prometheus/config/prometheus.yml
|
|
}
|
|
|
|
copy_loki_config() {
|
|
run_privileged mkdir -p /opt/loki/config
|
|
run_privileged cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml
|
|
}
|
|
|
|
copy_alloy_config() {
|
|
run_privileged mkdir -p /opt/alloy/config
|
|
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() {
|
|
if [[ "$TARGET" != dankclaw ]]; then
|
|
echo "homepage is owned by dankclaw; refusing to deploy it on $TARGET" >&2
|
|
return 2
|
|
fi
|
|
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
|
|
}
|
|
|
|
stage_legacy_gitea_caddy_state() {
|
|
local legacy_state="$STATE_DIR/gitea-caddy"
|
|
[[ -e "$legacy_state" ]] || return 0
|
|
local legacy_trash="$HOME/.AGENTTRASH/$(date +%Y%m%d)-gitea-caddy"
|
|
mkdir -p "$legacy_trash"
|
|
mv -- "$legacy_state" "$legacy_trash/gitea-caddy.state"
|
|
}
|
|
|
|
retire_legacy_gitea_caddy() {
|
|
# The old split proxy owns ports 80/443 on hosts upgraded in place. Stop
|
|
# and disable its generated unit before the canonical Caddy is started.
|
|
if systemctl --user is-active --quiet gitea-caddy.service 2>/dev/null; then
|
|
systemctl --user stop gitea-caddy.service
|
|
fi
|
|
if systemctl --user is-enabled --quiet gitea-caddy.service 2>/dev/null; then
|
|
# A Quadlet-generated unit may report "generated" without having an
|
|
# enablement symlink. Disable is therefore best-effort; moving the
|
|
# source out of the generator directory is the durable action.
|
|
systemctl --user disable gitea-caddy.service 2>/dev/null || true
|
|
fi
|
|
local unit_dir="$HOME/.config/containers/systemd"
|
|
local legacy_trash="$HOME/.AGENTTRASH/$(date +%Y%m%d)-gitea-caddy"
|
|
# Podman generators scan the unit directory recursively on this host, so
|
|
# leaving the retired file in systemd/.AGENTTRASH would regenerate it.
|
|
# Stage it outside the unit tree in the dated home trash instead.
|
|
for unit_file in "$unit_dir/gitea-caddy.container" \
|
|
"$unit_dir/.AGENTTRASH/gitea-caddy.container"; do
|
|
if [[ -e "$unit_file" ]]; then
|
|
mkdir -p "$legacy_trash"
|
|
mv -- "$unit_file" "$legacy_trash/"
|
|
fi
|
|
done
|
|
stage_legacy_gitea_caddy_state
|
|
systemctl --user daemon-reload
|
|
if podman container exists gitea-caddy; then
|
|
podman rm --force gitea-caddy
|
|
fi
|
|
}
|
|
|
|
deploy_caddy() {
|
|
if [[ "$TARGET" != dankclaw ]]; then
|
|
echo "caddy is owned by dankclaw; refusing to deploy it on $TARGET" >&2
|
|
return 2
|
|
fi
|
|
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
|
|
# Retire the old split proxy before claiming ports 80/443. This is
|
|
# idempotent and leaves the old unit recoverable in the target trash.
|
|
retire_legacy_gitea_caddy
|
|
# Caddy's env: Porkbun DNS-01 credentials from the checkout's .env. The
|
|
# live file holds secrets, so it exists only on the host; the quadlet
|
|
# reads it via EnvironmentFile.
|
|
mkdir -p /home/dank/Containers/caddy
|
|
if [[ ! -f /home/dank/Containers/caddy/env ]]; then
|
|
awk -F= '/^PORKBUN_API_KEY=|^PORKBUN_API_SECRET_KEY=/ { print }' \
|
|
dankclaw/.env > /home/dank/Containers/caddy/env
|
|
fi
|
|
local var
|
|
for var in PORKBUN_API_KEY PORKBUN_API_SECRET_KEY; do
|
|
grep -q "^${var}=." /home/dank/Containers/caddy/env \
|
|
|| { echo "caddy env missing ${var}; set it in dankclaw/.env" >&2; return 1; }
|
|
done
|
|
# Retire the bare compose container: all state (Caddyfile, certs, config)
|
|
# lives in host volumes, so removing the container loses nothing.
|
|
podman stop caddy >/dev/null 2>&1 || true
|
|
podman rm caddy >/dev/null 2>&1 || true
|
|
# install_quadlet runs daemon-reload, which re-runs podman's quadlet
|
|
# generator: it creates caddy.service AND the default.target.wants symlink
|
|
# from the unit's [Install] section (is-enabled reports "generated" —
|
|
# that is the healthy state for a quadlet; an explicit systemctl enable
|
|
# fails on generated units). Boot persistence comes from the generator,
|
|
# same as pihole/gitea/searxng/open-webui.
|
|
install_quadlet caddy dankclaw/caddy/quadlet caddy
|
|
systemctl --user restart caddy.service
|
|
if podman container exists caddy; then
|
|
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
|
|
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|hermes-webui|prometheus|grafana|loki|alloy|node-exporter)
|
|
case "$service" in
|
|
comfyui|loki|alloy)
|
|
build_compose_image "$service"
|
|
;;
|
|
prometheus)
|
|
copy_prometheus_config
|
|
;;
|
|
loki)
|
|
copy_loki_config
|
|
;;
|
|
alloy)
|
|
copy_alloy_config
|
|
;;
|
|
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_searxng_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() {
|
|
# Rootless port 53 needs net.ipv4.ip_unprivileged_port_start <= 53, and it
|
|
# must hold at boot. The repo drop-in is the single source of truth.
|
|
# systemd-sysctl applies /etc/sysctl.d/*.conf (lexicographic), then
|
|
# /run/sysctl.d, /usr/lib/sysctl.d, and finally /etc/sysctl.conf — later
|
|
# files override earlier ones. On 2026-08-26 a later-sorted drop-in (=80)
|
|
# AND a stale line in /etc/sysctl.conf (=80) both reset the value after a
|
|
# reboot and took LAN DNS down. Neutralize every conflicting source:
|
|
# stage sysctl.d files that set a value above 53 in the home trash, and
|
|
# comment out an active line in /etc/sysctl.conf (idempotent).
|
|
run_privileged install -m 0644 dankclaw/pihole/sysctl/99-dnsmasq.conf \
|
|
/etc/sysctl.d/99-dnsmasq.conf
|
|
local sysctl_file sysctl_val sysctl_trash
|
|
while IFS= read -r sysctl_file; do
|
|
[[ "$sysctl_file" == */99-dnsmasq.conf ]] && continue
|
|
sysctl_val="$(sed -nE 's/^[[:space:]]*net\.ipv4\.ip_unprivileged_port_start[[:space:]]*=[[:space:]]*([0-9]+).*/\1/p' "$sysctl_file" | head -1)"
|
|
if [[ "$sysctl_val" =~ ^[0-9]+$ ]] && (( sysctl_val > 53 )); then
|
|
sysctl_trash="$HOME/.AGENTTRASH/$(date +%Y%m%d)-sysctl-unprivileged-port"
|
|
mkdir -p "$sysctl_trash"
|
|
run_privileged mv "$sysctl_file" "$sysctl_trash/"
|
|
fi
|
|
done < <(grep -lE '^[[:space:]]*net\.ipv4\.ip_unprivileged_port_start[[:space:]]*=' /etc/sysctl.d/*.conf 2>/dev/null || true)
|
|
if [[ -f /etc/sysctl.conf ]] && grep -qE '^[[:space:]]*net\.ipv4\.ip_unprivileged_port_start[[:space:]]*=' /etc/sysctl.conf; then
|
|
run_privileged sed -i -E 's/^[[:space:]]*(net\.ipv4\.ip_unprivileged_port_start[[:space:]]*=)/# \1 managed by dankclaw\/pihole\/sysctl\/99-dnsmasq.conf/' /etc/sysctl.conf
|
|
fi
|
|
run_privileged sysctl --system >/dev/null
|
|
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"
|
|
}
|
|
|
|
# Authentik runs as three boot-persistent Quadlet units (postgres -> server ->
|
|
# worker). The trio deploys as one unit: unit files are installed, stale
|
|
# compose-era containers holding the claimed names are retired, then the chain
|
|
# starts in dependency order with health gates between stages. Re-running the
|
|
# handler on an already-active stack is a deliberate no-op (no DB bounce).
|
|
wait_container_healthy() {
|
|
local name="$1" limit="$2" status="unknown"
|
|
for ((i = 1; i <= limit; i += 5)); do
|
|
status="$(podman inspect -f '{{.State.Health.Status}}' "$name" 2>/dev/null || echo missing)"
|
|
[[ "$status" == "healthy" ]] && {
|
|
echo "$name healthy after approximately ${i}s"
|
|
return 0
|
|
}
|
|
sleep 5
|
|
done
|
|
echo "FAIL: $name did not become healthy in ${limit}s (status: $status)" >&2
|
|
return 1
|
|
}
|
|
|
|
# --- dankserver media/library stack ----------------------------------------------
|
|
# These services migrated from compose-era bare containers to boot-persistent
|
|
# Quadlet units installed from this repo (podman 5.x v1 generator honors the
|
|
# WantedBy=default.target inside each .container file). The first deploy of a
|
|
# service retires the old container and migrates the data root before starting
|
|
# the unit; re-running the handler on an already-active unit restarts only the
|
|
# selected unit.
|
|
|
|
migrate_data_root() { # $1 = old /opt path, $2 = new data-root path (same fs: rename/merge)
|
|
local from="$1" to="$2" entry
|
|
[[ -d "$from" ]] || return 0
|
|
if [[ -d "$to" && -n "$(ls -A "$to" 2>/dev/null)" ]]; then
|
|
# The destination already holds seeded state (env file, rescued
|
|
# anonymous-volume data). Merge the old root into it without ever
|
|
# overwriting an existing entry, then drop the emptied source.
|
|
shopt -s dotglob
|
|
for entry in "$from"/*; do
|
|
[[ -e "$entry" ]] || continue
|
|
mv -n -- "$entry" "$to/"
|
|
done
|
|
shopt -u dotglob
|
|
rmdir -- "$from" 2>/dev/null || \
|
|
echo "WARN: $from not empty after merge (collisions kept old side); left in place" >&2
|
|
else
|
|
mv -- "$from" "$to"
|
|
fi
|
|
chown -R "$(id -u):$(id -g)" "$to"
|
|
}
|
|
|
|
deploy_romm_stack() {
|
|
local service="$1" unit vol tmp
|
|
case "$service" in
|
|
mariadb) service=romm-mariadb ;;
|
|
redis) service=romm-redis ;;
|
|
esac
|
|
for unit in romm-mariadb romm-redis romm; do
|
|
cp "dankserver/romm/quadlet/$unit.container" "$quadlet_unit_dir/"
|
|
done
|
|
systemctl --user daemon-reload
|
|
if ! systemctl --user is-active --quiet romm-mariadb.service; then
|
|
# First cutover: the env file must exist before the first start, or
|
|
# the units would boot with empty secrets. Fail before stopping anything.
|
|
[[ -f /home/dank/Containers/romm/env ]] || {
|
|
echo "FAIL: /home/dank/Containers/romm/env does not exist; seed it before the first romm deploy" >&2
|
|
return 1
|
|
}
|
|
# Capture anonymous volume names while the containers are still inspectable,
|
|
# then stop and rm the compose-era trio.
|
|
local vols
|
|
vols="$(podman inspect -f '{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}} {{end}}{{end}}' romm 2>/dev/null || true)"
|
|
podman stop -t 60 romm-mariadb romm-redis romm || true
|
|
podman rm romm-mariadb romm-redis romm || true
|
|
for vol in $vols; do
|
|
tmp="$(mktemp -d)"
|
|
podman volume export --output "$tmp/v.tar" "$vol"
|
|
# --skip-old-files keeps live bind-mounted content (config/, library/,
|
|
# resources/, mariadb/, redis/) and rescues only unshadowed entries like assets/.
|
|
tar --skip-old-files -C /home/dank/Containers/romm -xf "$tmp/v.tar"
|
|
rm -rf -- "$tmp"
|
|
done
|
|
migrate_data_root /opt/romm /home/dank/Containers/romm
|
|
systemctl --user start romm-mariadb.service
|
|
wait_container_healthy romm-mariadb 120 || return 1
|
|
systemctl --user start romm-redis.service
|
|
wait_container_healthy romm-redis 60 || return 1
|
|
if [[ "$service" == "romm" ]]; then
|
|
systemctl --user start romm.service
|
|
fi
|
|
return 0
|
|
fi
|
|
systemctl --user restart "$service.service"
|
|
}
|
|
|
|
deploy_jellyfin() {
|
|
cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/"
|
|
systemctl --user daemon-reload
|
|
if ! systemctl --user is-active -q jellyfin.service; then
|
|
podman stop -t 30 jellyfin || true
|
|
podman rm jellyfin || true
|
|
migrate_data_root /opt/jellyfin /home/dank/Containers/jellyfin
|
|
fi
|
|
systemctl --user restart jellyfin.service
|
|
}
|
|
|
|
deploy_calibre_web() {
|
|
cp "dankserver/calibre-web/quadlet/calibre-web.container" "$quadlet_unit_dir/"
|
|
systemctl --user daemon-reload
|
|
if ! systemctl --user is-active -q calibre-web.service; then
|
|
# The compose-era container ran under a podman-generate-systemd unit; stop
|
|
# that unit first so its Restart=always cannot resurrect the old container
|
|
# and collide with the new ContainerName.
|
|
systemctl --user stop container-calibre-web.service 2>/dev/null || true
|
|
podman stop calibre-web || true
|
|
podman rm calibre-web || true
|
|
fi
|
|
systemctl --user restart calibre-web.service
|
|
}
|
|
|
|
deploy_copyparty() {
|
|
# The admin account is created from ${COPAY_ACCOUNT}:${COPAY_PASSWORD} expanded
|
|
# at ExecStart time; a missing env file must fail the unit, not boot copyparty
|
|
# with a garbage admin password.
|
|
[[ -f /home/dank/Containers/copyparty/env ]] || {
|
|
echo "FAIL: /home/dank/Containers/copyparty/env does not exist; seed it before the first copyparty deploy" >&2
|
|
return 1
|
|
}
|
|
cp "dankserver/copyparty/quadlet/copyparty.container" "$quadlet_unit_dir/"
|
|
systemctl --user daemon-reload
|
|
if ! systemctl --user is-active -q copyparty.service; then
|
|
podman stop -t 15 copyparty || true
|
|
podman rm copyparty || true
|
|
migrate_data_root /opt/copyparty /home/dank/Containers/copyparty
|
|
fi
|
|
systemctl --user restart copyparty.service
|
|
}
|
|
|
|
deploy_authentik() {
|
|
local unit
|
|
mkdir -p "$quadlet_unit_dir"
|
|
for unit in authentik-postgres authentik-server authentik-worker; do
|
|
cp "dankclaw/authentik/quadlet/$unit.container" "$quadlet_unit_dir/"
|
|
done
|
|
systemctl --user daemon-reload
|
|
# Retire compose-era containers only while the units are not yet active;
|
|
# unit-managed containers self-heal via Restart=always.
|
|
if ! systemctl --user is-active --quiet authentik-postgres.service; then
|
|
podman stop authentik-postgres authentik-server authentik-worker 2>/dev/null || true
|
|
podman rm authentik-postgres authentik-server authentik-worker 2>/dev/null || true
|
|
fi
|
|
# No `systemctl --user enable` here: this host's quadlet generator
|
|
# (podman 5.x v1) generates units into /run/user/1000/systemd/generator/,
|
|
# and generated units cannot be enabled — enable fails with "transient or
|
|
# generated". Boot persistence comes from [Install] WantedBy=default.target
|
|
# inside each .container file, which the generator honors at user-manager
|
|
# start (linger must stay on for the deploy user).
|
|
systemctl --user start authentik-postgres.service
|
|
wait_container_healthy authentik-postgres 120 || return 1
|
|
systemctl --user start authentik-server.service
|
|
wait_container_healthy authentik-server 300 || return 1
|
|
systemctl --user start authentik-worker.service
|
|
}
|
|
|
|
deploy_authentik_outpost() {
|
|
# The outpost is the worker-role binary of the same server image; it dials
|
|
# back to core over websocket and serves /outpost.goauthentik.io/* for
|
|
# Caddy's forward_auth. Image already in storage — zero pulls. Restarting
|
|
# only this unit cycles auth for guarded vhosts for a few seconds; the
|
|
# rest of the stack is untouched.
|
|
cp dankclaw/authentik/quadlet/authentik-outpost.container "$quadlet_unit_dir/"
|
|
systemctl --user daemon-reload
|
|
systemctl --user restart authentik-outpost.service
|
|
sleep 5
|
|
systemctl --user is-active --quiet authentik-outpost.service || \
|
|
echo "WARN: authentik-outpost did not come up active" >&2
|
|
}
|
|
|
|
deploy_hermes() {
|
|
# The hermes trio (gateway, dashboard, webui) shares one image and the
|
|
# /data volume and deploys as one unit: install the units, retire the
|
|
# compose-era containers holding the claimed names while the units are not
|
|
# yet active, then restart each unit. An explicit hermes deploy is a
|
|
# deliberate action, so restarting an already-running trio is intended.
|
|
# Boot persistence comes from [Install] in each unit file (generated units
|
|
# cannot be enabled by this host's v1 quadlet generator).
|
|
local unit
|
|
mkdir -p "$quadlet_unit_dir"
|
|
# The CDP browser daemon is a companion service to the trio: install its
|
|
# unit and start it if inactive, but never restart it on deploy — a
|
|
# restart would kill in-flight browser sessions. Consumers (gateway,
|
|
# webui) reconnect with backoff.
|
|
cp "dankclaw/hermes/quadlet/chrome-devtools-protocol.container" "$quadlet_unit_dir/"
|
|
systemctl --user daemon-reload
|
|
if ! systemctl --user is-active --quiet chrome-devtools-protocol.service; then
|
|
systemctl --user start chrome-devtools-protocol.service
|
|
fi
|
|
# The units run the image under a localhost/-aliased tag: this host's
|
|
# quadlet path can fail to resolve registry-prefixed refs from local
|
|
# storage at create time. Tag first, then (re)create via deploy.
|
|
podman tag docker.io/nousresearch/hermes-agent:latest localhost/hermes-agent:latest 2>/dev/null || true
|
|
for unit in hermes hermes-dashboard hermes-webui; do
|
|
cp "dankclaw/hermes/quadlet/$unit.container" "$quadlet_unit_dir/"
|
|
done
|
|
# The generator must see the freshly-copied unit files before the
|
|
# restarts below; without this reload the first deploy after a unit edit
|
|
# restarts with the stale generated ExecStart (statfs ENOENT on renamed
|
|
# Volume= sources).
|
|
systemctl --user daemon-reload
|
|
if ! systemctl --user is-active --quiet hermes.service; then
|
|
podman stop hermes hermes-dashboard hermes-webui 2>/dev/null || true
|
|
podman rm hermes hermes-dashboard hermes-webui 2>/dev/null || true
|
|
fi
|
|
systemctl --user restart hermes.service
|
|
systemctl --user restart hermes-dashboard.service
|
|
systemctl --user restart hermes-webui.service
|
|
}
|
|
|
|
deploy_anubis() {
|
|
# Anubis is the bot-challenge proxy in front of the guarded vhosts.
|
|
# The image lives on ghcr.io; pull once, then the unit runs it from
|
|
# local storage (Pull=never keeps boots off the registry).
|
|
if ! podman image exists ghcr.io/techarohq/anubis:v1.13.0; then
|
|
podman pull ghcr.io/techarohq/anubis:v1.13.0
|
|
fi
|
|
# The unit runs the localhost/ alias (see the unit file comment): this
|
|
# host's podman quadlet path cannot resolve registry-prefixed refs from
|
|
# local storage at create time. Tag first, then (re)create via deploy.
|
|
podman tag ghcr.io/techarohq/anubis:v1.13.0 localhost/techarohq/anubis:v1.13.0 2>/dev/null || true
|
|
cp "dankclaw/anubis/quadlet/anubis.container" "$quadlet_unit_dir/"
|
|
systemctl --user daemon-reload
|
|
# No enable: generated units cannot be enabled on this host (v1 quadlet
|
|
# generator); boot persistence comes from [Install] in the unit file.
|
|
# restart, not start: policy.json is parsed at process start only (no
|
|
# reload signal exists), so every redeploy must cycle the process.
|
|
systemctl --user restart anubis.service
|
|
wait_container_healthy anubis 60 || return 1
|
|
}
|
|
|
|
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."
|
|
}
|
|
|
|
# frank-default-sightsound is a live serving endpoint (FLM on the XDNA2 NPU),
|
|
# not a relink-only unit: it is enabled at boot and restarted on deploy. The
|
|
# health gate polls /v1/version, FLM's cheapest always-on route (there is no
|
|
# /health route in the FLM server).
|
|
deploy_llm_sightsound() {
|
|
relink_unit frank-default-sightsound
|
|
run_privileged systemctl daemon-reload
|
|
run_privileged systemctl enable frank-default-sightsound
|
|
run_privileged systemctl restart frank-default-sightsound
|
|
wait_for_health frank-default-sightsound http://127.0.0.1:7771/v1/version 300
|
|
}
|
|
|
|
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 ;;
|
|
authentik-postgres|authentik-server|authentik-worker)
|
|
deploy_authentik
|
|
;;
|
|
authentik-outpost)
|
|
deploy_authentik_outpost
|
|
;;
|
|
anubis) deploy_anubis ;;
|
|
frank-default-large) deploy_llm_large ;;
|
|
frank-default-small) deploy_llm_small ;;
|
|
frank-default-npu) deploy_llm_npu ;;
|
|
frank-default-sightsound) deploy_llm_sightsound ;;
|
|
hermes|hermes-dashboard|hermes-webui) deploy_hermes ;;
|
|
romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;;
|
|
jellyfin) deploy_jellyfin ;;
|
|
calibre-web) deploy_calibre_web ;;
|
|
copyparty) deploy_copyparty ;;
|
|
*) deploy_compose_service "$service" ;;
|
|
esac
|
|
}
|
|
|
|
if [[ -n "$FROM_REVISION" ]]; then
|
|
# Register every changed service before applying the requested scope. This
|
|
# preserves an explicit --service selection as pending work for the next
|
|
# ordinary deployment.
|
|
register_changed_services "$FROM_REVISION"
|
|
fi
|
|
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
|