#!/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). For # disaster recovery you need BOTH: a backup of BIND_DIR (data + secrets) AND # the repo checkout ~/bin (this script, finance/ for the image build, Grafana # provisioning). BIND_DIR must exist and be private before .env is written. 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" <> "$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) # The extra "-v $ENV_FILE:/data/.env:Z" below (Ausbaustufe 4 Task 2, Admin # password change) is a SINGLE-FILE bind mount. Unlike a directory mount, # this follows the host file's INODE: the app must read/write it in place # (open r+/truncate under flock - see app/services/admin.py), NEVER via # temp-file+rename, because a rename would swap in a new inode that the # already-running mount no longer points at. 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" \ -v "$ENV_FILE:/data/.env: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 GF_SECURITY_COOKIE_SAMESITE=lax \ -e GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/finanzen.json \ -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