apiVersion: v1 kind: ConfigMap metadata: name: backup-verified-debb9e4c59c9 namespace: activity-core labels: railiance.io/workplan: RPF-WP-0029 immutable: true data: tools__cmd__forgejo-backup: "#!/usr/bin/env bash\n# tools/cmd/forgejo-backup \u2014\ \ Forgejo Option A backup (RAIL-HO-WP-0005 T04/T09)\n#\n# Daily: forgejo dump\ \ (blob state) + pg_dump (forgejo-db) \u2192 age \u2192 Nextcloud forgejo/\n#\ \ Retention: 14 daily + 4 weekly uploads (Sunday); local cache keep 7 per type.\n\ #\n# Env:\n# KUBECONFIG (default ~/.kube/config-hosteurope)\n\ # RAILIANCE_BACKUP_NC_TOKEN Nextcloud WebDAV token\n# RAILIANCE_BACKUP_DRY_RUN=1\ \ skip upload and remote side effects\nset -euo pipefail\n\n\nROOT=\"$(cd \"\ $(dirname \"${BASH_SOURCE[0]}\")/../..\" && pwd)\"\n# shellcheck source=lib/railiance-print.sh\n\ source \"${ROOT}/lib/railiance-print.sh\"\n# shellcheck source=lib/railiance-backup-common.sh\n\ source \"${ROOT}/lib/railiance-backup-common.sh\"\n\nKUBECONFIG=\"${KUBECONFIG:-${HOME}/.kube/config-hosteurope}\"\ \nexport KUBECONFIG\nDRY_RUN=\"${RAILIANCE_BACKUP_DRY_RUN:-0}\"\n\nFORGEJO_NAMESPACE=\"\ ${FORGEJO_NAMESPACE:-forgejo}\"\nFORGEJO_RELEASE=\"${FORGEJO_RELEASE:-forgejo}\"\ \nFORGEJO_DB_NAMESPACE=\"${FORGEJO_DB_NAMESPACE:-databases}\"\nFORGEJO_DB_CLUSTER=\"\ ${FORGEJO_DB_CLUSTER:-forgejo-db}\"\n\nkubectl_get_pod() {\n local ns=\"$1\"\ \ selector=\"$2\" attempt out\n for attempt in 1 2 3 4 5 6 7 8 9 10; do\n \ \ out=\"$(kubectl get pods -n \"${ns}\" -l \"${selector}\" \\\n -o jsonpath='{.items[0].metadata.name}'\ \ 2>/dev/null || true)\"\n if [[ -n \"${out}\" ]]; then\n echo \"${out}\"\ \n return 0\n fi\n sleep $((attempt * 3))\n done\n return 1\n}\n\n\ BACKUP_DIR=\"${XDG_CACHE_HOME:-$HOME/.cache}/railiance/backups/forgejo\"\nTS=\"\ $(date -u +%Y%m%dT%H%M%SZ)\"\nDOW=\"$(date -u +%u)\" # 7 = Sunday\n\nDUMP_PLAIN=\"\ ${BACKUP_DIR}/forgejo-dump-${TS}.zip\"\nDUMP_AGE=\"${DUMP_PLAIN}.age\"\nDB_PLAIN=\"\ ${BACKUP_DIR}/forgejo-db-${TS}.sql\"\nDB_AGE=\"${DB_PLAIN}.age\"\n\ncleanup_plain()\ \ {\n rm -f \"${DUMP_PLAIN}\" \"${DB_PLAIN}\"\n}\ntrap cleanup_plain EXIT\n\n\ if [[ \"${DRY_RUN}\" != \"1\" ]]; then\n railiance_backup_require_openbao_lane\n\ fi\nrailiance_backup_require_tools\n\nFORGEJO_DB_POD=\"${FORGEJO_DB_POD:-$(kubectl_get_pod\ \ \"${FORGEJO_DB_NAMESPACE}\" \\\n \"cnpg.io/cluster=${FORGEJO_DB_CLUSTER}\"\ \ || true)}\"\nif [[ -z \"${FORGEJO_DB_POD}\" ]]; then\n bad \"preflight\" \"\ forgejo-db pod not found (cluster ${FORGEJO_DB_CLUSTER})\"\n exit 1\nfi\n\nif\ \ ! kubectl wait --for=condition=Ready \"pod/${FORGEJO_DB_POD}\" \\\n -n \"${FORGEJO_DB_NAMESPACE}\"\ \ --timeout=180s >/dev/null 2>&1; then\n bad \"preflight\" \"forgejo-db pod not\ \ Ready (check: make forgejo-db-status)\"\n exit 1\nfi\n\nPROD_POD=\"$(kubectl_get_pod\ \ \"${FORGEJO_NAMESPACE}\" \\\n \"app.kubernetes.io/instance=${FORGEJO_RELEASE}\"\ \ || true)\"\nif [[ -z \"${PROD_POD}\" ]]; then\n bad \"preflight\" \"Forgejo\ \ app pod not found in ${FORGEJO_NAMESPACE}\"\n exit 1\nfi\n\nmkdir -p \"${BACKUP_DIR}\"\ \nprint_hdr \"Forgejo backup \u2014 ${TS}\"\nok \"kubeconfig\" \"${KUBECONFIG}\"\ \nok \"forgejo pod\" \"${FORGEJO_NAMESPACE}/${PROD_POD}\"\nok \"forgejo-db\" \"\ ${FORGEJO_DB_NAMESPACE}/${FORGEJO_DB_POD}\"\n\n# 1. Application dump (repos, packages,\ \ attachments, LFS, avatars)\nok \"forgejo dump\" \"running in pod\u2026\"\npython3\ \ \"${ROOT}/scripts/capture_forgejo_archive.py\" \\\n --namespace \"${FORGEJO_NAMESPACE}\"\ \ --pod \"${PROD_POD}\" --output \"${DUMP_PLAIN}\"\nok \"forgejo dump\" \"$(du\ \ -h \"${DUMP_PLAIN}\" | awk '{print $1}') $(basename \"${DUMP_PLAIN}\")\"\n\n\ # 2. PostgreSQL logical dump (plain SQL to stdout; CNPG root FS is read-only)\n\ ok \"pg_dump\" \"database forgejo\u2026\"\nkubectl exec -n \"${FORGEJO_DB_NAMESPACE}\"\ \ \"${FORGEJO_DB_POD}\" -c postgres -- \\\n pg_dump -U postgres -d forgejo --no-owner\ \ --no-acl > \"${DB_PLAIN}\"\nok \"pg_dump\" \"$(du -h \"${DB_PLAIN}\" | awk '{print\ \ $1}') $(basename \"${DB_PLAIN}\")\"\n\n# 3. Encrypt\nrailiance_backup_encrypt\ \ \"${DUMP_PLAIN}\" \"${DUMP_AGE}\"\nrailiance_backup_encrypt \"${DB_PLAIN}\"\ \ \"${DB_AGE}\"\nok \"age\" \"encrypted dump + db\"\n\nif [[ \"${DRY_RUN}\" ==\ \ \"1\" ]]; then\n warn \"dry-run\" \"skipping Nextcloud upload\"\nelse\n railiance_backup_nc_upload\ \ \"${DUMP_AGE}\" \"forgejo-dump-${TS}.zip.age\"\n ok \"upload\" \"forgejo-dump-${TS}.zip.age\"\ \n railiance_backup_nc_upload \"${DB_AGE}\" \"forgejo-db-${TS}.sql.age\"\n ok\ \ \"upload\" \"forgejo-db-${TS}.sql.age\"\n\n if [[ \"${DOW}\" == \"7\" ]]; then\n\ \ railiance_backup_nc_upload \"${DUMP_AGE}\" \"forgejo-dump-weekly-${TS}.zip.age\"\ \n railiance_backup_nc_upload \"${DB_AGE}\" \"forgejo-db-weekly-${TS}.sql.age\"\ \n ok \"weekly\" \"Sunday copies uploaded\"\n fi\n\n railiance_backup_record_success\ \ \"${BACKUP_DIR}\" \"${TS}\"\nfi\n\n# 4. Local cache prune (keep 7 per type)\n\ railiance_backup_prune_local \"${BACKUP_DIR}\" 'forgejo-dump-*.zip.age' 7\nrailiance_backup_prune_local\ \ \"${BACKUP_DIR}\" 'forgejo-db-*.sql.age' 7\nrailiance_backup_prune_local \"\ ${BACKUP_DIR}\" 'forgejo-dump-*.zip' 3\nrailiance_backup_prune_local \"${BACKUP_DIR}\"\ \ 'forgejo-db-*.sql' 3\nok \"cache\" \"local copies pruned\"\n\necho\nok \"done\"\ \ \"Forgejo backup complete \u2014 ${TS}\"\nif [[ \"${DRY_RUN}\" != \"1\" ]];\ \ then\n echo \" Dump: ${RAILIANCE_BACKUP_NC_PREFIX}/forgejo-dump-${TS}.zip.age\"\ \n echo \" DB: ${RAILIANCE_BACKUP_NC_PREFIX}/forgejo-db-${TS}.sql.age\"\ \n echo\n echo \" Decrypt with ~/.config/age/railiance-backup.key\"\n echo\ \ \" Restore drill: railiance-infra tools/forgejo-restore-drill.sh\"\nfi" scripts__capture_forgejo_archive.py: "#!/usr/bin/env python3\n\"\"\"Capture only\ \ a completed, byte-identical and CRC-verified Forgejo ZIP.\"\"\"\nimport argparse\n\ import hashlib\nfrom pathlib import Path\nimport secrets\nimport subprocess\n\ import time\nimport zipfile\n\n\nESSENTIALS_FLAGS = ['--skip-package-data', '--skip-log',\ \ '--skip-index', '--skip-repo-archives']\n\n\ndef validate_archive(path, profile=\"\ full\"):\n with zipfile.ZipFile(path) as archive:\n if sum(i.file_size\ \ for i in archive.infolist()) > 20*1024**3:\n raise ValueError('archive\ \ exceeds recovery size bound')\n names = set(archive.namelist())\n \ \ if 'forgejo-db.sql' not in names or not any(n.startswith('repos/') for n\ \ in names):\n raise ValueError('required archive content missing')\n\ \ if profile == 'essentials' and any(n.startswith(('data/packages/', 'data/repo-archive/',\ \ 'data/indexers/')) and not n.endswith('/') for n in names):\n raise\ \ ValueError('excluded data present in essentials archive')\n if archive.testzip()\ \ is not None:\n raise ValueError('archive checksum failure')\n\n\n\ def capture(namespace, pod, destination, profile=\"full\"):\n if profile not\ \ in (\"full\", \"essentials\"): raise ValueError(\"unknown archive profile\"\ )\n k = ['kubectl', 'exec', '--request-timeout=120s', '-n', namespace, pod,\ \ '-c', 'gitea', '--']\n def call(args, timeout=150):\n r = subprocess.run(k\ \ + args, capture_output=True, timeout=timeout)\n if r.returncode: raise\ \ ValueError('capture command failed')\n return r.stdout\n remote =\ \ '/tmp/wp0029-backup-' + secrets.token_hex(12)\n completed = False\n try:\n\ \ # Completion belongs to this exact process, not a global pgrep or file\ \ existence.\n flags = ' '.join(ESSENTIALS_FLAGS) if profile == 'essentials'\ \ else ''\n command = f'umask 077; forgejo dump {flags} -f {remote}.zip\ \ >{remote}.log 2>&1; result=$?; printf \"%s\" \"$result\" >{remote}.exit'\n \ \ call(['sh','-c', 'nohup sh -c \"$1\" >/dev/null 2>&1 /dev/null 2>&1 || return 0\n\ \ bao kv metadata get \"${RAILIANCE_BACKUP_BAO_PATH}\" >/dev/null 2>&1 || return\ \ 0\n : \"${RAILIANCE_BACKUP_NC_TOKEN:=$(bao kv get -field=NC_WEBDAV_TOKEN \"\ ${RAILIANCE_BACKUP_BAO_PATH}\" 2>/dev/null)}\"\n : \"${RAILIANCE_BACKUP_NC_WEBDAV_URL:=$(bao\ \ kv get -field=NC_WEBDAV_URL \"${RAILIANCE_BACKUP_BAO_PATH}\" 2>/dev/null)}\"\ \n}\n\nrailiance_backup_require_openbao_lane() {\n railiance_backup_load_openbao_lane\n\ \ if [[ -z \"${RAILIANCE_BACKUP_NC_TOKEN:-}\" ]]; then\n echo \"ERROR: governed\ \ backup credential unavailable (CCR-2026-0004)\" >&2\n return 1\n fi\n #\ \ Build a file-drop URL only after the required token is present.\n : \"${RAILIANCE_BACKUP_NC_WEBDAV_URL:=https://nx4069.your-storageshare.de/public.php/dav/filesdrop/${RAILIANCE_BACKUP_NC_TOKEN}}\"\ \n}\n\nrailiance_backup_require_tools() {\n # Prefer vendored static binaries\ \ (activity-core hostPath / offline runners).\n local root vendor\n root=\"\ $(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\n vendor=\"${root}/tools/vendor/bin\"\ \n if [[ -d \"$vendor\" ]]; then\n export PATH=\"${vendor}:${PATH}\"\n fi\n\ \ if [[ -n \"${RAILIANCE_BACKUP_AGE_BIN:-}\" && -x \"${RAILIANCE_BACKUP_AGE_BIN}\"\ \ ]]; then\n export PATH=\"$(dirname \"${RAILIANCE_BACKUP_AGE_BIN}\"):${PATH}\"\ \n fi\n local t\n for t in age kubectl; do\n command -v \"$t\" >/dev/null\ \ 2>&1 || { echo \"ERROR: missing required tool: $t (run tools/cmd/install-cnpg-backup-vendor-tools)\"\ \ >&2; exit 1; }\n done\n # curl optional \u2014 upload falls back to python3\ \ urllib (activity-core worker has no curl)\n if ! command -v curl >/dev/null\ \ 2>&1 && ! command -v python3 >/dev/null 2>&1; then\n echo \"ERROR: need curl\ \ or python3 for Nextcloud upload\" >&2\n exit 1\n fi\n}\n\nrailiance_backup_nc_upload()\ \ {\n local file=\"$1\" remote_name=\"$2\"\n local dest=\"${RAILIANCE_BACKUP_NC_WEBDAV_URL%/}/${RAILIANCE_BACKUP_NC_PREFIX}/${remote_name}\"\ \n if [[ \"$dest\" != https://* || \"$dest\" == *$'\\n'* || \"$dest\" == *$'\\\ r'* ||\n \"${RAILIANCE_BACKUP_NC_TOKEN}\" == *$'\\n'* || \"${RAILIANCE_BACKUP_NC_TOKEN}\"\ \ == *$'\\r'* ||\n \"$file\" == *$'\\n'* || \"$file\" == *$'\\r'* ]]; then\n\ \ echo \"ERROR: invalid backup upload input\" >&2\n return 1\n fi\n if\ \ command -v curl >/dev/null 2>&1; then\n # Credentials and credential-bearing\ \ URLs travel over stdin, never argv.\n local config_url config_token config_file\ \ status\n config_url=\"${dest//\\\\/\\\\\\\\}\"; config_url=\"${config_url//\\\ \"/\\\\\\\"}\"\n config_token=\"${RAILIANCE_BACKUP_NC_TOKEN//\\\\/\\\\\\\\\ }\"; config_token=\"${config_token//\\\"/\\\\\\\"}\"\n config_file=\"${file//\\\ \\/\\\\\\\\}\"; config_file=\"${config_file//\\\"/\\\\\\\"}\"\n status=\"$(printf\ \ 'url = \"%s\"\\nuser = \"%s:\"\\nupload-file = \"%s\"\\n' \\\n \"$config_url\"\ \ \"$config_token\" \"$config_file\" | \\\n curl --disable --silent --fail\ \ --proto '=https' --max-time 600 \\\n --header 'X-Requested-With: XMLHttpRequest'\ \ \\\n --output /dev/null --write-out '%{http_code}' --config - 2>/dev/null)\"\ \ \\\n || { echo \"ERROR: Nextcloud upload failed\" >&2; return 1; }\n \ \ case \"$status\" in\n 200|201|204) ;;\n *) echo \"ERROR: Nextcloud\ \ upload failed\" >&2; return 1 ;;\n esac\n return 0\n fi\n # Python fallback\ \ (worker image)\n RAILIANCE_BACKUP_NC_TOKEN=\"${RAILIANCE_BACKUP_NC_TOKEN}\"\ \ \\\n RAILIANCE_BACKUP_UPLOAD_URL=\"${dest}\" \\\n RAILIANCE_BACKUP_UPLOAD_FILE=\"\ ${file}\" \\\n python3 - <<'PY' || { echo \"ERROR: Nextcloud upload failed\"\ \ >&2; return 1; }\nimport os, urllib.request, urllib.error, base64, sys\nurl\ \ = os.environ[\"RAILIANCE_BACKUP_UPLOAD_URL\"]\npath = os.environ[\"RAILIANCE_BACKUP_UPLOAD_FILE\"\ ]\ntoken = os.environ[\"RAILIANCE_BACKUP_NC_TOKEN\"]\nclass NoRedirect(urllib.request.HTTPRedirectHandler):\n\ \ def redirect_request(self, req, fp, code, msg, headers, newurl):\n \ \ raise urllib.error.HTTPError(req.full_url, code, 'redirect refused', headers,\ \ fp)\ntry:\n with open(path, 'rb') as source:\n chunks = iter(lambda:\ \ source.read(1024 * 1024), b'')\n req = urllib.request.Request(url, data=chunks,\ \ method=\"PUT\")\n req.add_header(\"Authorization\", \"Basic \" + base64.b64encode(f\"\ {token}:\".encode()).decode())\n req.add_header(\"X-Requested-With\", \"\ XMLHttpRequest\")\n req.add_header(\"Content-Type\", \"application/octet-stream\"\ )\n req.add_header(\"Content-Length\", str(os.fstat(source.fileno()).st_size))\n\ \ with urllib.request.build_opener(NoRedirect()).open(req, timeout=600)\ \ as resp:\n if resp.status not in (200, 201, 204):\n \ \ sys.exit(1)\nexcept Exception:\n # URLs may themselves contain credentials:\ \ never emit exception text.\n sys.exit(1)\nPY\n}\n\nrailiance_backup_encrypt()\ \ {\n local src=\"$1\" dest=\"$2\"\n age -r \"${RAILIANCE_BACKUP_AGE_PUBLIC_KEY}\"\ \ -o \"$dest\" \"$src\"\n}\n\nrailiance_backup_prune_local() {\n local dir=\"\ $1\" pattern=\"$2\" keep=\"${3:-7}\"\n find \"$dir\" -maxdepth 1 -name \"$pattern\"\ \ -type f 2>/dev/null \\\n | sort -r | tail -n +\"$((keep + 1))\" | xargs -r\ \ rm -f\n}\n\nrailiance_backup_record_success() {\n local stamp_dir=\"$1\" ts=\"\ $2\"\n mkdir -p \"$stamp_dir\"\n echo \"$ts\" > \"${stamp_dir}/.last-success\"\ \n echo \"$ts\" >> \"${stamp_dir}/success-log\"\n}\n" lib__railiance-print.sh: "#!/usr/bin/env bash\n# lib/railiance-print.sh \u2014 shared\ \ pretty printers for platform operator tools\nset -euo pipefail\n\nprint_hdr()\ \ { printf \"\\n%s\\n\" \"$1\"; printf \"%0.s-\" $(seq 1 \"${#1}\"); echo; }\n\ ok() { printf \" \u2705 %-12s %s\\n\" \"$1\" \"$2\"; }\nwarn() { printf \"\ \ \u26A0\uFE0F %-12s %s\\n\" \"$1\" \"$2\"; }\nbad() { printf \" \u274C %-12s\ \ %s\\n\" \"$1\" \"$2\"; }" entrypoint: '#!/bin/sh exec /opt/railiance-backup-verified/tools/cmd/forgejo-backup "$@" '