From 5d296fbb2bad1c82843dfa72d14e4becc6e7e470 Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Fri, 3 Apr 2026 20:42:43 -0500 Subject: [PATCH] feat: add Cloudflare DNS provisioning and health checks --- frontend/index.html | 14 ++++++++++ src/lib/config.ts | 3 +++ src/routes/customers.ts | 17 +++++++++++- src/services/cloudflare.ts | 54 ++++++++++++++++++++++++++++++++++++++ src/services/git.ts | 2 +- 5 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/services/cloudflare.ts diff --git a/frontend/index.html b/frontend/index.html index 12d9dc1..e46316c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -741,6 +741,20 @@ Prefix ${infra.spaces.prefix} ` : ''} +
+ DNS + ${infra?.dns?.exists + ? 'Active' + : 'Missing'} +
+
+ Health + ${infra?.health?.reachable + ? 'Healthy' + : infra?.health?.status + ? `HTTP ${infra.health.status}` + : 'Unreachable'} +
Provisioning Steps
diff --git a/src/lib/config.ts b/src/lib/config.ts index ffeda21..fd5d4d4 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -12,6 +12,9 @@ export const config = { spacesRegion: process.env.SPACES_REGION ?? "nyc3", spacesKey: process.env.SPACES_KEY!, spacesSecret: process.env.SPACES_SECRET!, + cfApiToken: process.env.CF_API_TOKEN!, + cfZoneId: process.env.CF_ZONE_ID!, + ingressIp: process.env.INGRESS_IP ?? "167.99.21.170", }; for (const [key, val] of Object.entries(config)) { diff --git a/src/routes/customers.ts b/src/routes/customers.ts index ffb69f3..1b34a3b 100644 --- a/src/routes/customers.ts +++ b/src/routes/customers.ts @@ -8,6 +8,7 @@ import { addCustomerChart, removeCustomerChart } from "../services/git"; import { setupCustomerDatabase, teardownCustomerDatabase } from "../services/db"; import { createNamespace, deleteNamespace, createSecret, createDockerRegistrySecret, patchSecret, getSecret, k8sFetch } from "../lib/k8s"; import { deleteSpacesObjects, getSpacesUsage } from "../services/spaces"; +import { createCustomerDnsRecord, deleteCustomerDnsRecord, getCustomerDnsRecord, checkCustomerHealth } from "../services/cloudflare"; import { db } from "../db/manager"; import { config } from "../lib/config"; import { getCachedStatus, setCachedStatus } from "../lib/cache"; @@ -75,6 +76,7 @@ export async function customerRoutes(app: FastifyInstance) { namespace: "pending", secrets: "pending", storage: "pending", + dns: "pending", chart: "pending", }; @@ -135,6 +137,9 @@ export async function customerRoutes(app: FastifyInstance) { }); await setStep("storage", "done"); + await createCustomerDnsRecord(slug); + await setStep("dns", "done"); + addCustomerChart(slug, body.appVersion); await setStep("chart", "done"); @@ -166,6 +171,10 @@ export async function customerRoutes(app: FastifyInstance) { await deleteSpacesObjects(config.spacesKey, config.spacesSecret, config.spacesBucket, config.spacesRegion, `${slug}/`); } catch {} + try { + await deleteCustomerDnsRecord(slug); + } catch {} + await Promise.all([ deleteDatabase(slug), deleteDatabaseUser(slug), @@ -220,7 +229,7 @@ export async function customerRoutes(app: FastifyInstance) { } // ── Infrastructure checks ───────────────────────────────────────────────── - const [dbCheck, sizeHistory, secrets] = await Promise.allSettled([ + const [dbCheck, sizeHistory, secrets, dnsCheck, healthCheck] = await Promise.allSettled([ // Try connecting to the customer DB (async () => { const sql = postgres(config.doadminDbUrl.replace(/\/([^/?]+)(\?|$)/, `/${slug}$2`), { max: 1, connect_timeout: 5 }); @@ -239,10 +248,14 @@ export async function customerRoutes(app: FastifyInstance) { LIMIT 30 `, getSecret(namespace, "lunarfront-secrets").catch(() => null), + getCustomerDnsRecord(slug), + checkCustomerHealth(slug), ]); const dbExists = dbCheck.status === "fulfilled" ? dbCheck.value : false; const secretData = secrets.status === "fulfilled" ? secrets.value : null; + const dns = dnsCheck.status === "fulfilled" ? dnsCheck.value : { exists: false, proxied: false, ip: null }; + const health = healthCheck.status === "fulfilled" ? healthCheck.value : { reachable: false, status: null }; const infra = { database: { exists: dbExists }, spaces: { @@ -250,6 +263,8 @@ export async function customerRoutes(app: FastifyInstance) { bucket: secretData?.["spaces-bucket"] ?? null, prefix: secretData?.["spaces-prefix"] ?? null, }, + dns, + health, }; return reply.send({ diff --git a/src/services/cloudflare.ts b/src/services/cloudflare.ts new file mode 100644 index 0000000..1f42c58 --- /dev/null +++ b/src/services/cloudflare.ts @@ -0,0 +1,54 @@ +import { config } from "../lib/config"; + +const CF_API = "https://api.cloudflare.com/client/v4"; + +async function cfFetch(path: string, options: RequestInit = {}) { + const res = await fetch(`${CF_API}${path}`, { + ...options, + headers: { + "Authorization": `Bearer ${config.cfApiToken}`, + "Content-Type": "application/json", + ...options.headers, + }, + }); + const data = await res.json() as { success: boolean; result: any; errors: any[] }; + if (!data.success) throw new Error(`Cloudflare API error: ${JSON.stringify(data.errors)}`); + return data.result; +} + +export async function createCustomerDnsRecord(slug: string): Promise { + await cfFetch(`/zones/${config.cfZoneId}/dns_records`, { + method: "POST", + body: JSON.stringify({ + type: "A", + name: `${slug}.lunarfront.tech`, + content: config.ingressIp, + ttl: 1, // auto TTL + proxied: true, + }), + }); +} + +export async function deleteCustomerDnsRecord(slug: string): Promise { + const records = await cfFetch(`/zones/${config.cfZoneId}/dns_records?name=${slug}.lunarfront.tech&type=A`); + for (const record of records) { + await cfFetch(`/zones/${config.cfZoneId}/dns_records/${record.id}`, { method: "DELETE" }); + } +} + +export async function getCustomerDnsRecord(slug: string): Promise<{ exists: boolean; proxied: boolean; ip: string | null }> { + const records = await cfFetch(`/zones/${config.cfZoneId}/dns_records?name=${slug}.lunarfront.tech&type=A`); + if (!records.length) return { exists: false, proxied: false, ip: null }; + return { exists: true, proxied: records[0].proxied, ip: records[0].content }; +} + +export async function checkCustomerHealth(slug: string): Promise<{ reachable: boolean; status: number | null }> { + try { + const res = await fetch(`https://${slug}.lunarfront.tech/health`, { + signal: AbortSignal.timeout(5000), + }); + return { reachable: res.ok, status: res.status }; + } catch { + return { reachable: false, status: null }; + } +} diff --git a/src/services/git.ts b/src/services/git.ts index fcb05ea..cb792a8 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -66,7 +66,7 @@ spec: helm: parameters: - name: ingress.host - value: ${slug}.lunarfront.app + value: ${slug}.lunarfront.tech destination: server: https://kubernetes.default.svc namespace: customer-${slug}