activity-core/k8s/backup-release/bundle.yaml
tegwick afa1058062
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 42s
Record repaired backup lane and stage bounded consumer refresh packet
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
2026-10-03 21:58:57 +02:00

208 lines
17 KiB
YAML

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 &', 'sh',\
\ command])\n for _ in range(180):\n r = subprocess.run(k +\
\ ['cat',remote+'.exit'], capture_output=True, timeout=30)\n if r.returncode\
\ == 0:\n completed = True\n if r.stdout.strip()\
\ != b'0': raise ValueError('dump process failed')\n break\n \
\ time.sleep(10)\n else: raise ValueError('dump completion timeout')\n\
\ size = int(call(['stat','-c%s',remote+'.zip']).strip())\n expected\
\ = call(['sha256sum',remote+'.zip']).split()[0].decode()\n if size <=\
\ 0: raise ValueError('empty archive')\n chunk = 4*1024*1024\n with\
\ destination.open('xb') as out:\n destination.chmod(0o600)\n \
\ for index in range((size+chunk-1)//chunk):\n piece = call(['dd','if='+remote+'.zip','bs='+str(chunk),'skip='+str(index),'count=1'])\n\
\ if len(piece) != min(chunk,size-index*chunk): raise ValueError('short\
\ archive chunk')\n out.write(piece)\n with destination.open('rb')\
\ as source:\n actual = hashlib.file_digest(source,'sha256').hexdigest()\n\
\ if actual != expected: raise ValueError('archive transfer mismatch')\n\
\ validate_archive(destination, profile)\n if profile == \"essentials\"\
:\n from forgejo_essentials_profile import seal\n seal(destination)\n\
\ validate_archive(destination, profile)\n finally:\n # Do\
\ not remove a file while a timed-out producer may still be writing it.\n \
\ if completed:\n call(['rm','-f',remote+'.zip',remote+'.log',remote+'.exit'])\n\
\n\ndef main():\n p=argparse.ArgumentParser(description=__doc__)\n p.add_argument('--namespace',required=True);\
\ p.add_argument('--pod',required=True)\n p.add_argument('--output',required=True,type=Path)\n\
\ p.add_argument(\"--profile\",choices=[\"full\",\"essentials\"],default=\"\
full\")\n a=p.parse_args()\n try: capture(a.namespace,a.pod,a.output,a.profile)\n\
\ except Exception as error:\n print('ERROR: Forgejo archive capture\
\ or integrity validation failed ('+type(error).__name__+')')\n return\
\ 1\n print('Forgejo archive capture and integrity verified')\n return 0\n\
\nif __name__=='__main__': raise SystemExit(main())\n"
lib__railiance-backup-common.sh: "#!/usr/bin/env bash\n# lib/railiance-backup-common.sh\
\ \u2014 age encrypt + Nextcloud upload helpers (Option A)\nset -euo pipefail\n\
\n: \"${RAILIANCE_BACKUP_AGE_PUBLIC_KEY:=age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz}\"\
\n: \"${RAILIANCE_BACKUP_BAO_PATH:=platform/workloads/railiance/backup/offsite-lane}\"\
\n: \"${RAILIANCE_BACKUP_NC_PREFIX:=forgejo}\"\n\nrailiance_backup_load_openbao_lane()\
\ {\n if [[ -n \"${RAILIANCE_BACKUP_NC_TOKEN:-}\" && -n \"${RAILIANCE_BACKUP_NC_WEBDAV_URL:-}\"\
\ ]]; then\n return 0\n fi\n command -v bao >/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 "$@"
'