From 67acd53946dceb3dd718d197798ab7b6e15e7a70 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 22 Jun 2026 02:22:57 +0000 Subject: [PATCH 1/3] feat(scripts/ops): add prune_cf_e2e_dns.sh Add a dry-run-by-default Cloudflare DNS pruning tool for stale e2e-smoke-* and e2e-tmpl-* test records that exhaust the zone record quota (code 81045). Requires explicit --apply or PRUNE_APPLY=1 to delete; aborts on non-2xx Cloudflare API responses. Co-Authored-By: Claude --- scripts/ops/prune_cf_e2e_dns.sh | 254 ++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100755 scripts/ops/prune_cf_e2e_dns.sh diff --git a/scripts/ops/prune_cf_e2e_dns.sh b/scripts/ops/prune_cf_e2e_dns.sh new file mode 100755 index 000000000..11dd5fe08 --- /dev/null +++ b/scripts/ops/prune_cf_e2e_dns.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# prune_cf_e2e_dns.sh — prune stale ephemeral e2e DNS records from Cloudflare. +# +# Purpose: +# Staging tenant provisioning repeatedly exhausts the Cloudflare DNS +# record quota (error 81045) because e2e-smoke-* and e2e-tmpl-* test +# records are not cleaned up after ephemeral test runs. This script +# lists records in the zone, filters to clearly disposable test names, +# and deletes records older than a configurable age threshold. +# +# Safety: +# - DRY-RUN by default. It only prints what WOULD be deleted. +# - Requires explicit --apply or PRUNE_APPLY=1 to actually delete. +# - Skips anything that does NOT match the e2e-smoke-* or e2e-tmpl-* +# prefixes (optionally anchored to the moleculesai.app zone). +# - Aborts on any non-2xx Cloudflare API response (curl -f). +# - Reports counts and exits non-zero on API or validation errors. +# +# Env vars required: +# CF_API_TOKEN — Cloudflare API token with Zone:DNS:Edit on the zone. +# CF_ZONE_ID — Cloudflare zone ID (e.g. for moleculesai.app). +# +# Optional env vars: +# PRUNE_MIN_AGE_HOURS — only delete records older than N hours (default: 24). +# PRUNE_APPLY — set to 1 to actually delete (default dry-run). +# +# Usage: +# # Dry-run: print what would be pruned +# CF_API_TOKEN=xxx CF_ZONE_ID=yyy ./scripts/ops/prune_cf_e2e_dns.sh +# +# # Actually prune records older than 6 hours +# CF_API_TOKEN=xxx CF_ZONE_ID=yyy PRUNE_MIN_AGE_HOURS=6 PRUNE_APPLY=1 \ +# ./scripts/ops/prune_cf_e2e_dns.sh --apply +# +# Long-term: +# A scheduled post-run step in .gitea/workflows (e.g. after each E2E +# staging-saas run) is the better permanent fix, so this quota blocker +# does not recur. This script is the ready-to-run tool pending that +# workflow change and a scoped CF token. + +set -euo pipefail + +APPLY=0 +MIN_AGE_HOURS="${PRUNE_MIN_AGE_HOURS:-24}" +ZONE_DOMAIN="${PRUNE_ZONE_DOMAIN:-moleculesai.app}" + +for arg in "$@"; do + case "$arg" in + --apply) APPLY=1 ;; + --help|-h) + grep '^#' "$0" | head -55 | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "unknown arg: $arg (use --help)" >&2 + exit 1 + ;; + esac +done + +[ "${PRUNE_APPLY:-0}" = "1" ] && APPLY=1 + +need() { + local var="$1" + if [ -z "${!var:-}" ]; then + echo "ERROR: $var is required" >&2 + exit 1 + fi +} +need CF_API_TOKEN +need CF_ZONE_ID + +if ! [[ "$MIN_AGE_HOURS" =~ ^[0-9]+$ ]]; then + echo "ERROR: PRUNE_MIN_AGE_HOURS must be a non-negative integer" >&2 + exit 1 +fi + +log() { echo "[$(date -u +%H:%M:%S)] $*"; } + +log "Pruning ephemeral e2e DNS records (min-age=${MIN_AGE_HOURS}h, apply=${APPLY})..." + +# Validate CF token + zone reachability before any list/delete work. +PF_JSON=$(curl -sS -f -m 10 -H "Authorization: Bearer $CF_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/user/tokens/verify" 2>/dev/null) || { + log "ERROR: CF token verify failed (non-2xx or network error)." + exit 1 +} +if ! python3 -c " +import json, sys +p = json.loads(sys.stdin.read()) +if not p.get('success'): + print('ERROR: CF token verify returned success=false', file=sys.stderr) + sys.exit(1) +status = (p.get('result') or {}).get('status', '?') +if status != 'active': + print(f'ERROR: CF token not active (status={status})', file=sys.stderr) + sys.exit(1) +" <<< "$PF_JSON"; then + exit 1 +fi +log " CF token active ✓" + +ZONE_JSON=$(curl -sS -f -m 10 -H "Authorization: Bearer $CF_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID" 2>/dev/null) || { + log "ERROR: zone lookup failed (non-2xx or network error)." + exit 1 +} +if ! python3 -c " +import json, os, sys +p = json.loads(sys.stdin.read()) +if not p.get('success'): + print('ERROR: zone lookup returned success=false', file=sys.stderr) + sys.exit(1) +res = p.get('result') or {} +if res.get('id') != os.environ['CF_ZONE_ID']: + print('ERROR: zone id mismatch', file=sys.stderr) + sys.exit(1) +" <<< "$ZONE_JSON"; then + exit 1 +fi +log " zone $CF_ZONE_ID reachable ✓" + +# Fetch all DNS records, paginated. Use a temp file for the raw list. +TMPDIR=$(mktemp -d -t cf-e2e-prune-XXXXXX) +LIST_FILE="$TMPDIR/records.json" +trap 'rm -rf "$TMPDIR"' EXIT + +PAGE=1 +while :; do + PAGE_FILE="$TMPDIR/page-$(printf '%05d' "$PAGE").json" + curl -sS -f -m 30 -H "Authorization: Bearer $CF_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records?per_page=100&page=$PAGE" \ + > "$PAGE_FILE" 2>/dev/null || { + log "ERROR: DNS list page $PAGE failed (non-2xx or network error)." + exit 1 + } + if ! python3 -c " +import json, sys +try: + p = json.load(open(sys.argv[1])) +except Exception as e: + print(f'ERROR: non-JSON DNS list response: {e}', file=sys.stderr) + sys.exit(1) +if not p.get('success'): + print('ERROR: DNS list returned success=false', file=sys.stderr) + sys.exit(1) +if not isinstance(p.get('result'), list): + print('ERROR: DNS list result is not an array', file=sys.stderr) + sys.exit(1) +" "$PAGE_FILE"; then + exit 1 + fi + RESULT_COUNT=$(python3 -c "import json; print(len(json.load(open(sys.argv[1]))['result']))" "$PAGE_FILE") + if [ "$RESULT_COUNT" -eq 0 ]; then + break + fi + PAGE=$((PAGE + 1)) + if [ "$PAGE" -gt 1000 ]; then + log "WARNING: stopping pagination at page 1000 (100k records) — investigate if more." + break + fi +done + +python3 - ' +import glob, json, os, re, sys +from datetime import datetime, timezone, timedelta + +min_age_hours = int(os.environ["MIN_AGE_HOURS"]) +zone_domain = os.environ["ZONE_DOMAIN"] +cutoff = datetime.now(timezone.utc) - timedelta(hours=min_age_hours) + +# Clearly ephemeral test prefixes only. Require the record to live under +# the configured zone domain so we never match a similarly-named record +# in a different zone. +EPHEMERAL_RE = re.compile(r"^(e2e-smoke|e2e-tmpl)[a-zA-Z0-9_-]*\." + re.escape(zone_domain) + r"$") + +records = [] +for f in sorted(glob.glob(os.path.join(sys.argv[1], "page-*.json"))): + with open(f) as fh: + records.extend(json.load(fh).get("result") or []) + +def parse_iso(s): + if not s: + return None + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + except ValueError: + return None + +candidates = [] +for r in records: + name = r.get("name", "") + rid = r.get("id", "") + if not EPHEMERAL_RE.match(name): + continue + created = parse_iso(r.get("created_on")) + if created is None: + # If we cannot establish age, keep it for safety. + continue + if created >= cutoff: + continue + candidates.append({"id": rid, "name": name, "type": r.get("type", "?"), "created_on": r.get("created_on")}) + +print(json.dumps(candidates)) +' "$TMPDIR" > "$LIST_FILE" + +CANDIDATES_COUNT=$(python3 -c "import json; print(len(json.load(open(sys.argv[1]))))" "$LIST_FILE") +log "Found $CANDIDATES_COUNT candidate record(s) older than ${MIN_AGE_HOURS}h matching ephemeral prefixes." + +if [ "$CANDIDATES_COUNT" -eq 0 ]; then + log "Nothing to prune." + exit 0 +fi + +if [ "$APPLY" -ne 1 ]; then + log "" + log "DRY RUN — the following $CANDIDATES_COUNT record(s) WOULD be deleted." + log "Pass --apply or set PRUNE_APPLY=1 to actually delete." + log "" + python3 -c " +import json +for r in json.load(open(sys.argv[1])): + print(f\" {r['type']:6s} {r['name']:<60s} created={r['created_on']}\") +" "$LIST_FILE" + exit 0 +fi + +# Apply deletes. +log "" +log "APPLY — deleting $CANDIDATES_COUNT record(s)..." +DELETED=0 +FAILED=0 +while IFS= read -r line; do + rid=$(echo "$line" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['id'])") + name=$(echo "$line" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['name'])") + if curl -sS -f -m 15 -X DELETE \ + -H "Authorization: Bearer $CF_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records/$rid" \ + >/dev/null 2>&1; then + log " deleted: $name" + DELETED=$((DELETED + 1)) + else + log " FAILED: $name" + FAILED=$((FAILED + 1)) + fi +done < <(python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)))" < "$LIST_FILE" | python3 -c " +import json, sys +for r in json.load(sys.stdin): + print(json.dumps(r)) +") + +log "" +log "Done. deleted=$DELETED failed=$FAILED" +[ "$FAILED" -eq 0 ] -- 2.52.0 From 027c057f3635702dbb394bf6df71e94a5fa291f8 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 22 Jun 2026 02:54:59 +0000 Subject: [PATCH 2/3] feat(scripts/ops): prune_cf_e2e_dns.sh + recurrence workflow + fail-closed test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the Cloudflare DNS e2e-record prune tool and land the durable recurrence fix together: - scripts/ops/prune_cf_e2e_dns.sh: * URL-aware curl mock style, CF token/zone preflight validation. * Dry-run by default; requires --apply / PRUNE_APPLY=1. * --min-age-hours arg + PRUNE_MIN_AGE_HOURS env. * MAX_DELETE_PCT safety gate (default 50) refusing runaway deletes. * CF_API_TOKEN/CLOUDFLARE_API_TOKEN and CF_ZONE_ID/CLOUDFLARE_ZONE_ID fallback aliases. * Paginates DNS list API, aborts on non-2xx / malformed JSON. - .gitea/workflows/e2e-staging-saas.yml: * Add prune-stale-e2e-dns post-run job after e2e-staging-saas. * Runs always(), gated on CF_STAGING_DNS_API_TOKEN + CF_STAGING_ZONE_ID secrets, --apply --min-age-hours 2. * Best-effort (continue-on-error) so CF blips don't block merge. - tests/ops/test_prune_cf_e2e_dns_fail_closed.sh: * Boundary test proving abort on non-2xx / malformed / non-array CF list. * Sentinel proving delete step is NOT reached in abort cases. * Proves young / non-ephemeral records are kept. * Happy-path control proving old e2e-smoke record reaches delete. Local tests: bash tests/ops/test_prune_cf_e2e_dns_fail_closed.sh # 6/6 pass Relates-to: #3139 (sweep-cf-orphans is the orphan-based general sweeper; this is the targeted e2e-test-record pruner + scheduled recurrence fix; complementary, not redundant). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .gitea/workflows/e2e-staging-saas.yml | 47 ++ scripts/ops/prune_cf_e2e_dns.sh | 462 +++++++++++------- .../ops/test_prune_cf_e2e_dns_fail_closed.sh | 151 ++++++ 3 files changed, 496 insertions(+), 164 deletions(-) create mode 100755 tests/ops/test_prune_cf_e2e_dns_fail_closed.sh diff --git a/.gitea/workflows/e2e-staging-saas.yml b/.gitea/workflows/e2e-staging-saas.yml index 40997fb93..c216fde74 100644 --- a/.gitea/workflows/e2e-staging-saas.yml +++ b/.gitea/workflows/e2e-staging-saas.yml @@ -354,6 +354,53 @@ jobs: fi exit 0 + # ── POST-RUN DNS PRUNE (core#81045 recurrence fix) ─────────────────────────── + # + # The full-lifecycle E2E harness creates e2e-smoke-* and e2e-tmpl-* DNS + # records under the staging zone. When teardown is skipped (runner cancel, + # CP transient error, trap miss) these records leak and eventually exhaust + # the Cloudflare DNS record quota (error 81045), blocking staging tenant + # provisioning. This job runs after every real E2E run and prunes records + # older than a conservative age threshold. + # + # Design notes: + # - needs: e2e-staging-saas so it only runs when the real E2E job fires + # (push/dispatch/cron), not on PRs. + # - if: always() so it runs even when the E2E job fails or is cancelled, + # which is exactly when records are most likely to leak. + # - continue-on-error: true — pruning is best-effort janitorial cleanup; + # a transient CF API blip here must not block the merge gate. The + # sweep-stale-e2e-orgs workflow is the backstop. + # - Token and zone id come from repository secrets ONLY; never hardcoded. + # - --min-age-hours is conservative (2h) so in-flight records from a long + # E2E run or a recently-started dispatch are never touched. + prune-stale-e2e-dns: + name: Prune stale e2e DNS records + runs-on: ubuntu-latest + needs: e2e-staging-saas + if: always() + continue-on-error: true + timeout-minutes: 10 + permissions: + contents: read + env: + CF_API_TOKEN: ${{ secrets.CF_STAGING_DNS_API_TOKEN }} + CF_ZONE_ID: ${{ secrets.CF_STAGING_ZONE_ID }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Dry-run preview (read-only) + if: env.CF_API_TOKEN == '' || env.CF_ZONE_ID == '' + run: | + echo "::warning::CF_STAGING_DNS_API_TOKEN or CF_STAGING_ZONE_ID not configured — skipping DNS prune." + exit 0 + + - name: Prune stale e2e DNS records + if: env.CF_API_TOKEN != '' && env.CF_ZONE_ID != '' + run: | + set -euo pipefail + ./scripts/ops/prune_cf_e2e_dns.sh --apply --min-age-hours 2 + # ── PLATFORM-MANAGED BOOT REGRESSION (moonshot/kimi NOT_CONFIGURED) ────────── # # The REAL-boot complement to the deterministic unit suite diff --git a/scripts/ops/prune_cf_e2e_dns.sh b/scripts/ops/prune_cf_e2e_dns.sh index 11dd5fe08..fe6523880 100755 --- a/scripts/ops/prune_cf_e2e_dns.sh +++ b/scripts/ops/prune_cf_e2e_dns.sh @@ -1,64 +1,82 @@ #!/usr/bin/env bash -# prune_cf_e2e_dns.sh — prune stale ephemeral e2e DNS records from Cloudflare. +# prune_cf_e2e_dns.sh — targeted, fail-closed cleanup of disposable E2E DNS +# records that accumulate under the moleculesai.app zone and exhaust the +# Cloudflare DNS record quota (code 81045). # -# Purpose: -# Staging tenant provisioning repeatedly exhausts the Cloudflare DNS -# record quota (error 81045) because e2e-smoke-* and e2e-tmpl-* test -# records are not cleaned up after ephemeral test runs. This script -# lists records in the zone, filters to clearly disposable test names, -# and deletes records older than a configurable age threshold. +# Why this exists: staging E2E harnesses create DNS records for slugs like +# e2e-smoke--- and e2e-tmpl- (see +# tests/e2e/test_staging_full_saas.sh and tests/e2e/test_template_delivery_e2e.sh). +# When teardown is skipped (CI cancellation, runner crash, transient CP error), +# these records leak. Cloudflare caps DNS records per zone; once the cap is +# hit, new tenant provisioning fails with CF code 81045. This script is the +# immediate unblock tool: it deletes clearly-ephemeral test records by pattern +# + age, independent of CP state. # -# Safety: -# - DRY-RUN by default. It only prints what WOULD be deleted. -# - Requires explicit --apply or PRUNE_APPLY=1 to actually delete. -# - Skips anything that does NOT match the e2e-smoke-* or e2e-tmpl-* -# prefixes (optionally anchored to the moleculesai.app zone). -# - Aborts on any non-2xx Cloudflare API response (curl -f). -# - Reports counts and exits non-zero on API or validation errors. +# Scope (conservative): +# - Records whose full name matches +# e2e-smoke-*. +# e2e-tmpl-*. +# - Records older than --min-age-hours / PRUNE_MIN_AGE_HOURS (default 24) +# so in-flight runs are not touched. +# - Anything else is kept untouched. # -# Env vars required: -# CF_API_TOKEN — Cloudflare API token with Zone:DNS:Edit on the zone. -# CF_ZONE_ID — Cloudflare zone ID (e.g. for moleculesai.app). +# Dry-run by default; must pass --apply (or set PRUNE_APPLY=1) to delete. # -# Optional env vars: -# PRUNE_MIN_AGE_HOURS — only delete records older than N hours (default: 24). -# PRUNE_APPLY — set to 1 to actually delete (default dry-run). +# Required env: +# CF_API_TOKEN — Cloudflare API token with Zone:DNS:Edit on the target zone. +# Falls back to CLOUDFLARE_API_TOKEN. +# CF_ZONE_ID — Cloudflare zone id for moleculesai.app (or staging zone). +# Falls back to CLOUDFLARE_ZONE_ID. # -# Usage: -# # Dry-run: print what would be pruned -# CF_API_TOKEN=xxx CF_ZONE_ID=yyy ./scripts/ops/prune_cf_e2e_dns.sh +# Optional env: +# PRUNE_APPLY=1 — same as --apply (both accepted). +# PRUNE_MIN_AGE_HOURS= — default minimum age in hours (default: 24). +# MAX_DELETE_PCT= — refuse to delete more than this percentage of +# matched ephemeral records (default: 50). +# PRUNE_ZONE_DOMAIN= — zone domain to anchor matches (default: moleculesai.app). # -# # Actually prune records older than 6 hours -# CF_API_TOKEN=xxx CF_ZONE_ID=yyy PRUNE_MIN_AGE_HOURS=6 PRUNE_APPLY=1 \ -# ./scripts/ops/prune_cf_e2e_dns.sh --apply -# -# Long-term: -# A scheduled post-run step in .gitea/workflows (e.g. after each E2E -# staging-saas run) is the better permanent fix, so this quota blocker -# does not recur. This script is the ready-to-run tool pending that -# workflow change and a scoped CF token. +# Exit codes: +# 0 — dry-run completed or prune executed successfully +# 1 — missing required env, API failure, or unexpected state +# 2 — safety gate refused the prune set -euo pipefail -APPLY=0 +DRY_RUN=1 MIN_AGE_HOURS="${PRUNE_MIN_AGE_HOURS:-24}" +MAX_DELETE_PCT="${MAX_DELETE_PCT:-50}" ZONE_DOMAIN="${PRUNE_ZONE_DOMAIN:-moleculesai.app}" -for arg in "$@"; do - case "$arg" in - --apply) APPLY=1 ;; +while [ $# -gt 0 ]; do + case "$1" in + --apply|--execute|--no-dry-run) DRY_RUN=0; shift ;; + --min-age-hours) + shift + MIN_AGE_HOURS="${1:-}" + if ! [[ "$MIN_AGE_HOURS" =~ ^[0-9]+$ ]]; then + echo "ERROR: --min-age-hours requires a non-negative integer" >&2 + exit 1 + fi + shift + ;; --help|-h) - grep '^#' "$0" | head -55 | sed 's/^# \{0,1\}//' + sed -n '1,/^set -euo pipefail$/p' "$0" | grep '^#' | sed 's/^# \{0,1\}//' exit 0 ;; + --*) + echo "unknown arg: $1 (use --help)" >&2 + exit 1 + ;; *) - echo "unknown arg: $arg (use --help)" >&2 + echo "unknown arg: $1 (use --help)" >&2 exit 1 ;; esac done -[ "${PRUNE_APPLY:-0}" = "1" ] && APPLY=1 +if [ "${PRUNE_APPLY:-0}" = "1" ]; then + DRY_RUN=0 +fi need() { local var="$1" @@ -67,188 +85,304 @@ need() { exit 1 fi } + +# Accept canonical operator-host names OR CI-scoped names. +CF_API_TOKEN="${CF_API_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}" +CF_ZONE_ID="${CF_ZONE_ID:-${CLOUDFLARE_ZONE_ID:-}}" + need CF_API_TOKEN need CF_ZONE_ID +if ! command -v curl >/dev/null 2>&1; then + echo "ERROR: curl is required" >&2 + exit 1 +fi +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 is required" >&2 + exit 1 +fi + if ! [[ "$MIN_AGE_HOURS" =~ ^[0-9]+$ ]]; then - echo "ERROR: PRUNE_MIN_AGE_HOURS must be a non-negative integer" >&2 + echo "ERROR: PRUNE_MIN_AGE_HOURS/--min-age-hours must be a non-negative integer" >&2 exit 1 fi log() { echo "[$(date -u +%H:%M:%S)] $*"; } -log "Pruning ephemeral e2e DNS records (min-age=${MIN_AGE_HOURS}h, apply=${APPLY})..." - -# Validate CF token + zone reachability before any list/delete work. -PF_JSON=$(curl -sS -f -m 10 -H "Authorization: Bearer $CF_API_TOKEN" \ - "https://api.cloudflare.com/client/v4/user/tokens/verify" 2>/dev/null) || { - log "ERROR: CF token verify failed (non-2xx or network error)." - exit 1 -} -if ! python3 -c " +# --- Preflight: verify CF token + zone BEFORE any list/delete work --------- +log "Preflight: verifying CF token + zone..." +PF_TOKEN_JSON=$(curl -sS -m 10 -H "Authorization: Bearer $CF_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/user/tokens/verify") +if ! echo "$PF_TOKEN_JSON" | python3 -c ' import json, sys -p = json.loads(sys.stdin.read()) -if not p.get('success'): - print('ERROR: CF token verify returned success=false', file=sys.stderr) - sys.exit(1) -status = (p.get('result') or {}).get('status', '?') -if status != 'active': - print(f'ERROR: CF token not active (status={status})', file=sys.stderr) - sys.exit(1) -" <<< "$PF_JSON"; then +try: + p = json.load(sys.stdin) +except Exception as exc: + print(f"ERROR: non-JSON from /user/tokens/verify: {exc}", file=sys.stderr) + raise SystemExit(1) +if not p.get("success"): + errs = p.get("errors") or [] + detail = "; ".join( + "{code}: {msg}".format(code=e.get("code", "?"), msg=e.get("message", "?")) + for e in errs + ) or "unknown" + print(f"ERROR: CF token verify returned success=false: {detail}", file=sys.stderr) + raise SystemExit(1) +status = (p.get("result") or {}).get("status", "?") +if status != "active": + print(f"ERROR: CF token is not active (status={status})", file=sys.stderr) + raise SystemExit(1) +'; then + log " CF token preflight FAILED — verify CF_API_TOKEN/CLOUDFLARE_API_TOKEN is active." exit 1 fi log " CF token active ✓" -ZONE_JSON=$(curl -sS -f -m 10 -H "Authorization: Bearer $CF_API_TOKEN" \ - "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID" 2>/dev/null) || { - log "ERROR: zone lookup failed (non-2xx or network error)." - exit 1 -} -if ! python3 -c " +PF_ZONE_JSON=$(curl -sS -m 10 -H "Authorization: Bearer $CF_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID") +if ! echo "$PF_ZONE_JSON" | CF_ZONE_ID="$CF_ZONE_ID" python3 -c ' import json, os, sys -p = json.loads(sys.stdin.read()) -if not p.get('success'): - print('ERROR: zone lookup returned success=false', file=sys.stderr) - sys.exit(1) -res = p.get('result') or {} -if res.get('id') != os.environ['CF_ZONE_ID']: - print('ERROR: zone id mismatch', file=sys.stderr) - sys.exit(1) -" <<< "$ZONE_JSON"; then +try: + p = json.load(sys.stdin) +except Exception as exc: + print(f"ERROR: non-JSON from /zones/{os.environ['CF_ZONE_ID']}: {exc}", file=sys.stderr) + raise SystemExit(1) +if not p.get("success"): + errs = p.get("errors") or [] + detail = "; ".join( + "{code}: {msg}".format(code=e.get("code", "?"), msg=e.get("message", "?")) + for e in errs + ) or "unknown" + print(f"ERROR: zone lookup returned success=false: {detail}", file=sys.stderr) + raise SystemExit(1) +res = p.get("result") or {} +if res.get("id") != os.environ["CF_ZONE_ID"]: + print("ERROR: zone id mismatch", file=sys.stderr) + raise SystemExit(1) +'; then + log " CF zone preflight FAILED — verify CF_ZONE_ID/CLOUDFLARE_ZONE_ID and Zone:Read permission." exit 1 fi log " zone $CF_ZONE_ID reachable ✓" -# Fetch all DNS records, paginated. Use a temp file for the raw list. -TMPDIR=$(mktemp -d -t cf-e2e-prune-XXXXXX) -LIST_FILE="$TMPDIR/records.json" -trap 'rm -rf "$TMPDIR"' EXIT +# --- Gather DNS records with explicit pagination ---------------------------- +log "Fetching DNS records from zone $CF_ZONE_ID (paginated)..." +PAGES_DIR=$(mktemp -d -t cf-dns-XXXXXX) +PLAN_FILE="" +FAIL_LOG="" +cleanup() { + rm -rf "$PAGES_DIR" + [ -n "$PLAN_FILE" ] && rm -f "$PLAN_FILE" + [ -n "$FAIL_LOG" ] && rm -f "$FAIL_LOG" + return 0 +} +trap cleanup EXIT PAGE=1 -while :; do - PAGE_FILE="$TMPDIR/page-$(printf '%05d' "$PAGE").json" - curl -sS -f -m 30 -H "Authorization: Bearer $CF_API_TOKEN" \ - "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records?per_page=100&page=$PAGE" \ - > "$PAGE_FILE" 2>/dev/null || { - log "ERROR: DNS list page $PAGE failed (non-2xx or network error)." - exit 1 - } - if ! python3 -c " +NEXT_PAGE=1 +while [ -n "$NEXT_PAGE" ]; do + page_file="$PAGES_DIR/page-$(printf '%05d' "$PAGE").json" + curl -sS -m 30 -f -H "Authorization: Bearer $CF_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records?per_page=100&page=$NEXT_PAGE" \ + > "$page_file" || { + log "ERROR: CF DNS list page $NEXT_PAGE failed (non-2xx or network error)." + exit 1 + } + + if ! python3 -c ' import json, sys try: p = json.load(open(sys.argv[1])) -except Exception as e: - print(f'ERROR: non-JSON DNS list response: {e}', file=sys.stderr) - sys.exit(1) -if not p.get('success'): - print('ERROR: DNS list returned success=false', file=sys.stderr) - sys.exit(1) -if not isinstance(p.get('result'), list): - print('ERROR: DNS list result is not an array', file=sys.stderr) - sys.exit(1) -" "$PAGE_FILE"; then +except Exception as exc: + print(f"ERROR: non-JSON list response: {exc}", file=sys.stderr) + raise SystemExit(1) +if not p.get("success"): + errs = p.get("errors") or [] + detail = "; ".join("{code}: {msg}".format(code=e.get("code","?"), msg=e.get("message","?")) for e in errs) or "unknown" + print(f"ERROR: CF DNS list returned success=false: {detail}", file=sys.stderr) + raise SystemExit(1) +if not isinstance(p.get("result"), list): + print("ERROR: CF DNS list result is not a list", file=sys.stderr) + raise SystemExit(1) +' "$page_file"; then + log "ERROR: CF DNS list page $NEXT_PAGE returned errors or malformed JSON" exit 1 fi - RESULT_COUNT=$(python3 -c "import json; print(len(json.load(open(sys.argv[1]))['result']))" "$PAGE_FILE") - if [ "$RESULT_COUNT" -eq 0 ]; then - break - fi + + HAS_MORE=$(python3 -c ' +import json, sys +p = json.load(open(sys.argv[1])) +ri = p.get("result_info") or {} +print(1 if ri.get("page", 0) < ri.get("total_pages", 0) else "") +' "$page_file") PAGE=$((PAGE + 1)) - if [ "$PAGE" -gt 1000 ]; then - log "WARNING: stopping pagination at page 1000 (100k records) — investigate if more." + if [ -z "$HAS_MORE" ]; then + NEXT_PAGE="" + else + NEXT_PAGE=$PAGE + fi + if [ "$PAGE" -gt 500 ]; then + log "::warning::stopping pagination at page 500 (50k records) — re-run if more" break fi done -python3 - ' -import glob, json, os, re, sys -from datetime import datetime, timezone, timedelta - -min_age_hours = int(os.environ["MIN_AGE_HOURS"]) -zone_domain = os.environ["ZONE_DOMAIN"] -cutoff = datetime.now(timezone.utc) - timedelta(hours=min_age_hours) - -# Clearly ephemeral test prefixes only. Require the record to live under -# the configured zone domain so we never match a similarly-named record -# in a different zone. -EPHEMERAL_RE = re.compile(r"^(e2e-smoke|e2e-tmpl)[a-zA-Z0-9_-]*\." + re.escape(zone_domain) + r"$") - -records = [] +CF_JSON=$(python3 -c ' +import glob, json, os, sys +acc = {"result": []} for f in sorted(glob.glob(os.path.join(sys.argv[1], "page-*.json"))): with open(f) as fh: - records.extend(json.load(fh).get("result") or []) + acc["result"].extend(json.load(fh).get("result") or []) +print(json.dumps(acc)) +' "$PAGES_DIR") +TOTAL_CF=$(echo "$CF_JSON" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['result']))") +log " total CF records: $TOTAL_CF" + +# --- Compute targets --------------------------------------------------------- +export MIN_AGE_HOURS ZONE_DOMAIN +DECISIONS=$(echo "$CF_JSON" | python3 -c ' +import json, os, re, sys +from datetime import datetime, timezone, timedelta + +min_age = timedelta(hours=int(os.environ["MIN_AGE_HOURS"])) +zone_domain = os.environ["ZONE_DOMAIN"] +now = datetime.now(timezone.utc) + +# Conservative: only the two known disposable E2E prefixes, anchored to the +# configured zone domain so similarly-named records in other zones never match. +EPHEMERAL_RE = re.compile( + r"^(e2e-smoke|e2e-tmpl)[a-zA-Z0-9_-]*\." + re.escape(zone_domain) + r"$" +) def parse_iso(s): if not s: return None + s = s.strip() + if s.endswith("Z"): + s = s[:-1] + "+00:00" try: - return datetime.fromisoformat(s.replace("Z", "+00:00")) + return datetime.fromisoformat(s) except ValueError: return None -candidates = [] -for r in records: - name = r.get("name", "") +def decide(r): rid = r.get("id", "") - if not EPHEMERAL_RE.match(name): - continue + name = r.get("name", "") + typ = r.get("type", "") created = parse_iso(r.get("created_on")) + + if not EPHEMERAL_RE.match(name): + return ("keep", "not-ephemeral-pattern", rid, name, typ) + if created is None: - # If we cannot establish age, keep it for safety. - continue - if created >= cutoff: - continue - candidates.append({"id": rid, "name": name, "type": r.get("type", "?"), "created_on": r.get("created_on")}) + return ("keep", "missing-created_on", rid, name, typ) -print(json.dumps(candidates)) -' "$TMPDIR" > "$LIST_FILE" + if (now - created) < min_age: + return ("keep", "too-new", rid, name, typ) -CANDIDATES_COUNT=$(python3 -c "import json; print(len(json.load(open(sys.argv[1]))))" "$LIST_FILE") -log "Found $CANDIDATES_COUNT candidate record(s) older than ${MIN_AGE_HOURS}h matching ephemeral prefixes." + return ("delete", "stale-ephemeral", rid, name, typ) -if [ "$CANDIDATES_COUNT" -eq 0 ]; then - log "Nothing to prune." - exit 0 -fi +d = json.load(sys.stdin) +for r in d.get("result", []): + action, reason, rid, name, typ = decide(r) + print(json.dumps({ + "action": action, + "reason": reason, + "id": rid, + "name": name, + "type": typ, + "created_on": r.get("created_on", ""), + })) +') -if [ "$APPLY" -ne 1 ]; then - log "" - log "DRY RUN — the following $CANDIDATES_COUNT record(s) WOULD be deleted." - log "Pass --apply or set PRUNE_APPLY=1 to actually delete." - log "" - python3 -c " -import json -for r in json.load(open(sys.argv[1])): - print(f\" {r['type']:6s} {r['name']:<60s} created={r['created_on']}\") -" "$LIST_FILE" - exit 0 -fi +MATCHED_COUNT=$(printf '%s' "$DECISIONS" | python3 -c "import json,sys; print(sum(1 for l in sys.stdin if json.loads(l)['reason'] != 'not-ephemeral-pattern'))") +DELETE_COUNT=$(printf '%s' "$DECISIONS" | python3 -c "import json,sys; print(sum(1 for l in sys.stdin if json.loads(l)['action']=='delete'))") +KEEP_COUNT=$((MATCHED_COUNT - DELETE_COUNT)) -# Apply deletes. log "" -log "APPLY — deleting $CANDIDATES_COUNT record(s)..." +log "== Prune plan ==" +log " zone domain: $ZONE_DOMAIN" +log " total CF records: $TOTAL_CF" +log " matched ephemeral shape: $MATCHED_COUNT" +log " would delete: $DELETE_COUNT" +log " would keep (in scope): $KEEP_COUNT" +log " min-age-hours: $MIN_AGE_HOURS" +log "" + +printf '%s' "$DECISIONS" | python3 -c " +import json, sys, collections +c = collections.Counter() +for l in sys.stdin: + d = json.loads(l) + c[d['reason']] += 1 +for reason, n in c.most_common(): + print(f' {reason}: {n}') +" + +# --- Safety gate ------------------------------------------------------------- +if [ "$MATCHED_COUNT" -gt 0 ]; then + PCT=$(( DELETE_COUNT * 100 / MATCHED_COUNT )) + if [ "$PCT" -gt "$MAX_DELETE_PCT" ]; then + log "" + log "SAFETY: would delete $PCT% of matched ephemeral records (threshold $MAX_DELETE_PCT%) — refusing." + log " If this is expected, rerun with MAX_DELETE_PCT=$((PCT+5)) $0 $*" + exit 2 + fi +fi + +if [ "$DRY_RUN" = "1" ]; then + log "" + log "Dry run complete. Pass --apply (or PRUNE_APPLY=1) to delete $DELETE_COUNT records." + log "" + log "First 20 records that would be deleted:" + printf '%s' "$DECISIONS" | python3 -c " +import json, sys +shown = 0 +for l in sys.stdin: + d = json.loads(l) + if d['action'] == 'delete': + print(f\" {d['created_on'][:19]:20s} {d['name']}\") + shown += 1 + if shown >= 20: break +" + exit 0 +fi + +# --- Execute deletes --------------------------------------------------------- +PLAN_FILE=$(mktemp -t cf-dns-plan-XXXXXX) +FAIL_LOG=$(mktemp -t cf-dns-fail-XXXXXX) + +printf '%s' "$DECISIONS" | python3 -c ' +import json, sys +with open(sys.argv[1], "w") as plan: + for line in sys.stdin: + d = json.loads(line) + if d.get("action") == "delete": + plan.write(d["id"] + "\t" + d["name"] + "\n") +' "$PLAN_FILE" + +log "" +log "Executing $DELETE_COUNT deletions..." + DELETED=0 FAILED=0 -while IFS= read -r line; do - rid=$(echo "$line" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['id'])") - name=$(echo "$line" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['name'])") - if curl -sS -f -m 15 -X DELETE \ - -H "Authorization: Bearer $CF_API_TOKEN" \ - "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records/$rid" \ - >/dev/null 2>&1; then - log " deleted: $name" +while IFS=$'\t' read -r rid name; do + [ -n "$rid" ] || continue + if curl -sS -m 15 -f -X DELETE \ + -H "Authorization: Bearer $CF_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records/$rid" \ + >/dev/null 2>&1; then DELETED=$((DELETED + 1)) else - log " FAILED: $name" FAILED=$((FAILED + 1)) + echo "FAIL $name $rid" >> "$FAIL_LOG" fi -done < <(python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)))" < "$LIST_FILE" | python3 -c " -import json, sys -for r in json.load(sys.stdin): - print(json.dumps(r)) -") +done < "$PLAN_FILE" log "" log "Done. deleted=$DELETED failed=$FAILED" +if [ "$FAILED" -ne 0 ]; then + log "Failure detail (first 20):" + head -20 "$FAIL_LOG" | while IFS= read -r fl; do log " $fl"; done +fi [ "$FAILED" -eq 0 ] diff --git a/tests/ops/test_prune_cf_e2e_dns_fail_closed.sh b/tests/ops/test_prune_cf_e2e_dns_fail_closed.sh new file mode 100755 index 000000000..d26a4a4eb --- /dev/null +++ b/tests/ops/test_prune_cf_e2e_dns_fail_closed.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Regression test for scripts/ops/prune_cf_e2e_dns.sh — verifies fail-closed +# behavior for Cloudflare API errors and record-selection safety. +# +# Tests: +# 1. Non-2xx CF DNS list response aborts before any delete attempt. +# 2. Malformed JSON CF DNS list response aborts before any delete attempt. +# 3. CF DNS list result that is not an array aborts before any delete attempt. +# 4. A record matching the e2e-smoke-* pattern but younger than min-age is kept. +# 5. A non-ephemeral record (api.moleculesai.app) older than min-age is kept. +# 6. Happy path: an old e2e-smoke-* record is deleted (sentinel reached). +set -uo pipefail + +SCRIPT="${SCRIPT:-scripts/ops/prune_cf_e2e_dns.sh}" + +PASS=0 +FAIL=0 + +run_case() { + local name="$1" list_exit="$2" list_body="$3" expect_delete_sentinel="$4" + local tmp + tmp=$(mktemp -d -t cf-e2e-prune-fail-closed-XXXXXX) + local delete_sentinel="$tmp/delete_reached" + + # URL-aware curl mock. CF token/zone preflight always succeeds. CF DNS list + # endpoint receives the controlled response. CF DNS delete endpoint writes a + # sentinel if reached. + cat > "$tmp/curl" <<'MOCK' +#!/usr/bin/env bash +url="" +method="GET" +while [ "$#" -gt 0 ]; do + case "$1" in + -X) method="$2"; shift ;; + https://*) url="$1" ;; + esac + shift +done +case "$url" in + */user/tokens/verify) + echo '{"success":true,"result":{"status":"active"}}' + exit 0 + ;; + */zones/*/dns_records*) + if [ "$method" = "DELETE" ]; then + echo 'reached' > "$DELETE_SENTINEL" + echo '{"success":true,"result":{"id":"deleted"}}' + exit 0 + fi + __LIST_BODY__ + exit __LIST_EXIT__ + ;; + */zones/*) + echo '{"success":true,"result":{"id":"zone"}}' + exit 0 + ;; + *) + echo 'reached' > "$DELETE_SENTINEL" + echo '{"success":true,"result":{"id":"deleted"}}' + exit 0 + ;; +esac +MOCK + printf '%s\n' "$list_body" > "$tmp/list_body.txt" + sed -i "s|__LIST_BODY__|cat \"\$LIST_BODY_FILE\"|g; s|__LIST_EXIT__|$list_exit|g" "$tmp/curl" + chmod +x "$tmp/curl" + + local out="$tmp/out" err="$tmp/err" + # Export paths so the mock script can find the list body file and sentinel. + export DELETE_SENTINEL="$delete_sentinel" + export LIST_BODY_FILE="$tmp/list_body.txt" + # Allow the single-record happy-path case to delete 100% of matched records. + export MAX_DELETE_PCT=100 + PATH="$tmp:$PATH" \ + CF_API_TOKEN=tok \ + CF_ZONE_ID=zone \ + PRUNE_MIN_AGE_HOURS=1 \ + bash "$SCRIPT" --apply > "$out" 2> "$err" + local actual_exit=$? + local case_fail=0 + + if [ "$expect_delete_sentinel" = "true" ]; then + # Happy path: script must reach delete and exit 0. + if [ ! -f "$delete_sentinel" ]; then + echo " ✗ $name: delete sentinel missing — prune did not reach delete step" >&2 + case_fail=1 + fi + if [ "$actual_exit" -ne 0 ]; then + echo " ✗ $name: expected exit 0, got $actual_exit" >&2 + case_fail=1 + fi + else + # Fail-closed / keep cases: delete sentinel must NOT be written. + if [ -f "$delete_sentinel" ]; then + echo " ✗ $name: delete sentinel exists — prune reached delete step unexpectedly" >&2 + case_fail=1 + fi + if [ "$expect_delete_sentinel" = "false-abort" ] && [ "$actual_exit" -eq 0 ]; then + echo " ✗ $name: expected non-zero exit for abort case, got 0" >&2 + case_fail=1 + fi + if [ "$expect_delete_sentinel" = "false-keep" ] && [ "$actual_exit" -ne 0 ]; then + echo " ✗ $name: expected exit 0 for keep case, got $actual_exit" >&2 + case_fail=1 + fi + fi + + if [ "$case_fail" -eq 0 ]; then + echo " ✓ $name" + PASS=$((PASS + 1)) + else + echo " stdout:" >&2 + sed 's/^/ /' "$out" >&2 + echo " stderr:" >&2 + sed 's/^/ /' "$err" >&2 + FAIL=$((FAIL + 1)) + fi + + rm -rf "$tmp" +} + +echo "Test: prune_cf_e2e_dns fail-closed boundary" +echo + +# Bad CF list responses must abort before delete. +run_case "CF DNS list returns 500" 55 '{"success":false,"errors":[{"code":1000}]}' false-abort +run_case "CF DNS list returns malformed JSON" 0 'this is not json' false-abort +run_case "CF DNS list returns non-array result" 0 '{"success":true,"result":{"id":"rec1"}}' false-abort + +# Helper to build a DNS list result with one record, given created_on ISO string. +make_list() { + local created_on="$1" + cat < Date: Mon, 22 Jun 2026 03:03:08 +0000 Subject: [PATCH 3/3] fix(scripts/ops): address #3140 CR2/Researcher RC blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Tighten EPHEMERAL_RE to require the trailing hyphen: Prevents matching e2e-smokeprod / e2e-tmplprod near-miss names. 2. .gitea/workflows/e2e-staging-saas.yml: * Add directive above the job. * Add tracker comment for . * Pass so the scheduled prune matches actual staging subdomain records. 3. tests/ops/test_prune_cf_e2e_dns_fail_closed.sh: * Add near-miss regression cases (e2e-smokeprod, e2e-tmplprod kept). * Add staging subdomain happy-path case. * make_list() now accepts zone domain parameter. Local: bash tests/ops/test_prune_cf_e2e_dns_fail_closed.sh → 9/9 pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .gitea/workflows/e2e-staging-saas.yml | 5 +++++ scripts/ops/prune_cf_e2e_dns.sh | 4 +++- .../ops/test_prune_cf_e2e_dns_fail_closed.sh | 22 ++++++++++++++----- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/e2e-staging-saas.yml b/.gitea/workflows/e2e-staging-saas.yml index c216fde74..9b4f8e444 100644 --- a/.gitea/workflows/e2e-staging-saas.yml +++ b/.gitea/workflows/e2e-staging-saas.yml @@ -374,11 +374,13 @@ jobs: # - Token and zone id come from repository secrets ONLY; never hardcoded. # - --min-age-hours is conservative (2h) so in-flight records from a long # E2E run or a recently-started dispatch are never touched. + # bp-required: pending #3140 — non-required / best-effort cleanup job. prune-stale-e2e-dns: name: Prune stale e2e DNS records runs-on: ubuntu-latest needs: e2e-staging-saas if: always() + # mc#3140: best-effort cleanup; transient CF API failures must not block merge. continue-on-error: true timeout-minutes: 10 permissions: @@ -386,6 +388,9 @@ jobs: env: CF_API_TOKEN: ${{ secrets.CF_STAGING_DNS_API_TOKEN }} CF_ZONE_ID: ${{ secrets.CF_STAGING_ZONE_ID }} + # Staging tenant DNS records live under staging.moleculesai.app, not the + # apex zone, so the prefix matcher must anchor to the staging subdomain. + PRUNE_ZONE_DOMAIN: staging.moleculesai.app steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/scripts/ops/prune_cf_e2e_dns.sh b/scripts/ops/prune_cf_e2e_dns.sh index fe6523880..c73c4bac4 100755 --- a/scripts/ops/prune_cf_e2e_dns.sh +++ b/scripts/ops/prune_cf_e2e_dns.sh @@ -250,8 +250,10 @@ now = datetime.now(timezone.utc) # Conservative: only the two known disposable E2E prefixes, anchored to the # configured zone domain so similarly-named records in other zones never match. +# The prefix must include the trailing hyphen so e2e-smokeprod.moleculesai.app +# is NOT matched. EPHEMERAL_RE = re.compile( - r"^(e2e-smoke|e2e-tmpl)[a-zA-Z0-9_-]*\." + re.escape(zone_domain) + r"$" + r"^(e2e-smoke-|e2e-tmpl-)[a-zA-Z0-9_-]*\." + re.escape(zone_domain) + r"$" ) def parse_iso(s): diff --git a/tests/ops/test_prune_cf_e2e_dns_fail_closed.sh b/tests/ops/test_prune_cf_e2e_dns_fail_closed.sh index d26a4a4eb..b925ac6d5 100755 --- a/tests/ops/test_prune_cf_e2e_dns_fail_closed.sh +++ b/tests/ops/test_prune_cf_e2e_dns_fail_closed.sh @@ -17,7 +17,7 @@ PASS=0 FAIL=0 run_case() { - local name="$1" list_exit="$2" list_body="$3" expect_delete_sentinel="$4" + local name="$1" list_exit="$2" list_body="$3" expect_delete_sentinel="$4" zone_domain="${5:-moleculesai.app}" local tmp tmp=$(mktemp -d -t cf-e2e-prune-fail-closed-XXXXXX) local delete_sentinel="$tmp/delete_reached" @@ -75,6 +75,7 @@ MOCK CF_API_TOKEN=tok \ CF_ZONE_ID=zone \ PRUNE_MIN_AGE_HOURS=1 \ + PRUNE_ZONE_DOMAIN="$zone_domain" \ bash "$SCRIPT" --apply > "$out" 2> "$err" local actual_exit=$? local case_fail=0 @@ -127,23 +128,32 @@ run_case "CF DNS list returns 500" 55 '{"success":false,"errors":[{" run_case "CF DNS list returns malformed JSON" 0 'this is not json' false-abort run_case "CF DNS list returns non-array result" 0 '{"success":true,"result":{"id":"rec1"}}' false-abort -# Helper to build a DNS list result with one record, given created_on ISO string. +# Helper to build a DNS list result with one record, given created_on ISO string +# and optional zone domain (default: moleculesai.app). make_list() { - local created_on="$1" + local created_on="$1" zone_domain="${2:-moleculesai.app}" cat <