#!/usr/bin/env bash set -Eeuo pipefail set +x umask 077 readonly HOST_NGINX_PRODUCTION_PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' PATH=$HOST_NGINX_PRODUCTION_PATH export PATH hash -r readonly ROOT="$(cd -- "${BASH_SOURCE[0]%/*}/../.." && pwd -P)" declare -Ar HOST_NGINX_PRODUCTION_COMMANDS=( [awk]=/usr/bin/awk [bash]=/usr/bin/bash [cat]=/usr/bin/cat [curl]=/usr/bin/curl [date]=/usr/bin/date [dig]=/usr/bin/dig [dirname]=/usr/bin/dirname [grep]=/usr/bin/grep [head]=/usr/bin/head [id]=/usr/bin/id [install]=/usr/bin/install [jq]=/usr/bin/jq [kubectl]=/usr/local/bin/kubectl [mktemp]=/usr/bin/mktemp [openssl]=/usr/bin/openssl [python3]=/usr/bin/python3 [readlink]=/usr/bin/readlink [rm]=/usr/bin/rm [sed]=/usr/bin/sed [sha256sum]=/usr/bin/sha256sum [sleep]=/usr/bin/sleep [sort]=/usr/bin/sort [ss]=/usr/bin/ss [stat]=/usr/bin/stat [tail]=/usr/bin/tail [test]=/usr/bin/test [tr]=/usr/bin/tr ) readonly HOST_NGINX_SOURCE="$ROOT/infrastructure/networking/host-nginx" readonly METRICS_CANDIDATE="$HOST_NGINX_SOURCE/learn-services-metrics-guard.conf" readonly DENY_CANDIDATE="$HOST_NGINX_SOURCE/learn-services-grafana-deny-guard.conf" readonly FULL_CANDIDATE="$HOST_NGINX_SOURCE/learn-services-observability.conf" readonly ADMIN_CANDIDATE="$HOST_NGINX_SOURCE/learn-services-admin.conf" readonly SOURCE_TEST="$ROOT/scripts/validate/test-host-nginx-observability-source.sh" readonly ACTIVE="${PLATFORM_HOST_NGINX_ACTIVE:-/etc/nginx/sites-available/learn-services}" readonly ENABLED="${PLATFORM_HOST_NGINX_ENABLED:-/etc/nginx/sites-enabled/learn-services}" readonly ROLLBACK_BASE="${PLATFORM_HOST_NGINX_ROLLBACK_BASE:-/var/lib/hyeonworks/platform-rollbacks}" readonly GRAFANA_HOST=grafana.learn.hyeonworks.com readonly GITEA_HOST=git.learn.hyeonworks.com readonly KEYCLOAK_HOST=id.learn.hyeonworks.com readonly STORAGE_HOST=storage-admin.learn.hyeonworks.com readonly DB_HOST=db-admin.learn.hyeonworks.com readonly KEYCLOAK_ISSUER="https://$KEYCLOAK_HOST/realms/hyeonworks" readonly LAN_IP=192.168.0.107 readonly TAIL_IP=100.92.240.34 readonly CERT_NAME="$GRAFANA_HOST" readonly CERT_DIR="${PLATFORM_HOST_NGINX_CERT_DIR:-/etc/letsencrypt/live/$CERT_NAME}" readonly CREDENTIALS="${PLATFORM_HOST_NGINX_CREDENTIALS:-/home/donghyeon/.secrets/certbot/cloudflare.ini}" readonly NGINX_BIN="${PLATFORM_HOST_NGINX_NGINX_BIN:-/usr/sbin/nginx}" readonly SYSTEMCTL_BIN="${PLATFORM_HOST_NGINX_SYSTEMCTL_BIN:-/usr/bin/systemctl}" readonly SUDO_BIN="${PLATFORM_HOST_NGINX_SUDO_BIN:-/usr/bin/sudo}" readonly TEST_BIN="${PLATFORM_HOST_NGINX_TEST_BIN:-/usr/bin/test}" readonly CERTBOT_BIN="${PLATFORM_HOST_NGINX_CERTBOT_BIN:-/snap/bin/certbot}" readonly PREFLIGHT_COMMAND="${PLATFORM_HOST_NGINX_PREFLIGHT_COMMAND:-}" readonly POSTCHECK_COMMAND="${PLATFORM_HOST_NGINX_POSTCHECK_COMMAND:-}" readonly TEST_MODE="${PLATFORM_HOST_NGINX_TEST_MODE:-0}" readonly RULES_ACCEPTANCE_SCHEMA=platform-observability-rules-alerts-v2 readonly SOURCE_PROOF_SCHEMA=platform-blackbox-source-v1 readonly STAGE_LEDGER_HEADER=$'stage\tprevious_sha256\tcandidate_sha256\tpayload_file\tpayload_sha256\tapplied_at_utc' execute=false mode=full mode_count=0 certbot_email='' verified_output_dir='' rollback_armed=false rollback_payload='' rollback_previous_sha='' rollback_stage='' timestamp_backup='' fail() { printf 'ERROR: %s\n' "$*" >&2 return 1 } usage() { cat <<'USAGE' Usage: bash scripts/bootstrap/apply-host-nginx-observability.sh bash scripts/bootstrap/apply-host-nginx-observability.sh --execute --metrics-guard-only bash scripts/bootstrap/apply-host-nginx-observability.sh \ --execute --certificate-only --certbot-email you@example.com bash scripts/bootstrap/apply-host-nginx-observability.sh --execute --grafana-deny-guard-only bash scripts/bootstrap/apply-host-nginx-observability.sh \ --execute --verified-output-dir /tmp/platform-observability-metrics.XXXXXX Dry-run prints the exact stage hashes and does not call sudo, Nginx, systemd, Certbot, Kubernetes, or network probes. Execute modes are interactive. The full cutover additionally requires source proof, rules-alerts acceptance, and the two accepted metric inventories for the same rollback ID. USAGE } sha_of() { sha256sum -- "$1" | awk '{print $1}' } is_sha256() { [[ "$1" =~ ^[0-9a-f]{64}$ ]] } require_regular_source() { [[ -f "$1" && ! -L "$1" ]] || fail "unsafe or missing source: $1" } root_cat() { "$SUDO_BIN" -n cat -- "$1" } root_stat() { "$SUDO_BIN" -n stat "$@" } expected_owner_mode() { local path=$1 mode=$2 metadata metadata="$(root_stat -c '%U:%G %a' -- "$path")" || return 1 if [[ "$TEST_MODE" == 1 ]]; then [[ "${metadata##* }" == "$mode" ]] else [[ "$metadata" == "root:root $mode" ]] fi } ensure_root_directory_no_follow() { local path=$1 metadata type owner mode if "$SUDO_BIN" -n "$TEST_BIN" -L "$path"; then fail "root directory path is a symlink: $path" return 1 fi if ! "$SUDO_BIN" -n "$TEST_BIN" -e "$path"; then if ! "$SUDO_BIN" install -d -o root -g root -m 0700 "$path"; then fail "cannot create root directory: $path" return 1 fi fi if ! metadata="$("$SUDO_BIN" -n stat -c '%F|%U:%G|%a' -- "$path")"; then fail "cannot lstat root directory: $path" return 1 fi IFS='|' read -r type owner mode <<<"$metadata" if [[ "$type" != directory || "$mode" != 700 ]]; then fail "root directory type or mode is unsafe: $path" return 1 fi if [[ "$TEST_MODE" == 0 && "$owner" != root:root ]]; then fail "root directory owner is unsafe: $path" return 1 fi return 0 } validate_root_file_no_follow() { local path=$1 expected_mode=$2 metadata type owner mode links if "$SUDO_BIN" -n "$TEST_BIN" -L "$path"; then fail "root file path is a symlink: $path" return 1 fi if ! metadata="$("$SUDO_BIN" -n stat -c '%F|%U:%G|%a|%h' -- "$path")"; then fail "cannot lstat root file: $path" return 1 fi IFS='|' read -r type owner mode links <<<"$metadata" if [[ "$type" != 'regular file' || "$mode" != "$expected_mode" || "$links" != 1 ]]; then fail "root file type, mode, or link count is unsafe: $path" return 1 fi if [[ "$TEST_MODE" == 0 && "$owner" != root:root ]]; then fail "root file owner is unsafe: $path" return 1 fi return 0 } validate_test_command_boundary() { local label=$1 command_path=$2 fixture_bin=$3 resolved metadata type owner mode mode_value if [[ "$command_path" == /usr/bin/sudo ]]; then fail 'test command boundary is unsafe: real /usr/bin/sudo is forbidden' return 1 fi if ! resolved="$(readlink -f -- "$command_path" 2>/dev/null)" || [[ "$resolved" != "$command_path" ]]; then fail "test command boundary is unsafe: $label is not canonical" return 1 fi if [[ "$(dirname -- "$resolved")" != "$fixture_bin" ]]; then fail "test command boundary is unsafe: $label escapes the fixture bin" return 1 fi if [[ ! -f "$command_path" || -L "$command_path" || ! -x "$command_path" ]]; then fail "test command boundary is unsafe: $label is not a regular executable" return 1 fi if ! metadata="$(stat -c '%F|%u|%a' -- "$command_path")"; then fail "test command boundary is unsafe: cannot stat $label" return 1 fi IFS='|' read -r type owner mode <<<"$metadata" mode_value=$((8#$mode)) if [[ "$type" != 'regular file' || "$owner" != "$(id -u)" || $((mode_value & 0022)) != 0 || $((mode_value & 07000)) != 0 ]]; then fail "test command boundary is unsafe: $label owner or mode" return 1 fi return 0 } validate_test_command_fixture_directory() { local path=$1 label=$2 metadata type owner mode mode_value if [[ ! -d "$path" || -L "$path" ]]; then fail "test command fixture $label is not a regular directory" return 1 fi if ! metadata="$(stat -c '%F|%u|%a' -- "$path")"; then fail "cannot stat test command fixture $label" return 1 fi IFS='|' read -r type owner mode <<<"$metadata" mode_value=$((8#$mode)) if [[ "$type" != directory || "$owner" != "$(id -u)" || $((mode_value & 0022)) != 0 || $((mode_value & 07000)) != 0 ]]; then fail "test command fixture $label owner or mode is unsafe" return 1 fi return 0 } production_executable_metadata_is_safe() { local label=$1 lexical=$2 canonical=$3 type=$4 uid=$5 gid=$6 mode=$7 links=$8 policy=$9 local mode_value [[ "$type" == 'regular file' && "$uid" == 0 && "$gid" == 0 && "$links" == 1 ]] || return 1 [[ "$mode" =~ ^[0-7]{3,4}$ ]] || return 1 mode_value=$((8#$mode)) (( (mode_value & 0111) != 0 )) || return 1 (( (mode_value & 0022) == 0 )) || return 1 case "$policy" in sudo) [[ "$label" == sudo && "$lexical" == /usr/bin/sudo && "$canonical" == /usr/bin/sudo ]] || return 1 (( mode_value == 04755 )) || return 1 ;; ordinary) (( (mode_value & 06000) == 0 )) || return 1 ;; *) return 1 ;; esac } validate_production_executable_boundary() { local label=$1 lexical=$2 policy=$3 canonical metadata type uid gid mode links [[ "$lexical" == /* ]] || { fail "production command path is not absolute: $label" return 1 } if ! canonical="$(/usr/bin/readlink -f -- "$lexical" 2>/dev/null)"; then fail "production command target does not resolve: $label" return 1 fi if ! metadata="$(/usr/bin/stat -Lc '%F|%u|%g|%a|%h' -- "$lexical" 2>/dev/null)"; then fail "production command metadata is unreadable: $label" return 1 fi IFS='|' read -r type uid gid mode links <<<"$metadata" if ! production_executable_metadata_is_safe "$label" "$lexical" "$canonical" \ "$type" "$uid" "$gid" "$mode" "$links" "$policy"; then fail "production command boundary is unsafe: $label" return 1 fi } validate_production_path_directory() { local path=$1 canonical metadata type uid gid mode mode_value if ! canonical="$(/usr/bin/readlink -f -- "$path" 2>/dev/null)" || ! metadata="$(/usr/bin/stat -Lc '%F|%u|%g|%a' -- "$path" 2>/dev/null)"; then fail "production PATH directory does not resolve: $path" return 1 fi IFS='|' read -r type uid gid mode <<<"$metadata" mode_value=$((8#$mode)) if [[ "$canonical" != /* || "$type" != directory || "$uid" != 0 || "$gid" != 0 || $((mode_value & 0022)) != 0 ]]; then fail "production PATH directory is unsafe: $path" return 1 fi } validate_production_command_boundaries() { local name expected resolved path_directory local -a production_path_directories=() [[ "$PATH" == "$HOST_NGINX_PRODUCTION_PATH" ]] || { fail 'production PATH is not exact' return 1 } IFS=: read -r -a production_path_directories <<<"$HOST_NGINX_PRODUCTION_PATH" for path_directory in "${production_path_directories[@]}"; do validate_production_path_directory "$path_directory" || return 1 done for name in "${!HOST_NGINX_PRODUCTION_COMMANDS[@]}"; do expected=${HOST_NGINX_PRODUCTION_COMMANDS[$name]} if [[ "$name" == test ]]; then resolved=$expected elif ! resolved="$(command -v -- "$name")" || [[ "$resolved" != "$expected" ]]; then fail "production command resolution drifted: $name" return 1 fi validate_production_executable_boundary "$name" "$expected" ordinary || return 1 done validate_production_executable_boundary nginx "$NGINX_BIN" ordinary || return 1 validate_production_executable_boundary systemctl "$SYSTEMCTL_BIN" ordinary || return 1 validate_production_executable_boundary certbot "$CERTBOT_BIN" ordinary || return 1 validate_production_executable_boundary sudo "$SUDO_BIN" sudo || return 1 } validate_test_mode() { local override_name active_fixture_root rollback_fixture_root fixture_bin fixture_root if [[ "$TEST_MODE" != 0 && "$TEST_MODE" != 1 ]]; then fail 'invalid test-mode flag' return 1 fi if [[ "$TEST_MODE" == 0 ]]; then for override_name in ${!PLATFORM_HOST_NGINX_@}; do fail "production rejects Host Nginx override: $override_name" return 1 done if [[ "$ACTIVE" != /etc/nginx/sites-available/learn-services || "$ENABLED" != /etc/nginx/sites-enabled/learn-services || "$ROLLBACK_BASE" != /var/lib/hyeonworks/platform-rollbacks || "$CERT_DIR" != /etc/letsencrypt/live/grafana.learn.hyeonworks.com || "$CREDENTIALS" != /home/donghyeon/.secrets/certbot/cloudflare.ini || "$NGINX_BIN" != /usr/sbin/nginx || "$SYSTEMCTL_BIN" != /usr/bin/systemctl || "$SUDO_BIN" != /usr/bin/sudo || "$TEST_BIN" != /usr/bin/test || "$CERTBOT_BIN" != /snap/bin/certbot || -n "$PREFLIGHT_COMMAND" || -n "$POSTCHECK_COMMAND" ]]; then fail 'production Host Nginx constants are not exact' return 1 fi return 0 fi case "$ACTIVE:$ROLLBACK_BASE" in /tmp/platform-host-nginx-test.*:/tmp/platform-host-nginx-test.*) ;; *) fail 'test mode is restricted to an isolated /tmp/platform-host-nginx-test.* tree' return 1 ;; esac if [[ "$SUDO_BIN" == /usr/bin/sudo ]]; then fail 'test command boundary is unsafe: real /usr/bin/sudo is forbidden' return 1 fi if ! active_fixture_root="$(readlink -f -- "$(dirname -- "$(dirname -- "$ACTIVE")")" 2>/dev/null)" || ! rollback_fixture_root="$(readlink -f -- "$(dirname -- "$ROLLBACK_BASE")" 2>/dev/null)"; then fail 'test fixture root does not resolve' return 1 fi fixture_root=$active_fixture_root fixture_bin="$fixture_root/bin" if [[ "$fixture_root" != /tmp/platform-host-nginx-test.* || "$ACTIVE" != "$fixture_root/active/learn-services" || "$ENABLED" != "$fixture_root/enabled/learn-services" || "$ROLLBACK_BASE" != "$fixture_root/rollbacks" || "$rollback_fixture_root" != "$fixture_root" || "$(readlink -f -- "$fixture_root" 2>/dev/null)" != "$fixture_root" ]]; then fail 'test command fixture bin is not tied to the active and rollback fixture tree' return 1 fi if ! validate_test_command_fixture_directory "$fixture_root" root || ! validate_test_command_fixture_directory "$fixture_bin" bin; then return 1 fi validate_test_command_boundary sudo "$SUDO_BIN" "$fixture_bin" || return 1 validate_test_command_boundary test "$TEST_BIN" "$fixture_bin" || return 1 validate_test_command_boundary nginx "$NGINX_BIN" "$fixture_bin" || return 1 validate_test_command_boundary systemctl "$SYSTEMCTL_BIN" "$fixture_bin" || return 1 validate_test_command_boundary certbot "$CERTBOT_BIN" "$fixture_bin" || return 1 validate_test_command_boundary preflight "$PREFLIGHT_COMMAND" "$fixture_bin" || return 1 validate_test_command_boundary postcheck "$POSTCHECK_COMMAND" "$fixture_bin" || return 1 PATH="$fixture_bin:$HOST_NGINX_PRODUCTION_PATH" export PATH hash -r return 0 } parse_args() { while (( $# > 0 )); do case "$1" in --execute) execute=true shift ;; --metrics-guard-only) mode=metrics mode_count=$((mode_count + 1)) shift ;; --certificate-only) mode=certificate mode_count=$((mode_count + 1)) shift ;; --grafana-deny-guard-only) mode=deny mode_count=$((mode_count + 1)) shift ;; --certbot-email) (( $# >= 2 )) || fail '--certbot-email requires a value' certbot_email=$2 shift 2 ;; --verified-output-dir) (( $# >= 2 )) || fail '--verified-output-dir requires a value' verified_output_dir=$2 shift 2 ;; -h|--help) usage exit 0 ;; *) usage >&2 fail "unsupported argument: $1" ;; esac done (( mode_count <= 1 )) || fail 'execute modes are mutually exclusive' [[ -z "$certbot_email" || "$mode" == certificate ]] || fail '--certbot-email is certificate-only' [[ -z "$verified_output_dir" || "$mode" == full ]] || fail '--verified-output-dir is full-mode only' } source_contract() { require_regular_source "$ADMIN_CANDIDATE" require_regular_source "$METRICS_CANDIDATE" require_regular_source "$DENY_CANDIDATE" require_regular_source "$FULL_CANDIDATE" require_regular_source "$SOURCE_TEST" bash "$SOURCE_TEST" >/dev/null python3 - "$ADMIN_CANDIDATE" "$METRICS_CANDIDATE" <<'PY' import pathlib import sys prior, guard = map(lambda value: pathlib.Path(value).read_text(), sys.argv[1:]) needle = " location / {\n" addition = " location = /metrics {\n return 404;\n }\n\n" if prior.count(needle) < 1 or guard != prior.replace(needle, addition + needle, 1): raise SystemExit("metrics guard differs from the admin source outside exact /metrics") PY } print_plan() { local active_sha=unreadable [[ -f "$ACTIVE" && ! -L "$ACTIVE" && -r "$ACTIVE" ]] && active_sha="$(sha_of "$ACTIVE")" printf 'HOST_NGINX_ACTIVE_SHA256=%s\n' "$active_sha" printf 'HOST_NGINX_ADMIN_SHA256=%s\n' "$(sha_of "$ADMIN_CANDIDATE")" printf 'HOST_NGINX_METRICS_GUARD_SHA256=%s\n' "$(sha_of "$METRICS_CANDIDATE")" printf 'HOST_NGINX_GRAFANA_DENY_GUARD_SHA256=%s\n' "$(sha_of "$DENY_CANDIDATE")" printf 'HOST_NGINX_FULL_SHA256=%s\n' "$(sha_of "$FULL_CANDIDATE")" printf 'HOST_NGINX_CERTIFICATE_EXPECTED_SAN=%s\n' "$GRAFANA_HOST" printf 'HOST_NGINX_CERTIFICATE_SAN=NOT_CHECKED_DRY_RUN\n' printf 'HOST_NGINX_GRAFANA_PUBLIC_DNS=NOT_CHECKED_DRY_RUN\n' printf 'HOST_NGINX_OBSERVABILITY_DRY_RUN=PASS\n' } validate_host_files() { "$SUDO_BIN" -n "$TEST_BIN" -f "$ACTIVE" || fail "active site is missing: $ACTIVE" ! "$SUDO_BIN" -n "$TEST_BIN" -L "$ACTIVE" || fail "active site is a symlink: $ACTIVE" "$SUDO_BIN" -n "$TEST_BIN" -L "$ENABLED" || fail "enabled site is not a symlink: $ENABLED" [[ "$("$SUDO_BIN" -n readlink -f -- "$ENABLED")" == "$("$SUDO_BIN" -n readlink -f -- "$ACTIVE")" ]] || fail 'enabled site target changed' expected_owner_mode "$ACTIVE" 644 || fail 'active site must be root:root 0644' } validate_rollback_root() { local id=${PLATFORM_OBSERVABILITY_ROLLBACK_ID:-} [[ "$id" =~ ^[0-9]{8}T[0-9]{6}Z$ ]] || fail 'PLATFORM_OBSERVABILITY_ROLLBACK_ID format is invalid' ROLLBACK_ROOT="$ROLLBACK_BASE/observability-$id" "$SUDO_BIN" -n "$TEST_BIN" -d "$ROLLBACK_ROOT" || fail "rollback root is missing: $ROLLBACK_ROOT" ! "$SUDO_BIN" -n "$TEST_BIN" -L "$ROLLBACK_ROOT" || fail "rollback root is a symlink: $ROLLBACK_ROOT" expected_owner_mode "$ROLLBACK_ROOT" 700 || fail 'rollback root must be root:root 0700' readonly ROLLBACK_ROOT } declare -A PARSED_ENV=() read_exact_env() { local file=$1 shift local line key value allowed expected=$# count=0 PARSED_ENV=() if ! "$SUDO_BIN" -n "$TEST_BIN" -f "$file"; then fail "evidence file is missing: $file" return 1 fi if "$SUDO_BIN" -n "$TEST_BIN" -L "$file"; then fail "evidence file is a symlink: $file" return 1 fi if ! expected_owner_mode "$file" 600; then fail "evidence file must be root:root 0600: $file" return 1 fi while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" != *=* || "$line" == *$'\t'* || "$line" == *$'\r'* ]]; then fail "malformed evidence line: $file" return 1 fi key=${line%%=*} value=${line#*=} if [[ -z "$key" || -z "$value" || -n "${PARSED_ENV[$key]+x}" ]]; then fail "duplicate or empty evidence field: $file" return 1 fi allowed=false for expected_key in "$@"; do [[ "$key" == "$expected_key" ]] && allowed=true done if [[ "$allowed" != true ]]; then fail "unexpected evidence field $key: $file" return 1 fi PARSED_ENV[$key]=$value count=$((count + 1)) done < <(root_cat "$file") if (( count != expected )); then fail "evidence field count is not exact: $file" return 1 fi for expected_key in "$@"; do if [[ -z "${PARSED_ENV[$expected_key]:-}" ]]; then fail "missing evidence field $expected_key: $file" return 1 fi done return 0 } validate_slack_acceptance_tuple() { local tuple tuple="${PARSED_ENV[slack_deployment_gate]}|${PARSED_ENV[slack_gate_approval_ref]}|${PARSED_ENV[slack_gate_accepted_by_uid]}" case "$tuple" in 'RECOVERY|strict-recovery-evidence-v1|not-applicable'|\ 'RISK_ACCEPTED|2026-08-14-observability-slack-recovery-risk-acceptance-design|1000') return 0 ;; *) fail 'rules-alerts Slack deployment gate tuple mismatch' return 1 ;; esac } validate_recent_utc() { local value=$1 label=$2 then now age if [[ ! "$value" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then fail "$label timestamp format is invalid" return 1 fi if ! then="$(date -u -d "$value" +%s 2>/dev/null)"; then fail "$label timestamp is invalid" return 1 fi if ! now="$(date -u +%s)"; then fail "cannot read current UTC time for $label" return 1 fi age=$((now - then)) if (( age < -300 || age > 86400 )); then fail "$label evidence is stale or from the future" return 1 fi return 0 } validate_utc() { local value=$1 label=$2 if [[ ! "$value" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then fail "$label timestamp format is invalid" return 1 fi if ! date -u -d "$value" +%s >/dev/null 2>&1; then fail "$label timestamp is invalid" return 1 fi return 0 } inventory_sha() { local root=$1 phase=$2 directory checksum actual declared filename extra directory_mode json_mode checksum_mode directory="$root/$phase" checksum="$directory/inventory.sha256" if [[ ! -d "$directory" || -L "$directory" ]]; then fail "inventory phase is missing or unsafe: $phase" return 1 fi if [[ ! -f "$directory/inventory.json" || -L "$directory/inventory.json" ]]; then fail "inventory JSON is unsafe: $phase" return 1 fi if [[ ! -f "$checksum" || -L "$checksum" ]]; then fail "inventory checksum is unsafe: $phase" return 1 fi if ! directory_mode="$(stat -c %a -- "$directory")"; then fail "cannot stat inventory directory: $phase" return 1 fi if [[ "$directory_mode" != 700 ]]; then fail "inventory directory mode is not 0700: $phase" return 1 fi if ! json_mode="$(stat -c %a -- "$directory/inventory.json")" || ! checksum_mode="$(stat -c %a -- "$checksum")"; then fail "cannot stat inventory files: $phase" return 1 fi if [[ "$json_mode" != 600 || "$checksum_mode" != 600 ]]; then fail "inventory file mode is not 0600: $phase" return 1 fi if ! read -r declared filename extra <"$checksum"; then fail "cannot read inventory checksum: $phase" return 1 fi if [[ "$filename" != inventory.json || -n "${extra:-}" ]]; then fail "inventory checksum shape is invalid: $phase" return 1 fi if ! is_sha256 "$declared"; then fail "inventory checksum is invalid: $phase" return 1 fi if ! actual="$(sha_of "$directory/inventory.json")"; then fail "cannot hash inventory JSON: $phase" return 1 fi if [[ "$actual" != "$declared" ]]; then fail "inventory checksum mismatch: $phase" return 1 fi printf '%s' "$actual" || return 1 return 0 } validate_full_evidence() { local deny_sha=$1 acceptance proof initial_sha post_sha canonical_metric_root local metric_root_mode if [[ -z "$verified_output_dir" ]]; then fail 'full execute requires --verified-output-dir' return 1 fi if ! canonical_metric_root="$(readlink -f -- "$verified_output_dir" 2>/dev/null)"; then fail 'verified output dir does not resolve' return 1 fi if [[ "$canonical_metric_root" != "$verified_output_dir" || ! -d "$verified_output_dir" || -L "$verified_output_dir" ]]; then fail 'verified output dir must be a canonical regular directory' return 1 fi if ! metric_root_mode="$(stat -c %a -- "$verified_output_dir")"; then fail 'cannot stat verified output root' return 1 fi if [[ "$metric_root_mode" != 700 ]]; then fail 'verified output root mode must be 0700' return 1 fi if ! initial_sha="$(inventory_sha "$verified_output_dir" target-initial)"; then return 1 fi if ! post_sha="$(inventory_sha "$verified_output_dir" post-substrate)"; then return 1 fi proof="$ROLLBACK_ROOT/blackbox-source-proof.env" if ! read_exact_env "$proof" schema rollback_id nginx_sha256 tested_at_utc \ grafana_remote_addr grafana_status storage_admin_remote_addr storage_admin_status \ db_admin_remote_addr db_admin_status; then return 1 fi if [[ "${PARSED_ENV[schema]}" != "$SOURCE_PROOF_SCHEMA" ]]; then fail 'source-proof schema mismatch' return 1 fi if [[ "${PARSED_ENV[rollback_id]}" != "$PLATFORM_OBSERVABILITY_ROLLBACK_ID" ]]; then fail 'source-proof rollback ID mismatch' return 1 fi if [[ "${PARSED_ENV[nginx_sha256]}" != "$deny_sha" ]]; then fail 'source-proof Nginx hash mismatch' return 1 fi if [[ "${PARSED_ENV[grafana_status]}" != 403 || "${PARSED_ENV[storage_admin_status]}" != 403 || "${PARSED_ENV[db_admin_status]}" != 403 ]]; then fail 'source-proof statuses are not exact 403' return 1 fi if ! python3 - "${PARSED_ENV[grafana_remote_addr]}" "${PARSED_ENV[storage_admin_remote_addr]}" \ "${PARSED_ENV[db_admin_remote_addr]}" <<'PY' import ipaddress import sys for value in sys.argv[1:]: ipaddress.ip_address(value) PY then fail 'source-proof remote address is invalid' return 1 fi if ! validate_recent_utc "${PARSED_ENV[tested_at_utc]}" source-proof; then return 1 fi acceptance="$ROLLBACK_ROOT/access-rules-alerts/acceptance.env" if ! read_exact_env "$acceptance" \ schema rollback_id target_initial_sha256 post_substrate_sha256 \ slack_deployment_gate slack_gate_approval_ref slack_gate_accepted_by_uid \ accepted_at_utc state; then return 1 fi if [[ "${PARSED_ENV[schema]}" != "$RULES_ACCEPTANCE_SCHEMA" ]]; then fail 'rules-alerts acceptance schema mismatch' return 1 fi if [[ "${PARSED_ENV[rollback_id]}" != "$PLATFORM_OBSERVABILITY_ROLLBACK_ID" ]]; then fail 'rules-alerts rollback ID mismatch' return 1 fi if [[ "${PARSED_ENV[target_initial_sha256]}" != "$initial_sha" ]]; then fail 'target-initial acceptance hash mismatch' return 1 fi if [[ "${PARSED_ENV[post_substrate_sha256]}" != "$post_sha" ]]; then fail 'post-substrate acceptance hash mismatch' return 1 fi if ! validate_slack_acceptance_tuple; then return 1 fi if [[ "${PARSED_ENV[state]}" != accepted ]]; then fail 'rules-alerts state is not accepted' return 1 fi if ! validate_utc "${PARSED_ENV[accepted_at_utc]}" rules-alerts; then return 1 fi return 0 } validate_exact_certificate() { local san_output san_set if ! "$SUDO_BIN" -n "$TEST_BIN" -s "$CERT_DIR/fullchain.pem"; then fail 'Grafana certificate is missing' return 1 fi if ! "$SUDO_BIN" -n "$TEST_BIN" -s "$CERT_DIR/privkey.pem"; then fail 'Grafana private key is missing' return 1 fi if ! "$SUDO_BIN" -n openssl x509 -in "$CERT_DIR/fullchain.pem" -noout -checkend 0 >/dev/null; then fail 'Grafana certificate is expired' return 1 fi if ! san_output="$("$SUDO_BIN" -n openssl x509 -in "$CERT_DIR/fullchain.pem" -noout -ext subjectAltName)"; then fail 'cannot read Grafana certificate SAN' return 1 fi if ! san_set="$(printf '%s\n' "$san_output" | grep -oE 'DNS:[^,[:space:]]+' | sed 's/^DNS://' | sort -u)"; then fail 'cannot parse Grafana certificate SAN' return 1 fi if [[ "$san_set" != "$GRAFANA_HOST" ]]; then fail 'Grafana certificate SAN set is not exact' return 1 fi return 0 } validate_cloudflare_credentials() { local contract "$SUDO_BIN" -n "$TEST_BIN" -f "$CREDENTIALS" || fail "Cloudflare credential is missing: $CREDENTIALS" expected_owner_mode "$CREDENTIALS" 600 || fail 'Cloudflare credential must be root:root 0600' contract="$("$SUDO_BIN" -n awk -F= ' BEGIN { count=0; valid=0; unexpected=0 } /^[[:space:]]*($|#)/ { next } /^[[:space:]]*dns_cloudflare_api_token[[:space:]]*=/ { count++; value=$0; sub(/^[^=]*=[[:space:]]*/, "", value); sub(/[[:space:]]+$/, "", value) if (length(value) >= 20) valid++; next } { unexpected++ } END { printf "%d:%d:%d", count, valid, unexpected } ' "$CREDENTIALS")" [[ "$contract" == 1:1:0 ]] || fail 'Cloudflare credential must contain one nonempty API token key only' } certificate_mode() { if validate_exact_certificate 2>/dev/null; then printf 'HOST_NGINX_GRAFANA_CERTIFICATE=READY\n' return 0 fi [[ "$certbot_email" == *@* ]] || fail 'missing exact certificate; --certbot-email is required for issuance' validate_cloudflare_credentials [[ -x "$CERTBOT_BIN" ]] || fail "supported Certbot is missing: $CERTBOT_BIN" "$CERTBOT_BIN" plugins 2>/dev/null | grep -q dns-cloudflare || fail 'Certbot dns-cloudflare plugin is missing' confirm "issue the exact Grafana certificate" "$SUDO_BIN" "$CERTBOT_BIN" certonly --dns-cloudflare \ --dns-cloudflare-credentials "$CREDENTIALS" --dns-cloudflare-propagation-seconds 60 \ --cert-name "$CERT_NAME" --domains "$GRAFANA_HOST" --non-interactive --agree-tos \ --email "$certbot_email" validate_exact_certificate printf 'HOST_NGINX_GRAFANA_CERTIFICATE=READY\n' } status_code() { local code if code="$(curl --disable --noproxy '*' --silent --show-error --output /dev/null \ --connect-timeout 2 --max-time 5 --write-out '%{http_code}' "$@" 2>/dev/null)"; then printf '%s' "$code" else printf 000 fi } external_preflight() { local stage=$1 code host "$SYSTEMCTL_BIN" is-active --quiet nginx || fail 'Nginx is not active' "$SUDO_BIN" -n "$NGINX_BIN" -t if [[ "$TEST_MODE" == 1 ]]; then "$PREFLIGHT_COMMAND" "$stage" return fi case "$stage" in metrics) return 0 ;; deny) validate_exact_certificate [[ -z "$(ss -H -ltn '( sport = :1 )')" ]] || fail 'TCP 127.0.0.1:1 guard listener is not absent' return 0 ;; full) ;; *) fail "unknown transition stage: $stage" ;; esac validate_exact_certificate [[ "$(dig +time=2 +tries=1 +short "@$LAN_IP" A "$GRAFANA_HOST" | tail -n1)" == "$LAN_IP" ]] || fail 'LAN private DNS answer mismatch' [[ "$(dig +time=2 +tries=1 +short "@$TAIL_IP" A "$GRAFANA_HOST" | tail -n1)" == "$TAIL_IP" ]] || fail 'Tailscale private DNS answer mismatch' [[ -z "$(dig +time=2 +tries=1 +short @1.1.1.1 A "$GRAFANA_HOST" | tr -d '[:space:]')" ]] || fail 'Grafana public A exists' [[ -z "$(dig +time=2 +tries=1 +short @1.1.1.1 AAAA "$GRAFANA_HOST" | tr -d '[:space:]')" ]] || fail 'Grafana public AAAA exists' kubectl -n observability exec deployment/grafana -c grafana --request-timeout=10s -- \ getent ahostsv4 "$GRAFANA_HOST" | awk -v ip="$LAN_IP" '$1 == ip { found=1 } END { exit !found }' || fail 'Grafana Pod private DNS answer mismatch' kubectl -n observability get ingress grafana -o json --request-timeout=10s | jq -e --arg host "$GRAFANA_HOST" ' .spec.ingressClassName == "traefik" and (.spec.tls // [] | length == 0) and ([.spec.rules[] | select(.host == $host) | .http.paths[] | select(.path == "/" and .pathType == "Prefix" and .backend.service.name == "grafana" and .backend.service.port.number == 80)] | length == 1) ' >/dev/null || fail 'Grafana Ingress contract is not ready' kubectl -n observability get endpointslice -l kubernetes.io/service-name=grafana -o json --request-timeout=10s | jq -e '[.items[].endpoints[] | select(.conditions.ready == true) | .addresses[]] | length > 0' >/dev/null || fail 'Grafana EndpointSlice has no ready address' [[ "$(status_code --header "Host: $GRAFANA_HOST" http://127.0.0.1:30080/api/health)" == 200 ]] || fail 'Grafana internal health is not 200' [[ "$(status_code --resolve "$GITEA_HOST:443:127.0.0.1" "https://$GITEA_HOST/api/healthz")" == 200 ]] || fail 'Gitea regression failed' [[ "$(status_code --resolve "$KEYCLOAK_HOST:443:127.0.0.1" "https://$KEYCLOAK_HOST/realms/hyeonworks/.well-known/openid-configuration")" == 200 ]] || fail 'Keycloak regression failed' for host in "$STORAGE_HOST" "$DB_HOST"; do code="$(status_code --interface "$LAN_IP" --resolve "$host:443:$LAN_IP" "https://$host/")" [[ "$code" == 200 || "$code" == 302 || "$code" == 303 ]] || fail "admin endpoint regression failed: $host=$code" done [[ "$(status_code http://$LAN_IP:30080/)" == 000 ]] || fail 'LAN NodePort 30080 is reachable' [[ "$(status_code --insecure https://$LAN_IP:30443/)" == 000 ]] || fail 'LAN NodePort 30443 is reachable' } stage_postcheck_once() { local stage=$1 local code host [[ "$(status_code --resolve "$GITEA_HOST:443:127.0.0.1" "https://$GITEA_HOST/metrics")" == 404 ]] || return 1 [[ "$(status_code --resolve "$GITEA_HOST:443:127.0.0.1" "https://$GITEA_HOST/api/healthz")" == 200 ]] || return 1 [[ "$(status_code --resolve "$KEYCLOAK_HOST:443:127.0.0.1" "https://$KEYCLOAK_HOST/realms/hyeonworks/.well-known/openid-configuration")" == 200 ]] || return 1 for host in "$STORAGE_HOST" "$DB_HOST"; do code="$(status_code --interface "$LAN_IP" --resolve "$host:443:$LAN_IP" "https://$host/")" [[ "$code" == 200 || "$code" == 302 || "$code" == 303 ]] || return 1 [[ "$(status_code --resolve "$host:443:127.0.0.1" "https://$host/")" == 403 ]] || return 1 done [[ "$(status_code http://$LAN_IP:30080/)" == 000 ]] || return 1 [[ "$(status_code --insecure https://$LAN_IP:30443/)" == 000 ]] || return 1 [[ "$stage" == metrics ]] && return 0 [[ "$(status_code --resolve "$GRAFANA_HOST:443:127.0.0.1" "https://$GRAFANA_HOST/")" == 403 ]] || return 1 [[ "$(status_code --resolve "$GRAFANA_HOST:443:127.0.0.1" "https://$GRAFANA_HOST/metrics")" == 404 ]] || return 1 code="$(status_code --interface "$LAN_IP" --resolve "$GRAFANA_HOST:443:$LAN_IP" "https://$GRAFANA_HOST/")" if [[ "$stage" == deny ]]; then [[ "$code" == 502 ]] || return 1 else [[ "$stage" == full && ( "$code" == 200 || "$code" == 302 ) ]] || return 1 fi } external_postcheck() { local stage=$1 stable=0 attempt "$SYSTEMCTL_BIN" is-active --quiet nginx || return 1 for ((attempt=1; attempt<=15; attempt++)); do if { [[ "$TEST_MODE" == 1 ]] && "$POSTCHECK_COMMAND" "$stage"; } || { [[ "$TEST_MODE" == 0 ]] && stage_postcheck_once "$stage"; }; then stable=$((stable + 1)) (( stable >= 3 )) && break else stable=0 fi [[ "$TEST_MODE" == 0 && attempt -lt 15 ]] && sleep 1 done (( stable >= 3 )) || return 1 [[ "$TEST_MODE" == 1 ]] && return 0 if curl --disable --noproxy '*' --insecure --silent --output /dev/null --connect-timeout 2 --max-time 5 \ --resolve unconfigured.invalid:443:127.0.0.1 https://unconfigured.invalid/ 2>/dev/null; then return 1 fi } confirm() { local description=$1 answer printf 'Type APPLY to %s: ' "$description" if [[ "$TEST_MODE" == 1 ]]; then answer=${PLATFORM_HOST_NGINX_CONFIRMATION:-} printf '%s\n' "$answer" else [[ -t 0 ]] || fail '--execute requires an interactive terminal' read -r answer fi [[ "$answer" == APPLY ]] || fail 'cancelled' } validate_full_first_cutover_residue_absent() { local host_root child host_root="$ROLLBACK_ROOT/host-nginx" if ! "$SUDO_BIN" -n "$TEST_BIN" ! -e "$host_root" || ! "$SUDO_BIN" -n "$TEST_BIN" ! -L "$host_root"; then fail 'full first-cutover Host Nginx evidence root already exists or is unsafe' return 1 fi for child in \ "$host_root/stages.tsv" \ "$host_root/payloads" \ "$host_root/payloads/full-prior-0001.conf"; do if ! "$SUDO_BIN" -n "$TEST_BIN" ! -e "$child" || ! "$SUDO_BIN" -n "$TEST_BIN" ! -L "$child"; then fail 'full first-cutover Host Nginx evidence residue exists or is unsafe' return 1 fi done if ! "$SUDO_BIN" -n "$TEST_BIN" ! -e "$host_root" || ! "$SUDO_BIN" -n "$TEST_BIN" ! -L "$host_root"; then fail 'full first-cutover Host Nginx evidence root changed during validation' return 1 fi } prepare_stage_ledger() { local stage=$1 previous_sha=$2 candidate_sha=$3 host_root payload_root ledger attempt payload_rel payload tmp host_root="$ROLLBACK_ROOT/host-nginx" payload_root="$host_root/payloads" ledger="$host_root/stages.tsv" if ! ensure_root_directory_no_follow "$host_root"; then return 1 fi if ! ensure_root_directory_no_follow "$payload_root"; then return 1 fi if "$SUDO_BIN" -n "$TEST_BIN" -L "$ledger"; then fail 'Host Nginx ledger must not be a symlink' return 1 elif "$SUDO_BIN" -n "$TEST_BIN" -e "$ledger"; then if ! validate_root_file_no_follow "$ledger" 600; then return 1 fi if [[ "$(root_cat "$ledger" | head -n1)" != "$STAGE_LEDGER_HEADER" ]]; then fail 'Host Nginx ledger header mismatch' return 1 fi else tmp="$(mktemp /tmp/platform-host-nginx-ledger.XXXXXX)" printf '%s\n' "$STAGE_LEDGER_HEADER" >"$tmp" if ! "$SUDO_BIN" install -o root -g root -m 0600 "$tmp" "$ledger"; then rm -f -- "$tmp" return 1 fi rm -f -- "$tmp" || return 1 if ! validate_root_file_no_follow "$ledger" 600; then return 1 fi fi attempt=$(( $(root_cat "$ledger" | awk -F '\t' -v stage="$stage" 'NR > 1 && $1 == stage { count++ } END { print count+1 }') )) printf -v payload_rel 'payloads/%s-prior-%04d.conf' "$stage" "$attempt" payload="$host_root/$payload_rel" if "$SUDO_BIN" -n "$TEST_BIN" -L "$payload" || "$SUDO_BIN" -n "$TEST_BIN" -e "$payload"; then fail "ledger payload already exists or is a symlink: $payload_rel" return 1 fi "$SUDO_BIN" install -o root -g root -m 0600 "$ACTIVE" "$payload" if ! validate_root_file_no_follow "$payload" 600; then return 1 fi payload_sha="$(root_cat "$payload" | sha256sum | awk '{print $1}')" [[ "$payload_sha" == "$previous_sha" ]] || fail 'ledger payload hash mismatch' tmp="$(mktemp /tmp/platform-host-nginx-ledger.XXXXXX)" root_cat "$ledger" >"$tmp" printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$stage" "$previous_sha" "$candidate_sha" \ "$payload_rel" "$payload_sha" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >>"$tmp" if ! "$SUDO_BIN" install -o root -g root -m 0600 "$tmp" "$ledger"; then rm -f -- "$tmp" return 1 fi rm -f -- "$tmp" || return 1 rollback_payload=$payload rollback_previous_sha=$previous_sha rollback_stage=$stage return 0 } rollback() { local rc=0 actual set +e "$SUDO_BIN" -n "$TEST_BIN" -f "$rollback_payload" || rc=1 "$SUDO_BIN" -n "$TEST_BIN" -L "$rollback_payload" && rc=1 expected_owner_mode "$rollback_payload" 600 || rc=1 actual="$(root_cat "$rollback_payload" 2>/dev/null | sha256sum | awk '{print $1}')" [[ "$actual" == "$rollback_previous_sha" ]] || rc=1 if (( rc == 0 )); then "$SUDO_BIN" install -o root -g root -m 0644 "$rollback_payload" "$ACTIVE" || rc=1 [[ "$(sha_of "$ACTIVE")" == "$rollback_previous_sha" ]] || rc=1 "$SUDO_BIN" "$NGINX_BIN" -t || rc=1 "$SUDO_BIN" "$SYSTEMCTL_BIN" reload nginx || rc=1 "$SUDO_BIN" "$SYSTEMCTL_BIN" is-active --quiet nginx || rc=1 fi if (( rc == 0 )); then rollback_armed=false printf 'HOST_NGINX_OBSERVABILITY_ROLLBACK=PASS\n' >&2 else printf 'HOST_NGINX_OBSERVABILITY_ROLLBACK=FAIL\nMANUAL_RECOVERY_REQUIRED=YES\n' >&2 fi set -e return "$rc" } on_exit() { local rc=$? trap - EXIT INT TERM if (( rc != 0 )) && [[ "$rollback_armed" == true ]]; then rollback || true fi exit "$rc" } transition() { local stage=$1 expected=$2 candidate=$3 description=$4 previous_sha candidate_sha active_sha previous_sha="$(sha_of "$expected")" candidate_sha="$(sha_of "$candidate")" active_sha="$(sha_of "$ACTIVE")" if [[ "$stage" == full ]]; then if ! validate_full_evidence "$(sha_of "$DENY_CANDIDATE")"; then return 1 fi fi if [[ "$stage" == full && "$active_sha" == "$candidate_sha" ]]; then fail 'full cutover is already active without first-cutover provenance' return 1 fi if [[ "$active_sha" == "$candidate_sha" ]]; then external_preflight "$stage" external_postcheck "$stage" || fail "already-active $stage stage is unhealthy" printf 'HOST_NGINX_%s_STAGE=ALREADY_ACTIVE\n' "${stage^^}" return 0 fi [[ "$active_sha" == "$previous_sha" ]] || fail "$stage active hash gate failed: $active_sha" if [[ "$stage" == full ]]; then validate_full_first_cutover_residue_absent || return 1 fi external_preflight "$stage" [[ "$(sha_of "$ACTIVE")" == "$previous_sha" ]] || fail "$stage active config changed during preflight" printf 'HOST_NGINX_STAGE=%s\nHOST_NGINX_PREVIOUS_SHA256=%s\nHOST_NGINX_CANDIDATE_SHA256=%s\n' \ "$stage" "$previous_sha" "$candidate_sha" confirm "$description" [[ "$(sha_of "$ACTIVE")" == "$previous_sha" ]] || fail "$stage active config changed before commit" [[ "$(sha_of "$candidate")" == "$candidate_sha" ]] || fail "$stage candidate changed before commit" if ! prepare_stage_ledger "$stage" "$previous_sha" "$candidate_sha"; then fail 'fresh rollback ID + source proof + complete Task 6 required' return 1 fi timestamp_backup="${ACTIVE}.before-observability-${stage}-$(date -u +%Y%m%dT%H%M%SZ)" if "$SUDO_BIN" -n "$TEST_BIN" -e "$timestamp_backup" || "$SUDO_BIN" -n "$TEST_BIN" -L "$timestamp_backup"; then fail "timestamp backup already exists or is unsafe: $timestamp_backup" fail 'fresh rollback ID + source proof + complete Task 6 required' return 1 fi if ! "$SUDO_BIN" install -o root -g root -m 0644 "$ACTIVE" "$timestamp_backup"; then fail 'fresh rollback ID + source proof + complete Task 6 required' return 1 fi if [[ "$(sha_of "$timestamp_backup")" != "$previous_sha" ]]; then fail 'timestamp backup hash mismatch' fail 'fresh rollback ID + source proof + complete Task 6 required' return 1 fi rollback_armed=true "$SUDO_BIN" install -o root -g root -m 0644 "$candidate" "$ACTIVE" [[ "$(sha_of "$ACTIVE")" == "$candidate_sha" ]] || fail 'installed active hash mismatch' "$SUDO_BIN" "$SYSTEMCTL_BIN" daemon-reload "$SUDO_BIN" "$NGINX_BIN" -t "$SUDO_BIN" "$SYSTEMCTL_BIN" reload nginx "$SUDO_BIN" "$SYSTEMCTL_BIN" is-active --quiet nginx external_postcheck "$stage" || fail "$stage bounded postcheck failed" rollback_armed=false printf 'HOST_NGINX_%s_STAGE=PASS\nHOST_NGINX_TIMESTAMP_BACKUP=%s\n' "${stage^^}" "$timestamp_backup" } main() { parse_args "$@" validate_test_mode if [[ "$TEST_MODE" == 0 ]]; then validate_production_command_boundaries fi source_contract if [[ "$execute" == false ]]; then print_plan return 0 fi for cmd in awk cat curl date dig grep head install jq kubectl mktemp openssl python3 readlink sed sha256sum sort ss stat; do command -v "$cmd" >/dev/null 2>&1 || fail "required command is missing: $cmd" done [[ -x "$NGINX_BIN" && -x "$SYSTEMCTL_BIN" && -x "$SUDO_BIN" ]] || fail 'required host command boundary is missing' "$SUDO_BIN" -v validate_host_files validate_rollback_root trap on_exit EXIT trap 'exit 130' INT trap 'exit 143' TERM case "$mode" in metrics) transition metrics "$ADMIN_CANDIDATE" "$METRICS_CANDIDATE" 'install only the Gitea metrics guard' ;; certificate) certificate_mode ;; deny) transition deny "$METRICS_CANDIDATE" "$DENY_CANDIDATE" 'install only the Grafana deny guard' ;; full) transition full "$DENY_CANDIDATE" "$FULL_CANDIDATE" 'replace only the Grafana deny upstream with Traefik' ;; *) fail "invalid mode: $mode" ;; esac } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi