334 lines
15 KiB
Bash
Executable File
334 lines
15 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# To be run by user wlfb to create pod for the Finanzberatungs-Tool
|
|
# (API + Postgres + Grafana).
|
|
|
|
set -e
|
|
|
|
# Environment variables
|
|
POD_NAME='finance_pod'
|
|
DB_CTR_NAME='finance-db_ctr'
|
|
API_CTR_NAME='finance-api_ctr'
|
|
GRAFANA_CTR_NAME='finance-grafana_ctr'
|
|
|
|
# All image tags are pinned to fixed, immutable versions (no floating tags
|
|
# such as :latest, :3 or :16). Verified against the upstream registries on
|
|
# 2026-07-17 via `podman pull` (both tags exist as-is; no substitution
|
|
# needed).
|
|
POSTGRES_IMAGE='docker.io/library/postgres:17.10'
|
|
GRAFANA_IMAGE='docker.io/grafana/grafana-oss:12.1.0'
|
|
FINANCE_DIR="$HOME/bin/finance"
|
|
API_IMAGE="localhost/finance-api:$(cat "$FINANCE_DIR/VERSION")"
|
|
|
|
HOST_LOCAL_IP='127.0.0.1'
|
|
# Expose API on 8096 -> 8000
|
|
API_HOST_PORT='8096'
|
|
API_CONTAINER_PORT='8000'
|
|
# Expose Grafana on 8097 -> 3000
|
|
GRAFANA_HOST_PORT='8097'
|
|
GRAFANA_CONTAINER_PORT='3000'
|
|
# Postgres is only reachable inside the pod (localhost:5432 from API/Grafana)
|
|
|
|
BIND_DIR="$HOME/.local/share/$POD_NAME"
|
|
POSTGRES_DATA_DIR="$BIND_DIR/postgres-data"
|
|
GRAFANA_DATA_DIR="$BIND_DIR/grafana-data"
|
|
# inbox and uploads must live under one shared bind mount: process_pdf()
|
|
# moves a file from inbox to uploads via Path.replace() (os.replace/rename),
|
|
# which fails with EXDEV ("Invalid cross-device link") across two separate
|
|
# bind mounts even when both point at the same underlying host filesystem
|
|
# (Linux permits a filesystem to be mounted at multiple points, but rename()
|
|
# does not work across different mount points - see `man 2 rename`).
|
|
DATA_DIR="$BIND_DIR/data"
|
|
INBOX_DIR="$DATA_DIR/inbox"
|
|
UPLOADS_DIR="$DATA_DIR/uploads"
|
|
USER_SYSTEMD_DIR="$HOME/.config/systemd/user"
|
|
|
|
GRAFANA_PROVISIONING_DIR="$FINANCE_DIR/grafana/provisioning"
|
|
GRAFANA_DASHBOARDS_DIR="$FINANCE_DIR/grafana/dashboards"
|
|
|
|
# --- Secrets bootstrap -------------------------------------------------------
|
|
# Secrets live under BIND_DIR (not the code checkout in FINANCE_DIR), so a
|
|
# backup of BIND_DIR plus this script alone are sufficient for disaster
|
|
# recovery. BIND_DIR must exist and be private before .env is written into it.
|
|
mkdir -p "$BIND_DIR" && chmod 700 "$BIND_DIR"
|
|
|
|
ENV_FILE="$BIND_DIR/.env"
|
|
LEGACY_ENV="$HOME/bin/finance/.env"
|
|
if [ ! -f "$ENV_FILE" ] && [ -f "$LEGACY_ENV" ]; then
|
|
mv "$LEGACY_ENV" "$ENV_FILE" && chmod 600 "$ENV_FILE"
|
|
echo "MIGRIERT: .env nach $ENV_FILE verschoben"
|
|
fi
|
|
|
|
# On first run, generate all secrets into $ENV_FILE (gitignored) and print
|
|
# the shared GUI/Grafana login password once. On subsequent runs the
|
|
# existing .env is reused, so re-running this script never rotates secrets
|
|
# under existing data - except FB_GUI_PASSWORD_HASH, which is kept in sync
|
|
# with FB_PASSWORD on every run further below. That is what makes the
|
|
# password-change procedure a one-liner: edit FB_PASSWORD in $ENV_FILE, run
|
|
# this script again - the GUI hash (below) and Grafana's admin password
|
|
# (Grafana-sync retry loop, further down near the Grafana container) both
|
|
# follow automatically, with no separate hash-regeneration step.
|
|
if [ ! -f "$ENV_FILE" ]; then
|
|
# Compute all values into variables first, then write them SINGLE-QUOTED.
|
|
# The GUI password hash has the form salt$digest; if written unquoted, the
|
|
# later `set -a; . "$ENV_FILE"` would shell-expand the "$digest" part and
|
|
# corrupt the hash, breaking GUI login. Single quotes make every value a
|
|
# literal string on sourcing.
|
|
FB_PASSWORD_VAL=$(openssl rand -base64 12)
|
|
POSTGRES_PASSWORD_VAL=$(openssl rand -hex 16)
|
|
FINANCE_READ_PASSWORD_VAL=$(openssl rand -hex 16)
|
|
FB_API_KEY_VAL=$(openssl rand -hex 32)
|
|
FB_SESSION_SECRET_VAL=$(openssl rand -hex 32)
|
|
FB_GUI_PASSWORD_HASH_VAL=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$FB_PASSWORD_VAL")
|
|
cat > "$ENV_FILE" <<EOF
|
|
POSTGRES_PASSWORD='$POSTGRES_PASSWORD_VAL'
|
|
FINANCE_READ_PASSWORD='$FINANCE_READ_PASSWORD_VAL'
|
|
FB_API_KEY='$FB_API_KEY_VAL'
|
|
FB_SESSION_SECRET='$FB_SESSION_SECRET_VAL'
|
|
FB_GUI_USER='admin'
|
|
FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH_VAL'
|
|
FB_PASSWORD='$FB_PASSWORD_VAL'
|
|
EOF
|
|
chmod 600 "$ENV_FILE"
|
|
echo "NEU ERZEUGT: GUI-/Grafana-Login admin / $FB_PASSWORD_VAL (jetzt notieren!)"
|
|
elif grep -q '^FB_GUI_PASSWORD_HASH=' "$ENV_FILE" && ! grep -q '^FB_PASSWORD=' "$ENV_FILE"; then
|
|
# Migrate a pre-existing .env that predates the shared GUI/Grafana
|
|
# password (independent GUI hash + GRAFANA_ADMIN_PASSWORD) to one shared
|
|
# FB_PASSWORD. The old GUI password was only ever stored as a salted
|
|
# hash, so its plaintext cannot be recovered - a new password is
|
|
# unavoidable here. The stale hash and any GRAFANA_ADMIN_PASSWORD line
|
|
# (superseded by FB_PASSWORD) are dropped; all other keys (API key,
|
|
# session secret, DB passwords, ...) are left untouched.
|
|
FB_PASSWORD_VAL=$(openssl rand -base64 12)
|
|
FB_GUI_PASSWORD_HASH_VAL=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$FB_PASSWORD_VAL")
|
|
sed -i "/^FB_GUI_PASSWORD_HASH=/d; /^GRAFANA_ADMIN_PASSWORD=/d" "$ENV_FILE"
|
|
{
|
|
echo "FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH_VAL'"
|
|
echo "FB_PASSWORD='$FB_PASSWORD_VAL'"
|
|
} >> "$ENV_FILE"
|
|
chmod 600 "$ENV_FILE"
|
|
echo "MIGRATION: gemeinsames Passwort fuer GUI+Grafana eingefuehrt (altes GUI-Passwort war nur als Hash gespeichert und nicht rekonstruierbar)."
|
|
echo "MIGRATION: GUI-/Grafana-Login admin / $FB_PASSWORD_VAL (jetzt notieren!)"
|
|
fi
|
|
set -a; . "$ENV_FILE"; set +a
|
|
|
|
# Keep FB_GUI_PASSWORD_HASH in sync with FB_PASSWORD on every run (creation
|
|
# and migration above already produce a matching pair, so this is a no-op
|
|
# for them; it only rewrites $ENV_FILE when a user has edited FB_PASSWORD by
|
|
# hand since the last run - see the password-change procedure comment
|
|
# above).
|
|
if ! python3 -c "
|
|
import hashlib, sys
|
|
pw, stored = sys.argv[1], sys.argv[2]
|
|
salt, digest = stored.split('\$', 1)
|
|
sys.exit(0 if hashlib.pbkdf2_hmac('sha256', pw.encode(), salt.encode(), 200000).hex() == digest else 1)
|
|
" "$FB_PASSWORD" "$FB_GUI_PASSWORD_HASH" 2>/dev/null; then
|
|
FB_GUI_PASSWORD_HASH=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$FB_PASSWORD")
|
|
sed -i "s|^FB_GUI_PASSWORD_HASH=.*|FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH'|" "$ENV_FILE"
|
|
chmod 600 "$ENV_FILE"
|
|
echo "FB_PASSWORD-Aenderung erkannt: FB_GUI_PASSWORD_HASH in $ENV_FILE neu abgeleitet."
|
|
fi
|
|
|
|
# Sanity check: the GUI password hash must survive shell sourcing intact. An
|
|
# unquoted value containing a literal '$' gets mangled by parameter expansion,
|
|
# which would silently break GUI login. Require the canonical salt$digest form
|
|
# (32 hex chars, '$', 64 hex chars).
|
|
if ! printf '%s' "$FB_GUI_PASSWORD_HASH" | grep -Eq '^[0-9a-f]{32}\$[0-9a-f]{64}$'; then
|
|
echo "ERROR: FB_GUI_PASSWORD_HASH is malformed after sourcing $ENV_FILE." >&2
|
|
echo " All values in $ENV_FILE must be single-quoted." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Stop existing systemd-managed pod if present, to avoid conflicts on rerun
|
|
echo "Stopping systemd-managed pod 'pod-$POD_NAME.service' if it exists..."
|
|
if systemctl --user list-units --type=service --all 2>/dev/null | \
|
|
grep -q "pod-$POD_NAME.service"; then
|
|
systemctl --user stop "pod-$POD_NAME.service" || true
|
|
fi
|
|
|
|
# Prepare directories
|
|
mkdir -p "$POSTGRES_DATA_DIR" "$GRAFANA_DATA_DIR" "$INBOX_DIR" "$UPLOADS_DIR" \
|
|
"$USER_SYSTEMD_DIR"
|
|
|
|
# The Grafana image runs as a non-root user (uid 472) that belongs to group 0
|
|
# (root). Its data dir is bind-mounted from the host, where it is owned by the
|
|
# (rootless) container root. Make the dir group-writable with the setgid bit
|
|
# so the non-root Grafana process can write its sqlite db/plugins (same
|
|
# "arbitrary uid + gid 0" pattern as example_create_pod_langflow.sh's
|
|
# LANGFLOW_DATA_DIR fix).
|
|
chmod g+rwxs "$GRAFANA_DATA_DIR"
|
|
|
|
# Build the API image (Task 15 Containerfile)
|
|
podman build -t "$API_IMAGE" "$FINANCE_DIR"
|
|
|
|
# Create pod if not yet existing
|
|
if ! podman pod exists "$POD_NAME"; then
|
|
podman pod create -n "$POD_NAME" \
|
|
-p "$HOST_LOCAL_IP:$API_HOST_PORT:$API_CONTAINER_PORT" \
|
|
-p "$HOST_LOCAL_IP:$GRAFANA_HOST_PORT:$GRAFANA_CONTAINER_PORT"
|
|
echo "Pod '$POD_NAME' created (rc=$?)"
|
|
else
|
|
echo "Pod '$POD_NAME' already exists."
|
|
fi
|
|
|
|
# Remove any old containers (ignore errors if they don't exist)
|
|
podman rm -f "$DB_CTR_NAME" || true
|
|
podman rm -f "$API_CTR_NAME" || true
|
|
podman rm -f "$GRAFANA_CTR_NAME" || true
|
|
|
|
# Postgres container
|
|
podman run -d --name "$DB_CTR_NAME" --pod "$POD_NAME" \
|
|
-e POSTGRES_USER=finance \
|
|
-e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
|
|
-e POSTGRES_DB=finance \
|
|
-v "$POSTGRES_DATA_DIR:/var/lib/postgresql/data:Z" \
|
|
"$POSTGRES_IMAGE"
|
|
echo "Container '$DB_CTR_NAME' started (rc=$?)"
|
|
|
|
# Wait for Postgres to be ready before starting API / Grafana
|
|
echo "Waiting for Postgres to be ready (pg_isready)..."
|
|
for attempt in $(seq 1 30); do
|
|
if podman exec "$DB_CTR_NAME" pg_isready -q >/dev/null 2>&1; then
|
|
echo "Postgres is ready."
|
|
break
|
|
fi
|
|
sleep 2
|
|
if [ "$attempt" -eq 30 ]; then
|
|
echo "ERROR: Postgres did not become ready in time." >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
# Create the read-only role used by the Grafana datasource, idempotently.
|
|
echo "Ensuring read-only role 'finance_read' exists..."
|
|
podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \
|
|
"DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='finance_read')
|
|
THEN CREATE ROLE finance_read LOGIN PASSWORD '$FINANCE_READ_PASSWORD'; END IF; END \$\$;
|
|
GRANT CONNECT ON DATABASE finance TO finance_read;
|
|
GRANT USAGE ON SCHEMA public TO finance_read;
|
|
GRANT SELECT ON ALL TABLES IN SCHEMA public TO finance_read;
|
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO finance_read;"
|
|
echo "Role 'finance_read' is ready."
|
|
|
|
# API container (runs alembic upgrade head on start via entrypoint.sh)
|
|
podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \
|
|
-e FB_DATABASE_URL="postgresql+psycopg://finance:$POSTGRES_PASSWORD@localhost:5432/finance" \
|
|
-e FB_API_KEY \
|
|
-e FB_SESSION_SECRET \
|
|
-e FB_GUI_USER \
|
|
-e FB_GUI_PASSWORD_HASH \
|
|
-e FB_INBOX_DIR=/data/inbox \
|
|
-e FB_UPLOADS_DIR=/data/uploads \
|
|
-v "$DATA_DIR:/data:Z" \
|
|
"$API_IMAGE"
|
|
echo "Container '$API_CTR_NAME' started (rc=$?)"
|
|
|
|
# Grafana container (provisioning + dashboards mounted read-only; datasource
|
|
# yaml interpolates FINANCE_READ_PASSWORD). GF_SECURITY_ALLOW_EMBEDDING
|
|
# permits the GUI's iframe to embed dashboards; anonymous access stays OFF
|
|
# (no GF_AUTH_ANONYMOUS_* vars are set) - only the iframe X-Frame-Options
|
|
# restriction is relaxed, authentication is unaffected.
|
|
podman run -d --name "$GRAFANA_CTR_NAME" --pod "$POD_NAME" \
|
|
-e GF_SECURITY_ADMIN_PASSWORD="$FB_PASSWORD" \
|
|
-e GF_SECURITY_ALLOW_EMBEDDING=true \
|
|
-e FINANCE_READ_PASSWORD \
|
|
-v "$GRAFANA_PROVISIONING_DIR:/etc/grafana/provisioning:Z,ro" \
|
|
-v "$GRAFANA_DASHBOARDS_DIR:/var/lib/grafana/dashboards:Z,ro" \
|
|
-v "$GRAFANA_DATA_DIR:/var/lib/grafana:Z" \
|
|
"$GRAFANA_IMAGE"
|
|
echo "Container '$GRAFANA_CTR_NAME' started (rc=$?)"
|
|
|
|
# GF_SECURITY_ADMIN_PASSWORD above only takes effect on a FRESH Grafana data
|
|
# dir (initial admin bootstrap); an existing install keeps its previously
|
|
# set password. So sync Grafana's admin password to FB_PASSWORD explicitly
|
|
# on every script run via the CLI (idempotent: resetting to the same
|
|
# password is harmless). grafana-oss:12.1.0 ships the unified `grafana`
|
|
# binary, which exposes this as `grafana cli admin reset-admin-password`
|
|
# (verified in-image: `grafana cli admin reset-admin-password --help`
|
|
# succeeds); the legacy standalone `grafana-cli` binary is kept as a
|
|
# fallback in case of a differently-built image. Retry like the pg_isready
|
|
# wait above, since Grafana needs a moment after container start to
|
|
# initialize its SQLite db before the CLI can operate on it. This runs
|
|
# against the just-started container (before the stop/recreate-via-systemd
|
|
# further below); the result persists because GRAFANA_DATA_DIR is a
|
|
# bind-mounted directory shared with the systemd-managed restart.
|
|
echo "Syncing Grafana admin password with FB_PASSWORD..."
|
|
for attempt in $(seq 1 30); do
|
|
if podman exec "$GRAFANA_CTR_NAME" grafana cli admin reset-admin-password "$FB_PASSWORD" \
|
|
>/dev/null 2>&1; then
|
|
echo "Grafana admin password synced (grafana cli)."
|
|
break
|
|
fi
|
|
if podman exec "$GRAFANA_CTR_NAME" grafana-cli admin reset-admin-password "$FB_PASSWORD" \
|
|
>/dev/null 2>&1; then
|
|
echo "Grafana admin password synced (grafana-cli fallback)."
|
|
break
|
|
fi
|
|
sleep 2
|
|
if [ "$attempt" -eq 30 ]; then
|
|
echo "ERROR: could not sync Grafana admin password in time." >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
# Generate systemd service files
|
|
cd "$USER_SYSTEMD_DIR"
|
|
podman generate systemd --name --new --files "$POD_NAME"
|
|
echo "Generated systemd service files (rc=$?)"
|
|
|
|
# The --new units embed the full `podman run` commands, including container
|
|
# environment secrets (POSTGRES_PASSWORD, GF_SECURITY_ADMIN_PASSWORD,
|
|
# FB_GUI_PASSWORD_HASH, ...). Post-process each generated unit file:
|
|
# 1. chmod 600 so only the owner can read the embedded secrets.
|
|
# 2. Collapse '$$' -> '$' on Environment= lines. `podman generate systemd`
|
|
# escapes every '$' as '$$' (correct for ExecStart command lines, where
|
|
# systemd performs variable expansion). But systemd does NOT un-escape
|
|
# '$$' inside Environment= *values* - it passes them through literally.
|
|
# The GUI password hash has the form salt$digest, so without this fix the
|
|
# container receives salt$$digest and GUI login fails. Restrict the
|
|
# substitution to Environment= lines so ExecStart escaping stays intact.
|
|
for svc in "pod-${POD_NAME}.service" "container-${DB_CTR_NAME}.service" \
|
|
"container-${API_CTR_NAME}.service" "container-${GRAFANA_CTR_NAME}.service"; do
|
|
[ -f "$svc" ] || continue
|
|
chmod 600 "$svc"
|
|
sed -i '/^Environment=/ s/\$\$/$/g' "$svc"
|
|
done
|
|
|
|
# Stop & remove live pod and containers
|
|
podman pod stop --ignore --time 15 "$POD_NAME"
|
|
podman pod rm -f --ignore "$POD_NAME"
|
|
if podman pod exists "$POD_NAME"; then
|
|
echo "ERROR: Pod $POD_NAME still exists." >&2
|
|
exit 1
|
|
else
|
|
echo "Stopped & removed live pod $POD_NAME and containers."
|
|
fi
|
|
|
|
# Enable systemd user services
|
|
systemctl --user daemon-reload
|
|
# pod service (creates pod + containers)
|
|
systemctl --user enable --now "pod-${POD_NAME}.service"
|
|
systemctl --user is-enabled "pod-${POD_NAME}.service"
|
|
systemctl --user is-active "pod-${POD_NAME}.service"
|
|
echo "Enabled systemd service pod-${POD_NAME}.service (rc=$?)"
|
|
echo "To view status: systemctl --user status pod-${POD_NAME}.service"
|
|
echo "To view logs: journalctl --user -u pod-${POD_NAME}.service -f"
|
|
|
|
# Wait for API and Grafana readiness
|
|
CHECK_URL_API="http://$HOST_LOCAL_IP:$API_HOST_PORT/login"
|
|
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT/api/health"
|
|
for attempt in $(seq 1 30); do
|
|
API_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_API" || true)
|
|
GRAFANA_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_GRAFANA" || true)
|
|
if [ "$API_CODE" = "200" ] && [ "$GRAFANA_CODE" = "200" ]; then
|
|
echo "API is reachable at $CHECK_URL_API (200)."
|
|
echo "Grafana is reachable at $CHECK_URL_GRAFANA (200)."
|
|
break
|
|
fi
|
|
sleep 2
|
|
if [ "$attempt" -eq 30 ]; then
|
|
echo "timeout error (API=$API_CODE, Grafana=$GRAFANA_CODE)." >&2
|
|
exit 1
|
|
fi
|
|
done
|