Files
cloudinit-bot 7c20d5d6e4 fix(review): point new gitea helpers at jc-capital owner (post-org-migration)
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---
2026-06-08 21:55:46 +00:00

147 lines
5.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# scripts/rotate_secrets.sh — rotate a Gitea repo secret in place (REQ-INB-21).
#
# Usage:
# scripts/rotate_secrets.sh [--apply] <secret_name>
#
# In --dry-run mode (default), the helper prints a redacted diff of
# the existing vs. proposed value and exits 0 without making the PUT.
# In --apply mode the helper PUTs the new value to the Gitea
# actions-secrets endpoint via pipelines.common.gitea_request. The
# Gitea actions-secrets PUT is in-place (it overwrites the existing
# secret at the same name); the runbook §6.4 ordering is preserved
# by verifying the new value is readable after the PUT.
#
# The new value is read from STDIN (never from a CLI argument) so it
# is not visible in `ps` / process listings.
#
# Environment:
# GITEA_INSTANCE_URL e.g. http://gitea:3000 or https://git.cloudinit.dev
# GITEA_USER HTTP Basic Auth user (the cto service account)
# GITEA_PASS HTTP Basic Auth password
# REPO_OWNER jc-capital (default)
# REPO_NAME inboxos (default)
#
# Exit codes:
# 0 success (or dry-run ok)
# 2 missing env / missing secret name / missing new value
# 3 Gitea API call failed
# 4 helper internal error (e.g. requests module import failure)
set -euo pipefail
REPO_OWNER="${REPO_OWNER:-jc-capital}"
REPO_NAME="${REPO_NAME:-inboxos}"
MODE="dry-run"
SECRET_NAME=""
if [[ "${1:-}" == "--apply" ]]; then
MODE="apply"
SECRET_NAME="${2:-}"
shift 2
else
SECRET_NAME="${1:-}"
shift 1
fi
if [[ -z "${SECRET_NAME}" ]]; then
echo "Usage: $0 [--apply] <secret_name>" >&2
echo " The new value is read from STDIN." >&2
exit 2
fi
if [[ -z "${GITEA_INSTANCE_URL:-}" || -z "${GITEA_USER:-}" || -z "${GITEA_PASS:-}" ]]; then
echo "GITEA_INSTANCE_URL, GITEA_USER, and GITEA_PASS are required." >&2
exit 2
fi
# Read the new value from STDIN.
NEW_VALUE="$(cat)"
if [[ -z "${NEW_VALUE}" ]]; then
echo "No new value provided on STDIN." >&2
exit 2
fi
# Re-redact: show a sha256:8hex digest instead of the literal value.
_redact() {
local v="$1"
python3 -c 'import hashlib,sys; print("sha256:"+hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:8])' <<<"$v"
}
NEW_DIGEST="$(_redact "${NEW_VALUE}")"
# A Gitea secret endpoint does NOT return the secret plaintext via
# the API (Gitea never reveals stored secrets, per the GitHub-compatible
# API contract). The "old value (redacted)" in the diff is therefore
# reported as the unknown-gitea-no-plaintext marker.
OLD_DIGEST='unknown (Gitea does not return secret plaintexts via the API)'
echo "[rotate_secrets] secret_name=${SECRET_NAME} mode=${MODE}"
echo "[rotate_secrets] old value (redacted): ${OLD_DIGEST}"
echo "[rotate_secrets] new value (redacted): ${NEW_DIGEST}"
if [[ "${MODE}" == "dry-run" ]]; then
echo "[rotate_secrets] dry-run ok; no PUT issued. Re-run with --apply to commit."
exit 0
fi
# Real run: PUT the new value via the Python helper, then verify.
export GITEA_INSTANCE_URL GITEA_USER GITEA_PASS
PUT_OUT="$(
python3 - "$SECRET_NAME" "$NEW_VALUE" <<'PY'
import json, os, sys
try:
from common import gitea_request, GiteaConfig
except Exception as exc:
print(f"helper-import-failed: {exc}", file=sys.stderr)
sys.exit(4)
cfg = GiteaConfig.from_env()
secret_name, new_value = sys.argv[1], sys.argv[2]
body = {"name": secret_name, "data": new_value, "kind": "secret"}
resp = gitea_request(cfg, "PUT",
f"/api/v1/repos/{os.environ.get('REPO_OWNER', 'jc-capital')}/"
f"{os.environ.get('REPO_NAME', 'inboxos')}/actions/secrets/"
f"{secret_name}",
json_body=body)
if not resp.ok:
print(f"helper-put-failed: status={resp.status_code} body={resp.text[:200]!r}",
file=sys.stderr)
sys.exit(3)
print(json.dumps(resp.json() if resp.content else {"ok": True}))
PY
)" || {
echo "[rotate_secrets] PUT failed: ${PUT_OUT}" >&2
exit 3
}
# Verify-after-PUT: GET the secret metadata. Gitea returns the secret
# name + the (last-update) timestamp but never the plaintext. A
# successful GET with the new last-update timestamp confirms the
# rotation landed.
VERIFY_OUT="$(
python3 - "$SECRET_NAME" <<'PY'
import json, os, sys
try:
from common import gitea_request, GiteaConfig
except Exception as exc:
print(f"helper-import-failed: {exc}", file=sys.stderr)
sys.exit(4)
cfg = GiteaConfig.from_env()
secret_name = sys.argv[1]
resp = gitea_request(cfg, "GET",
f"/api/v1/repos/{os.environ.get('REPO_OWNER', 'jc-capital')}/"
f"{os.environ.get('REPO_NAME', 'inboxos')}/actions/secrets/"
f"{secret_name}")
if not resp.ok:
print(f"helper-verify-failed: status={resp.status_code} body={resp.text[:200]!r}",
file=sys.stderr)
sys.exit(3)
print(json.dumps(resp.json() if resp.content else {"ok": True}))
PY
)" || {
echo "[rotate_secrets] verify-after-PUT failed: ${VERIFY_OUT}" >&2
exit 3
}
echo "[rotate_secrets] ok secret_name=${SECRET_NAME} put=${PUT_OUT} verify=${VERIFY_OUT}"
exit 0