| 1 | #!/usr/bin/env bash |
| 2 | # Idempotent production setup for Diffinery on a Cloudflare account. |
| 3 | # |
| 4 | # Creates (only when missing): the D1 database, R2 bucket, event queues and the |
| 5 | # Cloudflare Access applications that put sign-in in front of the web UI while |
| 6 | # letting git, the API, MCP and runners authenticate with Diffinery tokens. |
| 7 | # Then renders apps/diffinery/wrangler.deploy.jsonc and applies D1 migrations. |
| 8 | # |
| 9 | # Requires: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, node, jq, curl. |
| 10 | # Optional: DIFFINERY_DOMAIN (default diffinery.com), ACCESS_TEAM_DOMAIN |
| 11 | # (e.g. codician.cloudflareaccess.com) when the token cannot read it. |
| 12 | set -euo pipefail |
| 13 | |
| 14 | : "${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN}" |
| 15 | : "${CLOUDFLARE_ACCOUNT_ID:?set CLOUDFLARE_ACCOUNT_ID}" |
| 16 | DOMAIN="${DIFFINERY_DOMAIN:-diffinery.com}" |
| 17 | ROOT="$(cd "$(dirname "$0")/.." && pwd)" |
| 18 | APP="$ROOT/apps/diffinery" |
| 19 | WRANGLER=(pnpm --dir "$APP" exec wrangler) |
| 20 | API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID" |
| 21 | |
| 22 | cf() { # cf METHOD PATH [JSON] |
| 23 | local method="$1" path="$2" body="${3:-}" |
| 24 | if [[ -n "$body" ]]; then |
| 25 | curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" --data "$body" |
| 26 | else |
| 27 | curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" |
| 28 | fi |
| 29 | } |
| 30 | log() { echo "::group::$*"; } |
| 31 | endlog() { echo "::endgroup::"; } |
| 32 | warn() { echo "::warning::$*" >&2; } |
| 33 | |
| 34 | # ------------------------------------------------------------------ D1 |
| 35 | log "D1 database" |
| 36 | D1_ID="$(cf GET "/d1/database?name=diffinery&per_page=100" | jq -r '.result[]? | select(.name=="diffinery") | .uuid' | head -n1)" |
| 37 | if [[ -z "$D1_ID" ]]; then |
| 38 | D1_ID="$(cf POST /d1/database '{"name":"diffinery"}' | jq -r '.result.uuid // empty')" |
| 39 | [[ -n "$D1_ID" ]] || { echo "could not create D1 database (does the token have D1: Edit?)" >&2; exit 1; } |
| 40 | echo "created D1 database $D1_ID" |
| 41 | else |
| 42 | echo "D1 database exists: $D1_ID" |
| 43 | fi |
| 44 | endlog |
| 45 | |
| 46 | # ------------------------------------------------------------------ R2 |
| 47 | log "R2 bucket" |
| 48 | if cf GET "/r2/buckets/diffinery-data" | jq -e '.success' >/dev/null; then |
| 49 | echo "R2 bucket exists" |
| 50 | else |
| 51 | cf POST /r2/buckets '{"name":"diffinery-data"}' | jq -e '.success' >/dev/null || { echo "could not create R2 bucket (R2 enabled? token has R2: Edit?)" >&2; exit 1; } |
| 52 | echo "created R2 bucket diffinery-data" |
| 53 | fi |
| 54 | # Expire transient data: run logs/caches are rewritten on use, artifacts and event archives age out. |
| 55 | cf PUT "/r2/buckets/diffinery-data/lifecycle" '{"rules":[ |
| 56 | {"id":"artifacts-90d","enabled":true,"conditions":{"prefix":"repos/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":7776000}},"abortMultipartUploadsTransition":{"condition":{"type":"Age","maxAge":86400}}}, |
| 57 | {"id":"events-400d","enabled":true,"conditions":{"prefix":"events/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":34560000}}} |
| 58 | ]}' >/dev/null || true |
| 59 | endlog |
| 60 | |
| 61 | # ------------------------------------------------------------------ Queues |
| 62 | log "Queues" |
| 63 | EXISTING_QUEUES="$(cf GET "/queues?per_page=100" | jq -r '.result[]?.queue_name')" |
| 64 | for q in diffinery-events diffinery-events-dlq; do |
| 65 | if grep -qx "$q" <<<"$EXISTING_QUEUES"; then |
| 66 | echo "queue $q exists" |
| 67 | else |
| 68 | cf POST /queues "{\"queue_name\":\"$q\"}" | jq -e '.success' >/dev/null || { echo "could not create queue $q (token has Queues: Edit?)" >&2; exit 1; } |
| 69 | echo "created queue $q" |
| 70 | fi |
| 71 | done |
| 72 | endlog |
| 73 | |
| 74 | # ------------------------------------------------------------------ Access |
| 75 | log "Cloudflare Access" |
| 76 | TEAM="${ACCESS_TEAM_DOMAIN:-}" |
| 77 | if [[ -z "$TEAM" ]]; then |
| 78 | TEAM="$(cf GET /access/organizations | jq -r '.result.auth_domain // empty' || true)" |
| 79 | fi |
| 80 | APPS="$(cf GET "/access/apps?per_page=100" || echo '{}')" |
| 81 | AUD="" |
| 82 | if ! jq -e '.success' >/dev/null <<<"$APPS"; then |
| 83 | warn "Could not read Access applications (token needs Access: Apps and Policies Edit). Sign-in stays disabled until Access is configured." |
| 84 | else |
| 85 | WEB_APP="$(jq -c --arg n "Diffinery" '.result[] | select(.name==$n)' <<<"$APPS" | head -n1)" |
| 86 | if [[ -z "$WEB_APP" ]]; then |
| 87 | WEB_APP="$(cf POST /access/apps "$(jq -nc --arg d "$DOMAIN" '{ |
| 88 | name: "Diffinery", type: "self_hosted", domain: $d, |
| 89 | destinations: [{type: "public", uri: $d}], |
| 90 | session_duration: "168h", app_launcher_visible: true, http_only_cookie_attribute: true, same_site_cookie_attribute: "lax", |
| 91 | policies: [{name: "Anyone who signs in (Diffinery invites decide access)", decision: "allow", include: [{everyone: {}}], precedence: 1}] |
| 92 | }')" | jq -c '.result // empty')" |
| 93 | [[ -n "$WEB_APP" ]] && echo "created Access app for $DOMAIN" || warn "Could not create the Access application for $DOMAIN." |
| 94 | else |
| 95 | echo "Access app exists" |
| 96 | fi |
| 97 | AUD="$(jq -r '.aud // empty' <<<"${WEB_APP:-{}}")" |
| 98 | |
| 99 | # Machine endpoints authenticate with Diffinery tokens, so Access must not intercept them. |
| 100 | BYPASS_DESTS="$(jq -nc --arg d "$DOMAIN" '[ |
| 101 | "/api/*", "/mcp", "/mcp/*", "/runner/*", "/.well-known/*", "/healthz", |
| 102 | "/*/*/info/refs", "/*/*/git-upload-pack", "/*/*/git-receive-pack", "/*/*/info/lfs/*", |
| 103 | "/*/*/actions/runs/*/artifacts/*" |
| 104 | ] | map({type: "public", uri: ($d + .)})')" |
| 105 | BYPASS_BODY="$(jq -nc --arg d "$DOMAIN" --argjson dests "$BYPASS_DESTS" '{ |
| 106 | name: "Diffinery machine endpoints", type: "self_hosted", domain: ($d + "/api/*"), destinations: $dests, |
| 107 | app_launcher_visible: false, |
| 108 | policies: [{name: "Bypass: Diffinery verifies tokens itself", decision: "bypass", include: [{everyone: {}}], precedence: 1}] |
| 109 | }')" |
| 110 | BYPASS_ID="$(jq -r '.result[] | select(.name=="Diffinery machine endpoints") | .id' <<<"$APPS" | head -n1)" |
| 111 | if [[ -z "$BYPASS_ID" ]]; then |
| 112 | cf POST /access/apps "$BYPASS_BODY" | jq -e '.success' >/dev/null && echo "created Access bypass for git/API/MCP/runner paths" || warn "Could not create the Access bypass application; git and API calls will be redirected to sign-in." |
| 113 | else |
| 114 | cf PUT "/access/apps/$BYPASS_ID" "$(jq -c 'del(.policies)' <<<"$BYPASS_BODY")" | jq -e '.success' >/dev/null && echo "updated Access bypass paths" || warn "Could not update the Access bypass application." |
| 115 | fi |
| 116 | fi |
| 117 | if [[ -z "$TEAM" ]]; then |
| 118 | # Fall back to the sign-in redirect, which names the team domain. |
| 119 | TEAM="$(curl -sS -o /dev/null -w '%{redirect_url}' "https://$DOMAIN/" | sed -nE 's#^https://([^/]+\.cloudflareaccess\.com)/.*#\1#p' || true)" |
| 120 | fi |
| 121 | [[ -n "$TEAM" ]] || warn "Access team domain unknown; set the ACCESS_TEAM_DOMAIN repository variable (Zero Trust → Settings → Custom pages shows it)." |
| 122 | echo "team=${TEAM:-?} aud=${AUD:+set}" |
| 123 | endlog |
| 124 | |
| 125 | # ------------------------------------------------------------------ config + migrations |
| 126 | log "Render config and migrate D1" |
| 127 | node "$ROOT/scripts/render-config.mjs" --d1 "$D1_ID" ${TEAM:+--team "$TEAM"} ${AUD:+--aud "$AUD"} --public-url "https://$DOMAIN" |
| 128 | "${WRANGLER[@]}" d1 migrations apply diffinery --remote -c wrangler.deploy.jsonc |
| 129 | endlog |
| 130 | |
| 131 | if [[ -n "${GITHUB_OUTPUT:-}" ]]; then |
| 132 | { echo "d1_id=$D1_ID"; echo "access_team=$TEAM"; echo "access_aud_set=${AUD:+true}"; } >>"$GITHUB_OUTPUT" |
| 133 | fi |