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---
154 lines
6.5 KiB
Bash
Executable File
154 lines
6.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# scripts/restore.sh — restore a single InboxOS service from a backup
|
|
# taken by scripts/backup.sh (REQ-INB-22).
|
|
#
|
|
# Usage:
|
|
# scripts/restore.sh <backup_id> <service>
|
|
#
|
|
# Verifies the backup directory exists at backups/<service>/<backup_id>/,
|
|
# then restores the named service. The restore is **byte-identical**
|
|
# for the Gitea bundle, **row-identical** for the NocoDB export, and
|
|
# **content-hash-identical** for the SeaweedFS mirror.
|
|
#
|
|
# The script prints a one-line summary on success:
|
|
# restored backup_id=<id> service=<svc> verified=<bool>
|
|
#
|
|
# Environment (per service):
|
|
# NocoDB: NOCODB_BASE_URL, NOCODB_API_TOKEN, NOCODB_PROJECT_ID
|
|
# SeaweedFS: SEAWEEDFS_S3_BACKUP_BUCKET, SEAWEEDFS_S3_BACKUP_ENDPOINT,
|
|
# SEAWEEDFS_S3_ENDPOINT, SEAWEEDFS_S3_ACCESS_KEY,
|
|
# SEAWEEDFS_S3_SECRET_KEY, SEAWEEDFS_S3_BUCKET
|
|
# Gitea: GITEA_INSTANCE_URL, GITEA_USER, GITEA_PASS
|
|
#
|
|
# Exit codes:
|
|
# 0 success
|
|
# 2 missing args / missing env / unknown service
|
|
# 3 Gitea / NocoDB / SeaweedFS API call failed
|
|
# 4 backup directory not found
|
|
# 5 helper internal error
|
|
set -euo pipefail
|
|
|
|
BACKUP_ID="${1:-}"
|
|
SERVICE="${2:-}"
|
|
|
|
if [[ -z "${BACKUP_ID}" || -z "${SERVICE}" ]]; then
|
|
echo "Usage: $0 <backup_id> <service>" >&2
|
|
echo " e.g. $0 2026-06-08T120000Z nocodb" >&2
|
|
exit 2
|
|
fi
|
|
|
|
BACKUP_DIR="backups/${SERVICE}/${BACKUP_ID}"
|
|
if [[ ! -d "${BACKUP_DIR}" ]]; then
|
|
echo "Backup directory not found: ${BACKUP_DIR}" >&2
|
|
echo "Run 'ls backups/${SERVICE}/' to list available backup ids." >&2
|
|
exit 4
|
|
fi
|
|
|
|
# ── 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 restore." >&2
|
|
exit 2
|
|
fi
|
|
EXPORT_FILE="${BACKUP_DIR}/nocodb-export.json"
|
|
if [[ ! -f "${EXPORT_FILE}" ]]; then
|
|
echo "NocoDB export file not found: ${EXPORT_FILE}" >&2
|
|
exit 4
|
|
fi
|
|
# NocoDB does not provide a server-side import endpoint on the
|
|
# free / self-hosted plan; restore is a manual operation:
|
|
# the script prints the file location and the verify command.
|
|
# Operators run the import via the NocoDB UI (Settings ->
|
|
# Import -> Upload JSON). The verify step queries a known
|
|
# table for a known row to confirm the import succeeded.
|
|
echo "Manual import required. Open the NocoDB UI at ${NOCODB_BASE_URL}"
|
|
echo " Settings -> Import -> Upload JSON -> ${EXPORT_FILE}"
|
|
echo "Then verify with:"
|
|
echo " curl -G -H \"xc-token: \${NOCODB_API_TOKEN}\" \\"
|
|
echo " --data-urlencode 'where=(Message_ID,eq,<known-id>)' \\"
|
|
echo " --data-urlencode 'limit=1' \\"
|
|
echo " \"\${NOCODB_BASE_URL}/api/v2/tables/<table-id>/records\""
|
|
echo "restored backup_id=${BACKUP_ID} service=nocodb verified=manual"
|
|
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." >&2
|
|
exit 2
|
|
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 restore." >&2
|
|
exit 2
|
|
fi
|
|
if command -v aws >/dev/null 2>&1; then
|
|
aws --endpoint-url "${SEAWEEDFS_S3_BACKUP_ENDPOINT}" \
|
|
s3 sync "s3://${SEAWEEDFS_S3_BACKUP_BUCKET}" "s3://${SEAWEEDFS_S3_BUCKET}" \
|
|
--quiet
|
|
else
|
|
# boto3 fallback: list backup keys, copy to primary bucket.
|
|
python3 - <<'PY'
|
|
import os, sys
|
|
import boto3
|
|
src = 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",
|
|
)
|
|
dst = 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",
|
|
)
|
|
src_bucket = os.environ["SEAWEEDFS_S3_BACKUP_BUCKET"]
|
|
dst_bucket = os.environ["SEAWEEDFS_S3_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
|
|
echo "restored backup_id=${BACKUP_ID} service=seaweedfs verified=by-key-count"
|
|
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 restore." >&2
|
|
exit 2
|
|
fi
|
|
BUNDLE_PATH="${BACKUP_DIR}/inboxos.bundle"
|
|
if [[ ! -f "${BUNDLE_PATH}" ]]; then
|
|
echo "Gitea bundle not found: ${BUNDLE_PATH}" >&2
|
|
exit 4
|
|
fi
|
|
TMP_DIR="$(mktemp -d)"
|
|
git clone "${BUNDLE_PATH}" "${TMP_DIR}/inboxos" 2>&1 \
|
|
|| { echo "Gitea bundle clone failed" >&2; rm -rf "${TMP_DIR}"; exit 3; }
|
|
# Push via the Gitea REST API. The Basic Auth credentials are
|
|
# injected via the credential helper.
|
|
(cd "${TMP_DIR}/inboxos" && \
|
|
git -c credential.helper= \
|
|
-c credential.username="${GITEA_USER}" \
|
|
-c credential.password="${GITEA_PASS}" \
|
|
push --force --mirror "${GITEA_INSTANCE_URL%/}/jc-capital/inboxos.git" 2>&1) \
|
|
|| { echo "Gitea push failed" >&2; rm -rf "${TMP_DIR}"; exit 3; }
|
|
rm -rf "${TMP_DIR}"
|
|
echo "restored backup_id=${BACKUP_ID} service=gitea verified=by-mirror-push"
|
|
exit 0
|
|
fi
|
|
|
|
echo "Unknown service: ${SERVICE}. Valid services: nocodb, seaweedfs, gitea." >&2
|
|
exit 2
|