Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

133 lines · 6.9 KB · executableRaw History
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.
12set -euo pipefail
13
14: "${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN}"
15: "${CLOUDFLARE_ACCOUNT_ID:?set CLOUDFLARE_ACCOUNT_ID}"
16DOMAIN="${DIFFINERY_DOMAIN:-diffinery.com}"
17ROOT="$(cd "$(dirname "$0")/.." && pwd)"
18APP="$ROOT/apps/diffinery"
19WRANGLER=(pnpm --dir "$APP" exec wrangler)
20API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID"
21
22cf() { # 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}
30log() { echo "::group::$*"; }
31endlog() { echo "::endgroup::"; }
32warn() { echo "::warning::$*" >&2; }
33
34# ------------------------------------------------------------------ D1
35log "D1 database"
36D1_ID="$(cf GET "/d1/database?name=diffinery&per_page=100" | jq -r '.result[]? | select(.name=="diffinery") | .uuid' | head -n1)"
37if [[ -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"
41else
42 echo "D1 database exists: $D1_ID"
43fi
44endlog
45
46# ------------------------------------------------------------------ R2
47log "R2 bucket"
48if cf GET "/r2/buckets/diffinery-data" | jq -e '.success' >/dev/null; then
49 echo "R2 bucket exists"
50else
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"
53fi
54# Expire transient data: run logs/caches are rewritten on use, artifacts and event archives age out.
55cf 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
59endlog
60
61# ------------------------------------------------------------------ Queues
62log "Queues"
63EXISTING_QUEUES="$(cf GET "/queues?per_page=100" | jq -r '.result[]?.queue_name')"
64for 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
71done
72endlog
73
74# ------------------------------------------------------------------ Access
75log "Cloudflare Access"
76TEAM="${ACCESS_TEAM_DOMAIN:-}"
77if [[ -z "$TEAM" ]]; then
78 TEAM="$(cf GET /access/organizations | jq -r '.result.auth_domain // empty' || true)"
79fi
80APPS="$(cf GET "/access/apps?per_page=100" || echo '{}')"
81AUD=""
82if ! 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."
84else
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
116fi
117if [[ -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)"
120fi
121[[ -n "$TEAM" ]] || warn "Access team domain unknown; set the ACCESS_TEAM_DOMAIN repository variable (Zero Trust → Settings → Custom pages shows it)."
122echo "team=${TEAM:-?} aud=${AUD:+set}"
123endlog
124
125# ------------------------------------------------------------------ config + migrations
126log "Render config and migrate D1"
127node "$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
129endlog
130
131if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
132 { echo "d1_id=$D1_ID"; echo "access_team=$TEAM"; echo "access_aud_set=${AUD:+true}"; } >>"$GITHUB_OUTPUT"
133fi