diff --git a/.github/workflows/cloudflare-dns.yml b/.github/workflows/cloudflare-dns.yml new file mode 100644 index 000000000..4da6ed29c --- /dev/null +++ b/.github/workflows/cloudflare-dns.yml @@ -0,0 +1,96 @@ +# Cloudflare DNS as code (curl + jq, no Terraform). +# +# Reconciles infra/cloudflare/zones.json against the Cloudflare account: +# - ensures each declared zone exists (creates via POST /zones in apply mode) +# - upserts the declared DNS records (no destructive deletes unless prune=true) +# - prints each zone's Cloudflare nameservers + status so they can be set at +# Namecheap (registrar) to delegate the domain to Cloudflare. +# +# Auth comes from org GitHub Secrets (scope=ALL): the token never leaves trusted +# push/dispatch runs. Pull requests run offline config validation only. +# CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID +# +# Default is DRY-RUN. Set input mode=apply to actually write. +name: Cloudflare DNS + +on: + workflow_dispatch: + inputs: + mode: + description: "dry-run (default, no writes) or apply (create zones + records)" + type: choice + default: dry-run + options: + - dry-run + - apply + prune: + description: "Delete Cloudflare records not present in zones.json (destructive)" + type: boolean + default: false + push: + branches: [main] + paths: + - "infra/cloudflare/zones.json" + - "infra/cloudflare/reconcile.sh" + - ".github/workflows/cloudflare-dns.yml" + # PRs validate the declarative config without Cloudflare secrets. Push and + # workflow_dispatch runs perform the API-backed dry-run/apply. + pull_request: + paths: + - "infra/cloudflare/zones.json" + - "infra/cloudflare/reconcile.sh" + - ".github/workflows/cloudflare-dns.yml" + +# push-triggered runs are always dry-run (safe by default); +# only an explicit workflow_dispatch with mode=apply is allowed to write. +concurrency: + group: cloudflare-dns-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + reconcile: + name: Reconcile zones (${{ github.event.inputs.mode || 'dry-run' }}) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Validate Cloudflare DNS config + if: ${{ github.event_name == 'pull_request' }} + run: | + set -euo pipefail + jq -e ' + (.zones | type == "array" and length > 0) and + all(.zones[]; ( + (.zone_name | type == "string" and length > 0) and + (.product_repo | type == "string" and length > 0) and + (.product_label | type == "string" and length > 0) and + (.records | type == "array") and + all(.records[]; ( + (.record_type | type == "string" and length > 0) and + (.record_name | type == "string" and length > 0) and + (.record_content | type == "string" and length > 0) + )) + )) + ' infra/cloudflare/zones.json >/dev/null + count="$(jq '.zones | length' infra/cloudflare/zones.json)" + echo "Validated ${count} Cloudflare DNS zone declaration(s) without exposing Cloudflare secrets to pull request code." + + - name: Reconcile Cloudflare DNS + if: ${{ github.event_name != 'pull_request' }} + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CF_MODE: ${{ github.event.inputs.mode || 'dry-run' }} + CF_PRUNE: ${{ github.event.inputs.prune || 'false' }} + CF_CONFIG: infra/cloudflare/zones.json + run: | + set -euo pipefail + if [ -z "${CF_API_TOKEN}" ] || [ -z "${CF_ACCOUNT_ID}" ]; then + echo "::error::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID secrets are not available to this run." + exit 1 + fi + bash infra/cloudflare/reconcile.sh diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 000000000..dc0613020 --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,112 @@ +# Reusable Cloudflare Pages deploy (workflow_call). +# +# Any product repo in the org can call this to publish a static site to +# Cloudflare Pages and (optionally) attach a custom domain, using the org +# secrets CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID. The token stays inside +# GitHub Actions — callers pass `secrets: inherit`. +# +# Example caller (.github/workflows/site.yml in a product repo): +# +# name: Publish marketing site +# on: +# push: +# branches: [main] +# jobs: +# deploy: +# uses: ContextualWisdomLab/.github/.github/workflows/deploy-pages.yml@main +# with: +# project_name: keyverse-marketing # Cloudflare Pages project (snake/kebab ok) +# build_dir: ./public # directory of built static assets +# custom_domain: keyverse.io # optional; must have a CF zone first +# secrets: inherit +# +name: Deploy Cloudflare Pages + +on: + workflow_call: + inputs: + project_name: + description: "Cloudflare Pages project name (created on first deploy if absent)" + required: true + type: string + build_dir: + description: "Directory containing the built static assets to publish" + required: true + type: string + custom_domain: + description: "Optional custom domain to attach to the Pages project (zone must exist)" + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + deploy_pages: + name: Deploy ${{ inputs.project_name }} + runs-on: ubuntu-latest + steps: + - name: Checkout caller repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Guard secrets present + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + if [ -z "${CF_API_TOKEN}" ] || [ -z "${CF_ACCOUNT_ID}" ]; then + echo "::error::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID not available. Caller must use 'secrets: inherit'." + exit 1 + fi + + - name: Deploy to Cloudflare Pages (wrangler) + uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # Creates the project on first run; publishes the build_dir to production. + command: pages deploy ${{ inputs.build_dir }} --project-name=${{ inputs.project_name }} + + - name: Attach custom domain (idempotent) + if: ${{ inputs.custom_domain != '' }} + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + PROJECT_NAME: ${{ inputs.project_name }} + CUSTOM_DOMAIN: ${{ inputs.custom_domain }} + run: | + set -euo pipefail + api="https://api.cloudflare.com/client/v4" + base="${api}/accounts/${CF_ACCOUNT_ID}/pages/projects/${PROJECT_NAME}/domains" + + existing="$(curl -sS "${base}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + | jq -r --arg d "${CUSTOM_DOMAIN}" '(.result // [])[] | select(.name==$d) | .name')" + + if [ "${existing}" = "${CUSTOM_DOMAIN}" ]; then + echo "Custom domain ${CUSTOM_DOMAIN} already attached to ${PROJECT_NAME}." + else + echo "Attaching ${CUSTOM_DOMAIN} to Pages project ${PROJECT_NAME}..." + resp="$(curl -sS -X POST "${base}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -nc --arg n "${CUSTOM_DOMAIN}" '{name:$n}')")" + if [ "$(printf '%s' "${resp}" | jq -r '.success // false')" = "true" ]; then + echo "Attached ${CUSTOM_DOMAIN}." + else + echo "::warning::Could not attach ${CUSTOM_DOMAIN}: $(printf '%s' "${resp}" | jq -c '.errors // .')" + fi + fi + + - name: Summary + if: always() + run: | + { + echo "## Cloudflare Pages deploy" + echo "" + echo "- **Project:** \`${{ inputs.project_name }}\`" + echo "- **Build dir:** \`${{ inputs.build_dir }}\`" + echo "- **Custom domain:** \`${{ inputs.custom_domain || '(none)' }}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/infra/cloudflare/README.md b/infra/cloudflare/README.md new file mode 100644 index 000000000..af275da7a --- /dev/null +++ b/infra/cloudflare/README.md @@ -0,0 +1,114 @@ +# Cloudflare DNS + Pages (infrastructure as code) + +This directory manages the org's domains and static hosting on **Cloudflare**, +declaratively, with nothing more than `curl` + `jq` running inside GitHub +Actions. No Terraform — six zones do not justify the moving parts. + +## The model + +- **Cloudflare is the DNS authority _and_ the host.** Each domain becomes a + Cloudflare *zone*; static marketing sites are served from **Cloudflare Pages** + (Workers can be added later the same way). +- **Namecheap only holds the registration.** After a zone is created in + Cloudflare, you do a **one-time** step at Namecheap: replace the default + Namecheap nameservers with the two nameservers Cloudflare assigns to that + zone. From then on, all records are managed here in `zones.json`. +- **The Cloudflare API token is never exposed to pull request code.** It lives + as the org secret `CLOUDFLARE_API_TOKEN` (with `CLOUDFLARE_ACCOUNT_ID`). Pull + requests validate `zones.json` offline. Only trusted `main` pushes and manual + workflow runs touch the Cloudflare API with + `${{ secrets.CLOUDFLARE_API_TOKEN }}` / `${{ secrets.CLOUDFLARE_ACCOUNT_ID }}`. + +## Files + +| File | Purpose | +| ---- | ------- | +| `zones.json` | Declarative list of the 6 zones + their DNS records (data-driven; records start empty). | +| `reconcile.sh` | Idempotent reconciler (curl + jq). Ensures zones exist, upserts records, prints nameservers. | +| `../../.github/workflows/cloudflare-dns.yml` | Runs `reconcile.sh` with the org secrets. Dry-run by default. | +| `../../.github/workflows/deploy-pages.yml` | Reusable (`workflow_call`) Cloudflare Pages deploy any product repo can call. | + +## Domain ↔ product map + +| Domain | Product repo | Product | +| ------ | ------------ | ------- | +| `keyverse.io` | `cwl-idp` | Keyverse | +| `wardnet.io` | `waf-ids-ai-soc` | Wardnet | +| `inkspan.io` | `cwl-editor` | Inkspan | +| `cloud-erd.app` | `pg-erd-cloud` | Cloud ERD | +| `naruon.net` | `naruon` | Naruon | +| `naruon.io` | `naruon` | Naruon | + +## One-time setup per domain (Namecheap → Cloudflare) + +1. **Create the zone + read its nameservers.** Run the `Cloudflare DNS` + workflow in **apply** mode (Actions tab → *Cloudflare DNS* → + *Run workflow* → `mode = apply`). For any zone that does not exist yet, it + creates it via `POST /zones` and prints the two assigned nameservers and the + zone status to the job summary (and the run log). +2. **Point Namecheap at Cloudflare.** In Namecheap → *Domain List* → *Manage* → + *Nameservers* → **Custom DNS**, enter the two Cloudflare nameservers reported + for that domain, and save. +3. **Wait for activation.** The zone status is `pending` until Namecheap + delegation propagates (minutes to a few hours), then flips to `active`. + Re-running the workflow re-prints the current status. + +## Adding DNS records (once a Pages project exists) + +Records are intentionally empty for now because the Pages hosting targets do not +exist yet. To add one, edit `zones.json` and append to the target zone's +`records` array using this shape: + +```json +{ + "record_type": "CNAME", + "record_name": "keyverse.io", + "record_content": "keyverse-marketing.pages.dev", + "record_proxied": true, + "record_ttl": 1 +} +``` + +Then either push to `main` (the workflow runs a **dry-run** automatically) or +run the workflow manually with `mode = apply`. Reconciliation is idempotent: +existing records are updated in place, missing ones are created. Nothing is +deleted unless you explicitly set `prune = true`. + +## Deploying a product's static site to Cloudflare Pages + +Product repos call the reusable workflow and inherit the org secrets: + +```yaml +# .github/workflows/site.yml in e.g. cwl-idp (Keyverse) +name: Publish marketing site +on: + push: + branches: [main] +jobs: + deploy: + uses: ContextualWisdomLab/.github/.github/workflows/deploy-pages.yml@main + with: + project_name: keyverse-marketing + build_dir: ./public + custom_domain: keyverse.io # optional; the CF zone must already exist + secrets: inherit +``` + +The reusable workflow publishes `build_dir` to the named Pages project (creating +it on first run) via `wrangler pages deploy`, then idempotently attaches +`custom_domain` if provided. After attaching a custom domain, add the matching +DNS record to `zones.json` (usually a proxied `CNAME` to `.pages.dev`) +so the apex/`www` resolves to the site. + +This same reusable workflow is the deploy path for the per-product marketing +pages and for the org `github.io` / profile content. + +## Safety notes + +- **Dry-run is the default.** Pull requests run offline config validation, + `workflow_dispatch` defaults to `mode = dry-run`, and `push` events are + always dry-run — only an explicit manual run with `mode = apply` writes to + Cloudflare. +- **No destructive deletes** unless `prune = true` is set explicitly. +- **Fail-soft:** per-zone/record errors are logged and the run continues; only a + failed API-token verification aborts the job (so a broken token is loud). diff --git a/infra/cloudflare/reconcile.sh b/infra/cloudflare/reconcile.sh new file mode 100755 index 000000000..1d0a6c7d1 --- /dev/null +++ b/infra/cloudflare/reconcile.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +# +# reconcile.sh — idempotent Cloudflare DNS reconciler (curl + jq, no Terraform). +# +# Reads a declarative zones config (default: infra/cloudflare/zones.json) and, +# for each zone: ensures the zone exists in the Cloudflare account, upserts the +# declared DNS records, and prints the zone's Cloudflare-assigned nameservers and +# status so they can be set at the domain registrar (Namecheap). +# +# Secrets are taken from the environment (populated by GitHub Actions from the +# org secrets). Nothing secret is ever printed. +# +# Environment inputs: +# CF_API_TOKEN (required) Cloudflare API token -> ${{ secrets.CLOUDFLARE_API_TOKEN }} +# CF_ACCOUNT_ID (required) Cloudflare account id -> ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} +# CF_MODE (optional) "dry-run" (default) | "apply" +# CF_PRUNE (optional) "true" to delete undeclared records | "false" (default) +# CF_CONFIG (optional) path to zones config (default infra/cloudflare/zones.json) +# +# Exit status: 0 on success (including fail-soft per-zone errors). Non-zero only +# when the API token itself cannot be verified, so a broken token is loud. + +set -uo pipefail + +CF_API="https://api.cloudflare.com/client/v4" +CF_MODE="${CF_MODE:-dry-run}" +CF_PRUNE="${CF_PRUNE:-false}" +CF_CONFIG="${CF_CONFIG:-infra/cloudflare/zones.json}" +SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}" + +zone_error_count=0 + +log() { printf '%s\n' "$*"; } +sumln(){ printf '%s\n' "$*" >>"$SUMMARY"; } + +require_env() { + local missing=0 + [ -n "${CF_API_TOKEN:-}" ] || { log "ERROR: CF_API_TOKEN is empty"; missing=1; } + [ -n "${CF_ACCOUNT_ID:-}" ] || { log "ERROR: CF_ACCOUNT_ID is empty"; missing=1; } + [ -f "$CF_CONFIG" ] || { log "ERROR: config not found: $CF_CONFIG"; missing=1; } + [ "$missing" -eq 0 ] || exit 2 +} + +# cf_api METHOD PATH [JSON_BODY] -> prints response body; sets global CF_HTTP +cf_api() { + local method="$1" path="$2" body="${3:-}" + local tmp http + tmp="$(mktemp)" + if [ -n "$body" ]; then + http="$(curl -sS -o "$tmp" -w '%{http_code}' -X "$method" "${CF_API}${path}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$body")" + else + http="$(curl -sS -o "$tmp" -w '%{http_code}' -X "$method" "${CF_API}${path}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}")" + fi + CF_HTTP="$http" + cat "$tmp" + rm -f "$tmp" +} + +verify_token() { + local resp ok + resp="$(cf_api GET /user/tokens/verify)" + ok="$(printf '%s' "$resp" | jq -r '.success // false')" + if [ "$ok" = "true" ]; then + log "TOKEN_STATUS: valid (Cloudflare API token verified)" + return 0 + fi + log "TOKEN_STATUS: INVALID — token verification failed (http ${CF_HTTP:-?})" + log "$(printf '%s' "$resp" | jq -c '.errors // .' 2>/dev/null)" + return 1 +} + +# Look up a zone by name inside the account. Echoes the zone result object (or empty). +get_zone() { + local name="$1" resp + resp="$(cf_api GET "/zones?name=${name}&account.id=${CF_ACCOUNT_ID}&status=all")" + printf '%s' "$resp" | jq -c '.result[0] // empty' +} + +create_zone() { + local name="$1" resp + local body + body="$(jq -nc --arg n "$name" --arg a "$CF_ACCOUNT_ID" \ + '{name:$n, account:{id:$a}, type:"full"}')" + resp="$(cf_api POST /zones "$body")" + if [ "$(printf '%s' "$resp" | jq -r '.success // false')" = "true" ]; then + printf '%s' "$resp" | jq -c '.result' + return 0 + fi + log "ERROR: failed to create zone ${name} (http ${CF_HTTP:-?}): $(printf '%s' "$resp" | jq -c '.errors // .')" + return 1 +} + +# reconcile_records ZONE_ID ZONE_JSON +reconcile_records() { + local zid="$1" zjson="$2" + local count + count="$(printf '%s' "$zjson" | jq '.records | length')" + if [ "$count" -eq 0 ]; then + log " records: none declared (nothing to reconcile)" + return 0 + fi + + # Track declared record identities for optional prune. + local declared_ids="" existing_resp + while IFS= read -r rec; do + local rtype rname rcontent rproxied rttl rprio + rtype="$(printf '%s' "$rec" | jq -r '.record_type')" + rname="$(printf '%s' "$rec" | jq -r '.record_name')" + rcontent="$(printf '%s' "$rec" | jq -r '.record_content')" + rproxied="$(printf '%s' "$rec" | jq -r '.record_proxied // false')" + rttl="$(printf '%s' "$rec" | jq -r '.record_ttl // 1')" + rprio="$(printf '%s' "$rec" | jq -r '.record_priority // empty')" + + # Find an existing record of same type+name. + existing_resp="$(cf_api GET "/zones/${zid}/dns_records?type=${rtype}&name=${rname}")" + local rid + rid="$(printf '%s' "$existing_resp" | jq -r '.result[0].id // empty')" + + # Build payload. + local payload + payload="$(jq -nc \ + --arg t "$rtype" --arg n "$rname" --arg c "$rcontent" \ + --argjson p "$rproxied" --argjson ttl "$rttl" \ + '{type:$t, name:$n, content:$c, proxied:$p, ttl:$ttl}')" + if [ -n "$rprio" ]; then + payload="$(printf '%s' "$payload" | jq -c --argjson pr "$rprio" '. + {priority:$pr}')" + fi + + if [ -n "$rid" ]; then + declared_ids="${declared_ids} ${rid}" + if [ "$CF_MODE" = "apply" ]; then + local up + up="$(cf_api PUT "/zones/${zid}/dns_records/${rid}" "$payload")" + if [ "$(printf '%s' "$up" | jq -r '.success // false')" = "true" ]; then + log " UPSERT ok: ${rtype} ${rname} -> ${rcontent} (updated)" + else + log " ERROR upsert ${rtype} ${rname}: $(printf '%s' "$up" | jq -c '.errors // .')" + zone_error_count=$((zone_error_count+1)) + fi + else + log " [dry-run] would UPDATE ${rtype} ${rname} -> ${rcontent}" + fi + else + if [ "$CF_MODE" = "apply" ]; then + local cr crid + cr="$(cf_api POST "/zones/${zid}/dns_records" "$payload")" + if [ "$(printf '%s' "$cr" | jq -r '.success // false')" = "true" ]; then + crid="$(printf '%s' "$cr" | jq -r '.result.id')" + declared_ids="${declared_ids} ${crid}" + log " UPSERT ok: ${rtype} ${rname} -> ${rcontent} (created)" + else + log " ERROR create ${rtype} ${rname}: $(printf '%s' "$cr" | jq -c '.errors // .')" + zone_error_count=$((zone_error_count+1)) + fi + else + log " [dry-run] would CREATE ${rtype} ${rname} -> ${rcontent}" + fi + fi + done < <(printf '%s' "$zjson" | jq -c '.records[]') + + # Optional prune of undeclared records. + if [ "$CF_PRUNE" = "true" ]; then + local all_ids + all_ids="$(cf_api GET "/zones/${zid}/dns_records?per_page=100" | jq -r '.result[].id')" + local id + for id in $all_ids; do + case " $declared_ids " in + *" $id "*) : ;; + *) + if [ "$CF_MODE" = "apply" ]; then + cf_api DELETE "/zones/${zid}/dns_records/${id}" >/dev/null + log " PRUNE: deleted undeclared record ${id}" + else + log " [dry-run] would PRUNE undeclared record ${id}" + fi + ;; + esac + done + fi +} + +main() { + require_env + + log "=== Cloudflare DNS reconcile ===" + log "mode=${CF_MODE} prune=${CF_PRUNE} config=${CF_CONFIG}" + log "" + + if ! verify_token; then + log "Aborting: cannot proceed without a valid API token." + exit 1 + fi + + sumln "## Cloudflare DNS reconcile" + sumln "" + sumln "**Mode:** \`${CF_MODE}\` **Prune:** \`${CF_PRUNE}\`" + sumln "" + sumln "Point these nameservers at Namecheap for each domain. A zone stays **pending** until Namecheap delegation propagates, then flips to **active**." + sumln "" + sumln "| Domain | Product repo | Zone status | Cloudflare nameservers |" + sumln "| ------ | ------------ | ----------- | ---------------------- |" + + local zcount + zcount="$(jq '.zones | length' "$CF_CONFIG")" + log "Discovered ${zcount} zone(s) in config." + log "" + + local i + for i in $(seq 0 $((zcount-1))); do + local zjson zname prepo plabel + zjson="$(jq -c ".zones[$i]" "$CF_CONFIG")" + zname="$(printf '%s' "$zjson" | jq -r '.zone_name')" + prepo="$(printf '%s' "$zjson" | jq -r '.product_repo')" + plabel="$(printf '%s' "$zjson" | jq -r '.product_label')" + + log "--- zone: ${zname} (${plabel} / ${prepo}) ---" + + local zobj zid zstatus zns + zobj="$(get_zone "$zname")" + + if [ -z "$zobj" ]; then + if [ "$CF_MODE" = "apply" ]; then + log " zone not found; creating..." + zobj="$(create_zone "$zname")" || { zone_error_count=$((zone_error_count+1)); } + fi + fi + + if [ -z "$zobj" ]; then + # Still no zone object (dry-run and not existing, or create failed). + if [ "$CF_MODE" = "dry-run" ]; then + log " zone does NOT exist yet. In apply mode it will be created via POST /zones," + log " and Cloudflare will then assign nameservers (reported on the apply run)." + printf 'NAMESERVERS|%s|not-created(dry-run)|%s\n' "$zname" "apply-mode-will-create-and-report" + sumln "| ${zname} | ${prepo} | _not created (dry-run)_ | _apply mode will create & report_ |" + else + log " zone could not be resolved or created; see errors above." + printf 'NAMESERVERS|%s|error|unavailable\n' "$zname" + sumln "| ${zname} | ${prepo} | error | unavailable |" + zone_error_count=$((zone_error_count+1)) + fi + log "" + continue + fi + + zid="$(printf '%s' "$zobj" | jq -r '.id')" + zstatus="$(printf '%s' "$zobj" | jq -r '.status')" + zns="$(printf '%s' "$zobj" | jq -r '(.name_servers // []) | join(", ")')" + [ -n "$zns" ] || zns="(not assigned yet)" + + log " zone id: ${zid}" + log " status : ${zstatus}" + log " nameservers: ${zns}" + # Machine-parseable line for log scraping: + printf 'NAMESERVERS|%s|%s|%s\n' "$zname" "$zstatus" "$zns" + sumln "| ${zname} | ${prepo} | ${zstatus} | ${zns} |" + + reconcile_records "$zid" "$zjson" + log "" + done + + sumln "" + if [ "$CF_MODE" = "dry-run" ]; then + sumln "> Dry-run: no changes were written. Re-run with \`mode=apply\` to create zones and records." + fi + if [ "$zone_error_count" -gt 0 ]; then + log "Completed with ${zone_error_count} non-fatal zone/record error(s) (fail-soft)." + else + log "Completed with no errors." + fi + exit 0 +} + +main "$@" diff --git a/infra/cloudflare/zones.json b/infra/cloudflare/zones.json new file mode 100644 index 000000000..16796454c --- /dev/null +++ b/infra/cloudflare/zones.json @@ -0,0 +1,50 @@ +{ + "_about": "Declarative Cloudflare DNS config for ContextualWisdomLab. Reconciled by .github/workflows/cloudflare-dns.yml (curl + jq, idempotent). The Cloudflare account id and API token are supplied at runtime from org GitHub Secrets CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN. NOTHING secret lives in this file.", + "_record_schema": { + "record_type": "A | AAAA | CNAME | TXT | MX | ... (Cloudflare DNS record type)", + "record_name": "FQDN or @ for the zone apex (e.g. 'keyverse.io' or 'www.keyverse.io')", + "record_content": "target value (IP, hostname, text, ...)", + "record_proxied": "true|false — orange-cloud proxy; use true for Pages custom domains fronted by CF", + "record_ttl": "1 for automatic, or seconds; ignored when record_proxied is true", + "record_priority": "integer, only for MX/SRV records (optional)" + }, + "_records_note": "Records are intentionally empty for now: the Pages hosting targets do not exist yet. Once a Cloudflare Pages project is created for a product, add its custom-domain records here (usually a CNAME from the apex/www to .pages.dev, proxied=true) and re-run the workflow in apply mode.", + "zones": [ + { + "zone_name": "keyverse.io", + "product_repo": "cwl-idp", + "product_label": "Keyverse", + "records": [] + }, + { + "zone_name": "wardnet.io", + "product_repo": "waf-ids-ai-soc", + "product_label": "Wardnet", + "records": [] + }, + { + "zone_name": "inkspan.io", + "product_repo": "cwl-editor", + "product_label": "Inkspan", + "records": [] + }, + { + "zone_name": "cloud-erd.app", + "product_repo": "pg-erd-cloud", + "product_label": "Cloud ERD", + "records": [] + }, + { + "zone_name": "naruon.net", + "product_repo": "naruon", + "product_label": "Naruon", + "records": [] + }, + { + "zone_name": "naruon.io", + "product_repo": "naruon", + "product_label": "Naruon", + "records": [] + } + ] +}