From d0c7249a0d696d044e3fe814e0efde285465fe04 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 5 Sep 2026 22:10:22 +0200 Subject: [PATCH] Reject unfinished or truncated Forgejo backup archives before encryption Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883 --- scripts/capture_forgejo_archive.py | 72 +++++++++++++++++++ tests/test_forgejo_archive_integrity.py | 24 +++++++ tools/cmd/forgejo-backup | 95 +------------------------ 3 files changed, 98 insertions(+), 93 deletions(-) create mode 100644 scripts/capture_forgejo_archive.py create mode 100644 tests/test_forgejo_archive_integrity.py diff --git a/scripts/capture_forgejo_archive.py b/scripts/capture_forgejo_archive.py new file mode 100644 index 0000000..3d93a57 --- /dev/null +++ b/scripts/capture_forgejo_archive.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Capture only a completed, byte-identical and CRC-verified Forgejo ZIP.""" +import argparse +import hashlib +from pathlib import Path +import secrets +import subprocess +import time +import zipfile + + +def validate_archive(path): + with zipfile.ZipFile(path) as archive: + names = set(archive.namelist()) + if 'forgejo-db.sql' not in names or not any(n.startswith('repos/') for n in names): + raise ValueError('required archive content missing') + if archive.testzip() is not None: + raise ValueError('archive checksum failure') + + +def capture(namespace, pod, destination): + k = ['kubectl', 'exec', '--request-timeout=120s', '-n', namespace, pod, '-c', 'gitea', '--'] + def call(args, timeout=150): + r = subprocess.run(k + args, capture_output=True, timeout=timeout) + if r.returncode: raise ValueError('capture command failed') + return r.stdout + remote = '/tmp/wp0029-backup-' + secrets.token_hex(12) + completed = False + try: + # Completion belongs to this exact process, not a global pgrep or file existence. + command = f'umask 077; forgejo dump -f {remote}.zip >{remote}.log 2>&1; result=$?; printf "%s" "$result" >{remote}.exit' + call(['sh','-c', 'nohup sh -c "$1" >/dev/null 2>&1 /tmp/forgejo-dump.log 2>&1 &" -dump_wait=0 -while [[ "${dump_wait}" -lt 1800 ]]; do - if kubectl exec --request-timeout=30 -n "${FORGEJO_NAMESPACE}" "${PROD_POD}" -c gitea -- \ - test -s "${DUMP_REMOTE}" 2>/dev/null; then - if kubectl exec --request-timeout=30 -n "${FORGEJO_NAMESPACE}" "${PROD_POD}" -c gitea -- \ - sh -c "pgrep -f 'forgejo dump' >/dev/null"; then - sleep 10 - dump_wait=$((dump_wait + 10)) - continue - fi - break - fi - sleep 10 - dump_wait=$((dump_wait + 10)) -done -if ! kubectl exec --request-timeout=30 -n "${FORGEJO_NAMESPACE}" "${PROD_POD}" -c gitea -- \ - test -s "${DUMP_REMOTE}" 2>/dev/null; then - bad "forgejo dump" "timed out or empty archive (see /tmp/forgejo-dump.log in pod)" - exit 1 -fi -# Large dumps (~700M) fail via stdout stream or single kubectl cp — use chunks. -dump_size="$(kubectl exec -n "${FORGEJO_NAMESPACE}" "${PROD_POD}" -c gitea -- \ - stat -c%s "${DUMP_REMOTE}" 2>/dev/null || echo 0)" -refresh_prod_pod() { - PROD_POD="$(kubectl get pods -n "${FORGEJO_NAMESPACE}" \ - -l "app.kubernetes.io/instance=${FORGEJO_RELEASE}" \ - -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)" -} - -copy_from_pod() { - local remote_path="$1" local_path="$2" attempt - for attempt in 1 2 3 4 5; do - refresh_prod_pod - if [[ -z "${PROD_POD}" ]]; then - sleep $((attempt * 5)) - continue - fi - if kubectl cp -n "${FORGEJO_NAMESPACE}" -c gitea \ - "${FORGEJO_NAMESPACE}/${PROD_POD}:${remote_path}" "${local_path}" 2>/dev/null; then - return 0 - fi - if kubectl exec --request-timeout=120 -n "${FORGEJO_NAMESPACE}" "${PROD_POD}" -c gitea -- \ - cat "${remote_path}" > "${local_path}" 2>/dev/null && [[ -s "${local_path}" ]]; then - return 0 - fi - sleep $((attempt * 5)) - done - return 1 -} - -if [[ "${dump_size}" -lt 52428800 ]]; then - copy_from_pod "${DUMP_REMOTE}" "${DUMP_PLAIN}" || exit 1 -else - ok "forgejo dump" "chunked copy (${dump_size} bytes)…" - chunk_dir="$(mktemp -d)" - refresh_prod_pod - parts="$(kubectl exec --request-timeout=120 -n "${FORGEJO_NAMESPACE}" "${PROD_POD}" -c gitea -- \ - sh -c "rm -f /tmp/forgejo-backup-part-*; split -b 4m '${DUMP_REMOTE}' /tmp/forgejo-backup-part- && ls /tmp/forgejo-backup-part-*")" - for part in ${parts}; do - base="$(basename "${part}")" - local_chunk="${chunk_dir}/${base}" - if [[ -f "${local_chunk}" && -s "${local_chunk}" ]]; then - ok "forgejo dump" "reuse cached chunk ${base}" - continue - fi - chunk_ok=0 - for chunk_try in 1 2 3 4 5 6 7 8; do - if copy_from_pod "${part}" "${local_chunk}"; then - chunk_ok=1 - ok "forgejo dump" "chunk ${base} (${chunk_try})" - break - fi - sleep $((chunk_try * 3)) - done - if [[ "${chunk_ok}" -ne 1 ]]; then - bad "forgejo dump" "chunk copy failed: ${base}" - exit 1 - fi - done - cat "${chunk_dir}"/forgejo-backup-part-* > "${DUMP_PLAIN}" - rm -rf "${chunk_dir}" - kubectl exec -n "${FORGEJO_NAMESPACE}" "${PROD_POD}" -c gitea -- \ - rm -f /tmp/forgejo-backup-part-* "${DUMP_REMOTE}" || true -fi -if [[ ! -f "${DUMP_PLAIN}" || ! -s "${DUMP_PLAIN}" ]]; then - bad "forgejo dump" "failed to copy dump from pod" - exit 1 -fi +python3 "${ROOT}/scripts/capture_forgejo_archive.py" \ + --namespace "${FORGEJO_NAMESPACE}" --pod "${PROD_POD}" --output "${DUMP_PLAIN}" ok "forgejo dump" "$(du -h "${DUMP_PLAIN}" | awk '{print $1}') $(basename "${DUMP_PLAIN}")" # 2. PostgreSQL logical dump (plain SQL to stdout; CNPG root FS is read-only)