7c20d5d6e4
P0 from v1.1 multi-persona code review (Pass 1, correctness sweep).
The three v1.1-introduced shell helpers (backup.sh, restore.sh,
rotate_secrets.sh) hardcoded the old cto-inboxos owner in the clone URL,
push URL, and default REPO_OWNER. With the org migration to jc-capital
landed at a068c8a, those hardcodes would fail at first invocation:
- scripts/backup.sh:151 clone URL
- scripts/restore.sh:145 push --mirror URL
- scripts/rotate_secrets.sh:32-33 default REPO_OWNER
- scripts/rotate_secrets.sh:101,131 string-literal fallback
The runbook §6.2 service-account line (562) also referenced the old
owner; the runbook lines that are CPO-managed were left alone.
---ci---
project: inboxos
phase: 1
milestone: v1.1
status: review
---/ci---
163 lines
7.1 KiB
Bash
Executable File
163 lines
7.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# scripts/backup.sh — snapshot a single InboxOS service (REQ-INB-22).
|
|
#
|
|
# Usage:
|
|
# scripts/backup.sh <service> # service: nocodb | seaweedfs | gitea
|
|
#
|
|
# Snapshots one of the three stateful services to a timestamped
|
|
# directory under backups/<service>/<utc-timestamp>/. The script is
|
|
# idempotent: a re-run for the same service at the same UTC
|
|
# timestamp is a no-op.
|
|
#
|
|
# Per-service:
|
|
# nocodb -> backs up via the NocoDB REST API. The output is
|
|
# a JSON dump of every base / table / row in the
|
|
# NOCODB_PROJECT_ID.
|
|
# seaweedfs -> mirrors the inboxos bucket to a second bucket on
|
|
# a different host. Requires SEAWEEDFS_S3_BACKUP_BUCKET
|
|
# and SEAWEEDFS_S3_BACKUP_ENDPOINT env vars to be
|
|
# set; exits 4 with a clear message if either is unset.
|
|
# gitea -> writes a `git bundle` of the jc-capital/inboxos
|
|
# repo to backups/gitea/<ts>/inboxos.bundle.
|
|
#
|
|
# The script prints a one-line summary on success:
|
|
# backup_id=<utc-timestamp> service=<svc> bytes=<int> target=<path>
|
|
#
|
|
# Environment:
|
|
# NOCODB_BASE_URL, NOCODB_API_TOKEN, NOCODB_PROJECT_ID (nocodb)
|
|
# SEAWEEDFS_S3_ENDPOINT, SEAWEEDFS_S3_ACCESS_KEY,
|
|
# SEAWEEDFS_S3_SECRET_KEY, SEAWEEDFS_S3_BUCKET (seaweedfs source)
|
|
# SEAWEEDFS_S3_BACKUP_BUCKET, SEAWEEDFS_S3_BACKUP_ENDPOINT (seaweedfs target)
|
|
# GITEA_INSTANCE_URL, GITEA_USER, GITEA_PASS (gitea)
|
|
#
|
|
# Exit codes:
|
|
# 0 success
|
|
# 2 missing service / missing env
|
|
# 3 Gitea API / NocoDB API call failed
|
|
# 4 backup target not configured (seaweedfs)
|
|
# 5 helper internal error
|
|
set -euo pipefail
|
|
|
|
SERVICE="${1:-}"
|
|
if [[ -z "${SERVICE}" ]]; then
|
|
echo "Usage: $0 <service> # service: nocodb | seaweedfs | gitea" >&2
|
|
exit 2
|
|
fi
|
|
|
|
# UTC timestamp in the form 2026-06-08T120000Z (filename-safe).
|
|
TS="$(date -u +%Y-%m-%dT%H%M%SZ)"
|
|
BACKUP_ROOT="backups/${SERVICE}/${TS}"
|
|
|
|
# ── NocoDB ───────────────────────────────────────────────
|
|
if [[ "${SERVICE}" == "nocodb" ]]; then
|
|
if [[ -z "${NOCODB_BASE_URL:-}" || -z "${NOCODB_API_TOKEN:-}" || -z "${NOCODB_PROJECT_ID:-}" ]]; then
|
|
echo "NOCODB_BASE_URL, NOCODB_API_TOKEN, and NOCODB_PROJECT_ID are required for nocodb backups." >&2
|
|
exit 2
|
|
fi
|
|
mkdir -p "${BACKUP_ROOT}"
|
|
# Use the NocoDB REST API to export the base. The v2 export
|
|
# endpoint produces a JSON dump of every table in the base.
|
|
EXPORT_URL="${NOCODB_BASE_URL%/}/api/v2/meta/bases/${NOCODB_PROJECT_ID}/export"
|
|
HTTP_CODE=$(
|
|
curl -sS -o "${BACKUP_ROOT}/nocodb-export.json" -w "%{http_code}" \
|
|
-H "xc-token: ${NOCODB_API_TOKEN}" \
|
|
-X POST "${EXPORT_URL}" 2>/dev/null \
|
|
|| echo "000"
|
|
)
|
|
if [[ "${HTTP_CODE}" != "200" && "${HTTP_CODE}" != "201" && "${HTTP_CODE}" != "202" ]]; then
|
|
echo "NocoDB export failed: HTTP ${HTTP_CODE}" >&2
|
|
exit 3
|
|
fi
|
|
BYTES=$(wc -c < "${BACKUP_ROOT}/nocodb-export.json" | tr -d ' ')
|
|
echo "backup_id=${TS} service=nocodb bytes=${BYTES} target=${BACKUP_ROOT}/nocodb-export.json"
|
|
exit 0
|
|
fi
|
|
|
|
# ── SeaweedFS ────────────────────────────────────────────
|
|
if [[ "${SERVICE}" == "seaweedfs" ]]; then
|
|
if [[ -z "${SEAWEEDFS_S3_BACKUP_BUCKET:-}" || -z "${SEAWEEDFS_S3_BACKUP_ENDPOINT:-}" ]]; then
|
|
echo "No backup target configured: SEAWEEDFS_S3_BACKUP_BUCKET and" >&2
|
|
echo "SEAWEEDFS_S3_BACKUP_ENDPOINT are unset. Configure the backup" >&2
|
|
echo "bucket + endpoint in Gitea repo variables, then re-run." >&2
|
|
exit 4
|
|
fi
|
|
if [[ -z "${SEAWEEDFS_S3_ENDPOINT:-}" || -z "${SEAWEEDFS_S3_ACCESS_KEY:-}" || -z "${SEAWEEDFS_S3_SECRET_KEY:-}" || -z "${SEAWEEDFS_S3_BUCKET:-}" ]]; then
|
|
echo "SEAWEEDFS_S3_ENDPOINT, _ACCESS_KEY, _SECRET_KEY, _BUCKET are required for seaweedfs backups." >&2
|
|
exit 2
|
|
fi
|
|
mkdir -p "${BACKUP_ROOT}"
|
|
# Mirror the inboxos bucket to the backup bucket via the AWS CLI
|
|
# (or fall back to a python3 + boto3 loop if awscli is missing).
|
|
if command -v aws >/dev/null 2>&1; then
|
|
aws --endpoint-url "${SEAWEEDFS_S3_BACKUP_ENDPOINT}" \
|
|
s3 sync "s3://${SEAWEEDFS_S3_BUCKET}" "s3://${SEAWEEDFS_S3_BACKUP_BUCKET}" \
|
|
--quiet
|
|
echo "aws sync complete"
|
|
else
|
|
# boto3 fallback: list source keys and copy each to the
|
|
# backup bucket. The script does not remove source keys.
|
|
python3 - <<'PY'
|
|
import os, sys
|
|
import boto3
|
|
src = boto3.client(
|
|
"s3",
|
|
endpoint_url=os.environ["SEAWEEDFS_S3_ENDPOINT"],
|
|
aws_access_key_id=os.environ["SEAWEEDFS_S3_ACCESS_KEY"],
|
|
aws_secret_access_key=os.environ["SEAWEEDFS_S3_SECRET_KEY"],
|
|
region_name="us-east-1",
|
|
)
|
|
dst = boto3.client(
|
|
"s3",
|
|
endpoint_url=os.environ["SEAWEEDFS_S3_BACKUP_ENDPOINT"],
|
|
aws_access_key_id=os.environ["SEAWEEDFS_S3_ACCESS_KEY"],
|
|
aws_secret_access_key=os.environ["SEAWEEDFS_S3_SECRET_KEY"],
|
|
region_name="us-east-1",
|
|
)
|
|
src_bucket = os.environ["SEAWEEDFS_S3_BUCKET"]
|
|
dst_bucket = os.environ["SEAWEEDFS_S3_BACKUP_BUCKET"]
|
|
paginator = src.get_paginator("list_objects_v2")
|
|
count = 0
|
|
for page in paginator.paginate(Bucket=src_bucket):
|
|
for obj in page.get("Contents", []):
|
|
copy_source = {"Bucket": src_bucket, "Key": obj["Key"]}
|
|
dst.copy(copy_source, obj["Key"])
|
|
count += 1
|
|
print(f"copied {count} objects from {src_bucket} to {dst_bucket}")
|
|
PY
|
|
fi
|
|
BYTES=$(du -sb "${BACKUP_ROOT}" 2>/dev/null | awk '{print $1}')
|
|
echo "backup_id=${TS} service=seaweedfs bytes=${BYTES:-0} target=${BACKUP_ROOT}/"
|
|
exit 0
|
|
fi
|
|
|
|
# ── Gitea ───────────────────────────────────────────────
|
|
if [[ "${SERVICE}" == "gitea" ]]; then
|
|
if [[ -z "${GITEA_INSTANCE_URL:-}" || -z "${GITEA_USER:-}" || -z "${GITEA_PASS:-}" ]]; then
|
|
echo "GITEA_INSTANCE_URL, GITEA_USER, and GITEA_PASS are required for gitea backups." >&2
|
|
exit 2
|
|
fi
|
|
mkdir -p "${BACKUP_ROOT}"
|
|
# `git bundle` of the jc-capital/inboxos repo. The bundle is a
|
|
# single file that captures the full history; restore.sh clones
|
|
# the bundle to a temp dir and pushes via the Gitea REST API.
|
|
BUNDLE_PATH="${BACKUP_ROOT}/inboxos.bundle"
|
|
if ! git bundle create "${BUNDLE_PATH}" --all 2>/dev/null; then
|
|
# Fall back: clone the repo from the Gitea REST API and
|
|
# bundle the result.
|
|
TMP_DIR="$(mktemp -d)"
|
|
git -c credential.helper= \
|
|
-c credential.username="${GITEA_USER}" \
|
|
-c credential.password="${GITEA_PASS}" \
|
|
clone "${GITEA_INSTANCE_URL%/}/jc-capital/inboxos.git" "${TMP_DIR}/inboxos" 2>&1 \
|
|
|| { echo "Gitea clone failed" >&2; rm -rf "${TMP_DIR}"; exit 3; }
|
|
(cd "${TMP_DIR}/inboxos" && git bundle create "${BUNDLE_PATH}" --all)
|
|
rm -rf "${TMP_DIR}"
|
|
fi
|
|
BYTES=$(wc -c < "${BUNDLE_PATH}" | tr -d ' ')
|
|
echo "backup_id=${TS} service=gitea bytes=${BYTES} target=${BUNDLE_PATH}"
|
|
exit 0
|
|
fi
|
|
|
|
echo "Unknown service: ${SERVICE}. Valid services: nocodb, seaweedfs, gitea." >&2
|
|
exit 2
|