diff --git a/red-queen-architecture/control-plane/cp2a2_2b/rq-historical b/red-queen-architecture/control-plane/cp2a2_2b/rq-historical index 0266e33..69273f3 100644 --- a/red-queen-architecture/control-plane/cp2a2_2b/rq-historical +++ b/red-queen-architecture/control-plane/cp2a2_2b/rq-historical @@ -1,42 +1,44 @@ #!/bin/bash # ============================================================================ # rq-historical — eng begrenzter root-Wrapper fuer Red Queen (rq-host) -# RED-QUEEN-VPS-HOST-ACCESS + PHASE-13.5-READONLY | Rain Ocampo (Hermes) | 2026-08-29 +# RED-QUEEN-VPS-HOST-ACCESS + PHASE-13.5-READONLY + DEV/TEST/DEPLOY-CHANNEL +# | Rain Ocampo (Hermes) | 2026-08-29 # -# EINZIGER ZWECK: kontrollierter READ-ONLY-Zugriff + freigegebener Backfill- -# Start ausschliesslich auf den Historical-Service. Least-Privilege. +# LEAST-PRIVILEGE-Design: +# - rq-host hat KEINE docker-Gruppe, KEINEN Docker-Socket, KEIN sudo ALL, +# KEINE Root-Shell. Einziger Root-Kanal: NOPASSWD:/usr/local/bin/rq-historical +# - Dieser Wrapper ist der EINZIGE Kanal zu historical-service. Er laeuft als +# root (via sudoers), erzwingt aber fuer Dev/Test/Deploy strikte Fixierung: +# * feste Staging-Roots, feste Allowlist (KEY->Staging-Datei->Containerpfad) +# * keine freien Pfade, keine ../, kein Symlink-Escape, kein Mount-Arg +# * automatisches Backup + Healthcheck + Hash-Reverify vor/nach Deploy +# * deploy != execution: nie automatischer Backfill/Runner/Watchdog +# - Deploy-Methode: atomic docker cp der validierten Stage-Datei in den +# laufenden Container (Container ist beschreibbar, ReadonlyRootfs=false). +# Rebuild via compose-build NICHT freigegeben (wuerde rq-host Docker-Build +# + Container-Recreation geben -> Punkt 2/10-Verletzung). +# - Test: py_compile der Stage-Kopie in Container-/tmp (keine Mutation des +# laufenden Produktivcodes). # -# SICHERHEIT: -# - Container-/Skript-Namen sind FEST im Script (kein Benutzer-Argument -# fuer Container/Skript/Pfad). -# - Alle Subkommandos akzeptieren NUR strikt validierte, vollstaendig -# konsumierte Argumente; unbekannte/ueberzaehlige Argumente -> ERR. -# - code/hashes/code-list nutzen eine FESTE Allowlist: User uebergibt nur -# einen Key, der intern auf einen festen Containerpfad gemappt wird. -# KEIN frei durchgereichter Pfad, KEIN Shell-String aus User-Input. -# - Argumente werden als argv-Array an `docker exec` uebergeben -# (KEINE Shell-Interpolation von Benutzereingaben). -# - KEIN sudo ALL / NOPASSWD ALL / keine Root-Shell / kein docker run. -# -# AUFRUF (ueber /etc/sudoers.d/rq-historical, NUR als root): -# sudo rq-historical ps -# sudo rq-historical inspect -# sudo rq-historical logs [--tail N] -# sudo rq-historical run [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--limit-days N] -# sudo rq-historical code -# sudo rq-historical hashes -# sudo rq-historical code-list +# SUBKOMMANDOS: +# ps | inspect | logs [--tail N] | run [--start --end --limit-days] (bestehend) +# code | hashes | code-list (bestehend, read-only) +# stage-dir (zeigt Staging-Pfade) +# stage-check (validiert Staging-Kandidat) +# test (py_compile in /tmp, isoliert) +# search-writes (fixe Write-Schreibpfad-Suche) +# healthcheck (/health + /health/ready 200) +# backup (aktuellen Container-Code sichern) +# deploy (Backup->stage-check->test->cp->reverify->health) +# rollback (zurueck zum letzten Backup) # ============================================================================ set -euo pipefail CONTAINER=historical-service -# ------------------------------------------------------------------ Allowlist -# FESTE Mapping-Tabelle (Key -> Containerpfad). Read-only, hart im Script. -# Keys NICHT benutzerdefiniert erweiterbar. -# NOTE: Bei assign-assoc-array die Assignments OHNE Leerzeichen um '=' schreiben, -# sonst Syntaxfehler. (Korrektur unten: einzeilig mit declare -A init.) -declare -A CODE_FILES=( +# --------------------------------------------------------- Allowlist (fest) +# KEY -> Container-Zielpfad (identisch mit 'code'-Read-Allowlist) +declare -A TARGET_PATHS=( [runner]="/app/app/run_backfill_year.py" [backfill]="/app/app/backfill.py" [pipeline]="/app/app/pipeline_v2.py" @@ -47,46 +49,73 @@ declare -A CODE_FILES=( [trust_gate]="/app/app/trust/trust_gate.py" ) +# KEY -> Staging-Dateiname (fest, NUR Dateiname im Staging-Root) +declare -A STAGE_FILES=( + [runner]="run_backfill_year.py" + [backfill]="backfill.py" + [pipeline]="pipeline_v2.py" + [repository]="repository.py" + [provenance]="provenance.py" + [trust]="trust.py" + [sanity]="sanity.py" + [trust_gate]="trust_gate.py" +) + +STAGING_ROOT=/opt/rq-historical-staging +BACKUP_ROOT=/opt/rq-historical-backups + # Relevanter Pfad fuer den 'run'-Subbefehl (Backfill-Entrypoint) SCRIPT=/app/app/run_backfill_year.py -# ---------------------------------------------------------------- Validierung +# ------------------------------------------------------------ Validierung die() { echo "ERR: $*" >&2; exit 2; } req_ok() { docker inspect -f '{{.State.Running}}' "$CONTAINER" >/dev/null 2>&1 \ || die "Container '$CONTAINER' nicht vorhanden oder nicht am Laufen" } +key_path() { echo "${TARGET_PATHS[$1]:-}"; } # $1=key -> Containerpfad +stage_file() { echo "$STAGING_ROOT/${STAGE_FILES[$1]:-}"; } # $1=key -> abs Staging-Pfad +key_ok() { # $1=key; exit 2 wenn unbekannt + [ -n "${TARGET_PATHS[$1]:-}" ] && [ -n "${STAGE_FILES[$1]:-}" ] || die "unbekannter KEY '$1' (code-list)" +} +expect_zero() { [ $# -eq 0 ] || die "unerlaubtes Argument '$*'"; } -# -------------------------------------------------------------------- dispatch +# stage-check: FAIL-CLOSED-Validierung eines Staging-Kandidaten +validate_stage() { # $1=key + local key="$1" file real owner dmode + file="$(stage_file "$key")" + [ -e "$file" ] || die "Staging-Kandidat fehlt: $file (lege zuerst die Datei ab)" + real="$(realpath -m "$file")" + [[ "$real" == "$STAGING_ROOT/"* ]] || die "Traversal blockiert: realpath '$real' ausserhalb Staging" + [ "$real" = "$file" ] || die "Alias erkannt (realpath != Pfad): $file" + [ -f "$file" ] || die "keine reguläre Datei: $file" + [ -L "$file" ] && die "Symlink nicht erlaubt: $file" + owner="$(stat -c '%U' "$file")" + [ "$owner" = "rq-host" ] || die "Staging-Kandidat Owner = $owner (erwartet rq-host)" + dmode="$(stat -c '%a' "$STAGING_ROOT")" + [[ "$dmode" != *002 ]] && [[ "$dmode" != *022 ]] || die "Staging-Root global schreibbar '$dmode' (FAIL CLOSED)" + echo "OK: $key -> $file (regular, no symlink, owner rq-host, in staging)" +} + +# ---------------------------------------------------- dispatch SUBCMD="${1:-}" shift || true case "$SUBCMD" in - ps) - [ $# -eq 0 ] || die "unerlaubtes Argument 'ps $*'" - exec docker ps - ;; - inspect) - req_ok - [ $# -eq 0 ] || die "unerlaubtes Argument 'inspect $*'" - exec docker inspect "$CONTAINER" - ;; - logs) - req_ok + # ---------------- bestehend (read-only / run) ---------------- + ps) expect_zero "$@"; exec docker ps ;; + inspect) req_ok; expect_zero "$@"; exec docker inspect "$CONTAINER" ;; + logs) req_ok tail_n=200 while [ $# -gt 0 ]; do case "$1" in - --tail) - [ $# -ge 2 ] || die "--tail braucht Wert" - [[ "$2" =~ ^[0-9]+$ ]] || die "ungueltiges --tail" - tail_n="$2"; shift 2 ;; + --tail) [ $# -ge 2 ] || die "--tail braucht Wert"; [[ "$2" =~ ^[0-9]+$ ]] || die "ungueltiges --tail"; tail_n="$2"; shift 2 ;; *) die "unerlaubtes Argument 'logs $1'" ;; esac done exec docker logs --tail "$tail_n" "$CONTAINER" ;; - run) - req_ok + run) req_ok declare -a RUN=() while [ $# -gt 0 ]; do case "$1" in @@ -99,34 +128,122 @@ case "$SUBCMD" in echo "[rq-historical] Start: docker exec $CONTAINER python $SCRIPT ${RUN[*]}" >&2 exec docker exec "$CONTAINER" python "$SCRIPT" "${RUN[@]}" ;; - code) - # Fester Allowlist-Key -> fester Containerpfad. KEIN frei gereichter Pfad. - req_ok - [ $# -eq 1 ] || die "code braucht genau EINEN Key (siehe code-list)" - key="$1" - path="${CODE_FILES[$key]:-}" - [ -n "$path" ] || die "unbekannter Key 'code $key' (siehe code-list)" - exec docker exec "$CONTAINER" cat "$path" + # ------------- read-only Code ------------- + code) req_ok + [ $# -eq 1 ] || die "code braucht genau EINEN Key (code-list)" + p="$(key_path "$1")"; [ -n "$p" ] || die "unbekannter Key 'code $1'" + exec docker exec "$CONTAINER" cat "$p" ;; - hashes) - # SHA256 der festen Allowlist-Dateien aus dem laufenden Container. - req_ok - [ $# -eq 0 ] || die "unerlaubtes Argument 'hashes $*'" - # Feste, vollstaendig konsumierte Dateiliste -> argv-Array (keine Interpolation). - declare -a PATHS=() - for k in "${!CODE_FILES[@]}"; do PATHS+=("${CODE_FILES[$k]}"); done + hashes) req_ok; expect_zero "$@" + PATHS=(); for k in "${!TARGET_PATHS[@]}"; do PATHS+=("${TARGET_PATHS[$k]}"); done exec docker exec "$CONTAINER" sha256sum "${PATHS[@]}" ;; - code-list) - # Nur die verfuegbaren fixen Keys + Containerpfade anzeigen. Kein FS-Zugriff. - req_ok - [ $# -eq 0 ] || die "unerlaubtes Argument 'code-list $*'" + code-list) req_ok; expect_zero "$@" echo "Verfuegbare READ-ONLY-Code-Keys (historical-service):" - for k in "${!CODE_FILES[@]}"; do - printf ' %-11s -> %s\n' "$k" "${CODE_FILES[$k]}" + for k in "${!TARGET_PATHS[@]}"; do printf ' %-11s | staging: %-22s -> %s\n' "$k" "${STAGE_FILES[$k]}" "${TARGET_PATHS[$k]}"; done + ;; + # ------------- dev/test/deploy-kanal ------------- + stage-dir) expect_zero "$@" + echo "Staging-Root: $STAGING_ROOT (Owner rq-host, keine globalen Schreibrechte)" + for k in runner backfill pipeline repository provenance trust sanity trust_gate; do + printf ' %-11s %s\n' "$k" "$(stage_file "$k")" done ;; - *) - die "Aufruf: rq-historical {ps|inspect|logs [--tail N]|run [--start --end --limit-days]|code |hashes|code-list}" + stage-check) [ $# -eq 1 ] || die "stage-check braucht genau EINEN Key"; key_ok "$1" + validate_stage "$1" + ;; + test) req_ok; [ $# -eq 1 ] || die "test braucht genau EINEN Key"; key_ok "$1" + validate_stage "$1" + file="$(stage_file "$1")" + tmp="/tmp/rqtest_${1}_$(date +%s).py" + docker cp "$file" "$CONTAINER:$tmp" + if docker exec "$CONTAINER" python -m py_compile "$tmp" >/dev/null 2>&1; then + echo "PASS: py_compile OK ($file) [isoliert in Container-/tmp, Produktivcode unberuehrt]" + else + echo "FAIL: py_compile fehlgeschlagen ($file)" + docker exec "$CONTAINER" rm -f "$tmp" + exit 2 + fi + docker exec "$CONTAINER" rm -f "$tmp" + ;; + search-writes) req_ok; expect_zero "$@" + docker exec "$CONTAINER" sh -c 'grep -rnE "insert_raw_bars|insert_derived_bars|BackfillOrchestrator|trust_gate|TrustGate" /app/app --include="*.py" 2>/dev/null | head -100 || true' + ;; + healthcheck) req_ok; expect_zero "$@" + rh="$(docker exec "$CONTAINER" python -c 'import urllib.request; print(urllib.request.urlopen("http://localhost:8000/health", timeout=5).status)' 2>/dev/null || echo N/A)" + rr="$(docker exec "$CONTAINER" python -c 'import urllib.request; print(urllib.request.urlopen("http://localhost:8000/health/ready", timeout=5).status)' 2>/dev/null || echo N/A)" + running="$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" + echo "Container.Running=$running /health=$rh /health/ready=$rr" + [ "$running" = "true" ] && [ "$rh" = "200" ] && [ "$rr" = "200" ] || { echo "HEALTHCHECK FAILED"; exit 2; } + echo "HEALTHCHECK OK" + ;; + backup) req_ok; [ $# -eq 1 ] || die "backup braucht genau EINEN Key"; key_ok "$1" + p="$(key_path "$1")" + ts="$(date +%Y%m%d_%H%M%S)" + bdir="$BACKUP_ROOT/$1/$ts" + mkdir -p "$bdir" + docker exec "$CONTAINER" cat "$p" > "$bdir/$(basename "$p")" + sha256sum "$bdir/$(basename "$p")" > "$bdir/SHA256" + echo "BACKUP: $1 -> $bdir" + cat "$bdir/SHA256" + ;; + deploy) req_ok; [ $# -eq 1 ] || die "deploy braucht genau EINEN Key"; key_ok "$1" + key="$1"; p="$(key_path "$key")"; file="$(stage_file "$key")" + # 1) validate (FAIL CLOSED) + validate_stage "$key" + # 2) backup erzwingen (muss gelingen) + ts="$(date +%Y%m%d_%H%M%S)"; bdir="$BACKUP_ROOT/$key/$ts" + mkdir -p "$bdir" + docker exec "$CONTAINER" cat "$p" > "$bdir/$(basename "$p")" || die "Backup fehlgeschlagen -> deploy abgebrochen" + sha256sum "$bdir/$(basename "$p")" > "$bdir/SHA256" + echo "BACKUP: $bdir" + # 3) test (py_compile isoliert) + tmp="/tmp/rqdeploy_${key}_$(date +%s).py" + docker cp "$file" "$CONTAINER:$tmp" + if ! docker exec "$CONTAINER" python -m py_compile "$tmp" >/dev/null 2>&1; then + docker exec "$CONTAINER" rm -f "$tmp" + die "py_compile FAIL -> deploy abgebrochen (nichts deployt, kein Rollback noetig)" + fi + docker exec "$CONTAINER" rm -f "$tmp" + # 4) atomic deploy + docker cp "$file" "$CONTAINER:$p" + # 5) hash-reverify + sha_stage="$(sha256sum "$file" | awk '{print $1}')" + sha_deployed="$(docker exec "$CONTAINER" sha256sum "$p" | awk '{print $1}')" + echo "SHA stage : $sha_stage" + echo "SHA deployed: $sha_deployed" + if [ "$sha_stage" != "$sha_deployed" ]; then + echo "HASH-MISMATCH -> rollback" + docker cp "$bdir/$(basename "$p")" "$CONTAINER:$p" + die "Deploy Hash-Mismatch, zurueckgerollt" + fi + # 6) healthcheck (nur health/ready, KEIN run) + rh="$(docker exec "$CONTAINER" python -c 'import urllib.request; print(urllib.request.urlopen("http://localhost:8000/health", timeout=5).status)' 2>/dev/null || echo N/A)" + rr="$(docker exec "$CONTAINER" python -c 'import urllib.request; print(urllib.request.urlopen("http://localhost:8000/health/ready", timeout=5).status)' 2>/dev/null || echo N/A)" + echo "Health: /health=$rh /health/ready=$rr" + if [ "$rh" != "200" ] || [ "$rr" != "200" ]; then + echo "DEPLOY-HEALTHCHECK FAILED -> rollback" + docker cp "$bdir/$(basename "$p")" "$CONTAINER:$p" + die "Rollback ausgefuehrt" + fi + echo "DEPLOY OK: $key -> $p (Backup: $bdir). KEIN backfill/run gestartet." + ;; + rollback) req_ok; [ $# -eq 1 ] || die "rollback braucht genau EINEN Key"; key_ok "$1" + p="$(key_path "$1")" + bdir="$(ls -1t "$BACKUP_ROOT/$1" 2>/dev/null | head -1)" + [ -n "$bdir" ] || die "kein Backup fuer '$1' vorhanden" + bdir="$BACKUP_ROOT/$1/$bdir" + src="$bdir/$(basename "$p")" + [ -f "$src" ] || die "Backup-Datei fehlt: $src" + sha_bak="$(sha256sum "$src" | awk '{print $1}')" + sha_cur="$(docker exec "$CONTAINER" sha256sum "$p" | awk '{print $1}')" + echo "aktuell: $sha_cur / backup: $sha_bak" + [ "$sha_bak" != "$sha_cur" ] || die "kein Unterschied (bereits auf Backup-Stand)" + docker cp "$src" "$CONTAINER:$p" + sha_cur="$(docker exec "$CONTAINER" sha256sum "$p" | awk '{print $1}')" + [ "$sha_cur" = "$sha_bak" ] || die "ROLLBACK-HASH-MISMATCH" + echo "ROLLBACK OK: $key -> $p auf $bdir" + ;; + *) die "Aufruf: ps|inspect|logs|run|code |hashes|code-list|stage-dir|stage-check |test |search-writes|healthcheck|backup |deploy |rollback " ;; esac