Files
fuomag9 96bac86934 Fix L4 port manager failing to recreate caddy after Docker restart
The sidecar's `docker compose up` command lacked `--pull never`, so
Docker Compose would attempt to pull the caddy image from ghcr.io when
the local image was missing or stale. Since the sidecar has no registry
credentials this failed with 403 Forbidden.

Closes #117

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:35:12 +02:00

168 lines
6.0 KiB
Bash
Executable File

#!/bin/sh
#
# L4 Port Manager Sidecar
#
# On startup: always applies the current L4 ports override so the caddy
# container has the correct ports bound (the main compose stack starts caddy
# without the L4 ports override file).
#
# During runtime: watches the trigger file for changes and re-applies when
# the web app signals that port configuration has changed.
#
# Only ever recreates the caddy container — never touches any other service.
#
# Environment variables:
# DATA_DIR - Path to shared data volume (default: /data)
# COMPOSE_DIR - Path to compose files (default: /compose)
# CADDY_CONTAINER_NAME - Caddy container name for project auto-detection (default: caddy-proxy-manager-caddy)
# COMPOSE_PROJECT_NAME - Override compose project name (auto-detected from caddy container labels if unset)
# POLL_INTERVAL - Seconds between trigger file checks (default: 2)
set -e
DATA_DIR="${DATA_DIR:-/data}"
COMPOSE_DIR="${COMPOSE_DIR:-/compose}"
POLL_INTERVAL="${POLL_INTERVAL:-2}"
CADDY_CONTAINER_NAME="${CADDY_CONTAINER_NAME:-caddy-proxy-manager-caddy}"
TRIGGER_FILE="$DATA_DIR/l4-ports.trigger"
STATUS_FILE="$DATA_DIR/l4-ports.status"
OVERRIDE_FILE="$DATA_DIR/docker-compose.l4-ports.yml"
log() {
echo "[l4-port-manager] $(date -u '+%Y-%m-%dT%H:%M:%SZ') $*"
}
write_status() {
state="$1"
message="$2"
applied_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
error="${3:-}"
cat > "$STATUS_FILE" <<STATUSEOF
{
"state": "$state",
"message": "$message",
"appliedAt": "$applied_at"$([ -n "$error" ] && echo ",
\"error\": \"$error\"" || echo "")
}
STATUSEOF
}
# Auto-detect the Docker Compose project name from the running caddy container's labels.
# This ensures we operate on the correct project regardless of where compose files are mounted.
detect_project_name() {
if [ -n "$COMPOSE_PROJECT_NAME" ]; then
echo "$COMPOSE_PROJECT_NAME"
return
fi
detected=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$CADDY_CONTAINER_NAME" 2>/dev/null || echo "")
if [ -n "$detected" ]; then
echo "$detected"
else
echo "caddy-proxy-manager"
fi
}
# Apply the current port override — recreates only the caddy container.
do_apply() {
COMPOSE_PROJECT="$(detect_project_name)"
log "Using compose project: $COMPOSE_PROJECT"
# Build compose args. Files are read from COMPOSE_DIR (container path).
# COMPOSE_HOST_DIR (when set) is passed as --project-directory so the Docker
# daemon resolves relative bind-mount paths (e.g. ./geoip-data) against the
# actual host project directory rather than the sidecar's /compose mount.
COMPOSE_ARGS="-p $COMPOSE_PROJECT"
if [ -n "$COMPOSE_HOST_DIR" ]; then
COMPOSE_ARGS="$COMPOSE_ARGS --project-directory $COMPOSE_HOST_DIR"
fi
# Explicitly supply the .env file so required variables are available even
# when --project-directory points to a host path not mounted in the sidecar.
if [ -f "$COMPOSE_DIR/.env" ]; then
COMPOSE_ARGS="$COMPOSE_ARGS --env-file $COMPOSE_DIR/.env"
fi
COMPOSE_ARGS="$COMPOSE_ARGS -f $COMPOSE_DIR/docker-compose.yml"
if [ -f "$COMPOSE_DIR/docker-compose.override.yml" ]; then
COMPOSE_ARGS="$COMPOSE_ARGS -f $COMPOSE_DIR/docker-compose.override.yml"
fi
if [ -f "$OVERRIDE_FILE" ]; then
COMPOSE_ARGS="$COMPOSE_ARGS -f $OVERRIDE_FILE"
fi
write_status "applying" "Recreating caddy container with updated ports..."
# shellcheck disable=SC2086
if docker compose $COMPOSE_ARGS up -d --no-deps --pull never --force-recreate caddy 2>&1; then
log "Caddy container recreated successfully."
# Wait for caddy healthcheck to pass
HEALTH_TIMEOUT=30
HEALTH_WAITED=0
HEALTH="unknown"
while [ "$HEALTH_WAITED" -lt "$HEALTH_TIMEOUT" ]; do
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' "$CADDY_CONTAINER_NAME" 2>/dev/null || echo "unknown")
if [ "$HEALTH" = "healthy" ]; then
break
fi
sleep 1
HEALTH_WAITED=$((HEALTH_WAITED + 1))
done
if [ "$HEALTH" = "healthy" ]; then
write_status "applied" "Caddy container recreated and healthy with updated ports."
log "Caddy is healthy."
else
write_status "applied" "Caddy container recreated but health check status: $HEALTH (may still be starting)."
log "Warning: Caddy health status is '$HEALTH' after ${HEALTH_TIMEOUT}s."
fi
else
ERROR_MSG="Failed to recreate caddy container. Check Docker logs."
write_status "failed" "$ERROR_MSG" "$ERROR_MSG"
log "ERROR: $ERROR_MSG"
fi
# Delete the trigger file after processing so stale triggers don't cause
# "Waiting for port manager sidecar..." on the next boot.
rm -f "$TRIGGER_FILE"
}
# ---------------------------------------------------------------------------
# Startup: always apply the override so caddy has the correct ports bound.
# (The main compose stack starts caddy without the L4 ports override file.)
# Only apply if the override file exists — it is created on first "Apply Ports".
# ---------------------------------------------------------------------------
if [ -f "$OVERRIDE_FILE" ]; then
log "Startup: applying existing L4 port override..."
do_apply
else
write_status "idle" "Port manager sidecar is running and ready."
log "Started. No L4 port override file yet."
fi
# Capture the current trigger content so the poll loop doesn't re-apply
# a trigger that was already handled (either above or before this boot).
# Use explicit assignment — do NOT use ${VAR:-fallback} which treats empty as unset.
LAST_TRIGGER=$(cat "$TRIGGER_FILE" 2>/dev/null || echo "")
log "Watching $TRIGGER_FILE for changes (poll every ${POLL_INTERVAL}s)"
while true; do
sleep "$POLL_INTERVAL"
CURRENT_TRIGGER=$(cat "$TRIGGER_FILE" 2>/dev/null || echo "")
if [ "$CURRENT_TRIGGER" = "$LAST_TRIGGER" ]; then
continue
fi
# Empty trigger means the file was just deleted — nothing to do.
if [ -z "$CURRENT_TRIGGER" ]; then
LAST_TRIGGER=""
continue
fi
LAST_TRIGGER="$CURRENT_TRIGGER"
log "Trigger changed. Applying port changes..."
do_apply
done