import type { FastifyInstance } from "fastify"; import { z } from "zod"; import crypto from "crypto"; import postgres from "postgres"; import { createDatabase, createDatabaseUser, deleteDatabase, deleteDatabaseUser } from "../services/do"; import { addCustomerToPool, removeCustomerFromPool } from "../services/pgbouncer"; import { addCustomerChart, removeCustomerChart } from "../services/git"; import { setupCustomerDatabase, teardownCustomerDatabase } from "../services/db"; import { createNamespace, deleteNamespace, createSecret, createDockerRegistrySecret, patchSecret, getSecret, k8sFetch } from "../lib/k8s"; import { createSpacesKey, deleteSpacesKey, deleteSpacesObjects, getSpacesUsage } from "../services/spaces"; import { db } from "../db/manager"; import { config } from "../lib/config"; import { getCachedStatus, setCachedStatus } from "../lib/cache"; const MODULES = ["pos", "inventory", "rentals", "scheduling", "repairs", "accounting"] as const; const PGBOUNCER_HOST = "pgbouncer.pgbouncer.svc"; const PGBOUNCER_PORT = 5432; const ProvisionSchema = z.object({ slug: z.string().min(2).max(32).regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only"), name: z.string().min(1).max(128), appVersion: z.string().default("*"), modules: z.array(z.enum(MODULES)).default([]), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).default(() => new Date().toISOString().slice(0, 10)), expirationDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().default(null), }); const ListQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(25), q: z.string().optional(), sort: z.enum(["slug", "start_date", "expiration_date", "created_at", "updated_at"]).default("created_at"), order: z.enum(["asc", "desc"]).default("desc"), }); export async function customerRoutes(app: FastifyInstance) { app.get("/customers", async (req, reply) => { const query = ListQuerySchema.parse(req.query); const offset = (query.page - 1) * query.limit; const search = query.q ? `%${query.q}%` : null; const [rows, [{ count }]] = await Promise.all([ search ? db`SELECT * FROM customers WHERE slug ILIKE ${search} OR name ILIKE ${search} ORDER BY ${db(query.sort)} ${db.unsafe(query.order)} LIMIT ${query.limit} OFFSET ${offset}` : db`SELECT * FROM customers ORDER BY ${db(query.sort)} ${db.unsafe(query.order)} LIMIT ${query.limit} OFFSET ${offset}`, search ? db`SELECT COUNT(*)::int FROM customers WHERE slug ILIKE ${search} OR name ILIKE ${search}` : db`SELECT COUNT(*)::int FROM customers`, ]); return reply.send({ data: rows, pagination: { page: query.page, limit: query.limit, total: count, totalPages: Math.ceil(count / query.limit), }, }); }); app.post("/customers", async (req, reply) => { const body = ProvisionSchema.parse(req.body); const slug = body.slug; const namespace = `customer-${slug}`; app.log.info({ slug }, "provisioning customer"); type StepStatus = "pending" | "done" | "failed"; const steps: Record = { database: "pending", database_user: "pending", database_setup: "pending", pool: "pending", namespace: "pending", secrets: "pending", storage: "pending", chart: "pending", }; const setStep = async (step: string, status: StepStatus) => { steps[step] = status; await db`UPDATE customers SET steps = ${db.json(steps)}, updated_at = NOW() WHERE slug = ${slug}`; }; await db` INSERT INTO customers (slug, name, modules, start_date, expiration_date, status, steps) VALUES (${slug}, ${body.name}, ${body.modules}, ${body.startDate}, ${body.expirationDate}, 'provisioning', ${db.json(steps)}) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, modules = EXCLUDED.modules, start_date = EXCLUDED.start_date, expiration_date = EXCLUDED.expiration_date, status = 'provisioning', steps = ${db.json(steps)}, updated_at = NOW() `; try { const [, user] = await Promise.all([ createDatabase(slug).then(r => { setStep("database", "done"); return r; }), createDatabaseUser(slug).then(r => { setStep("database_user", "done"); return r; }), ]); await setupCustomerDatabase(slug, user.name); await setStep("database_setup", "done"); await addCustomerToPool(slug, user.password); await setStep("pool", "done"); // Create k8s namespace and secrets before pushing chart so ArgoCD can deploy immediately await createNamespace(namespace); await createDockerRegistrySecret(namespace, "registry-lunarfront", { server: "registry.digitalocean.com", username: "token", password: config.doToken, }); const jwtSecret = crypto.randomBytes(32).toString("hex"); const databaseUrl = `postgresql://${slug}:${user.password}@${PGBOUNCER_HOST}:${PGBOUNCER_PORT}/${slug}`; await createSecret(namespace, "lunarfront-secrets", { "database-url": databaseUrl, "jwt-secret": jwtSecret, "redis-url": config.managedValkeyUrl, "redis-key-prefix": slug, }); await setStep("namespace", "done"); await setStep("secrets", "done"); const { accessKey: spacesKey, secretKey: spacesSecret } = await createSpacesKey( `customer-${slug}`, config.spacesBucket, ); await patchSecret(namespace, "lunarfront-secrets", { "spaces-key": spacesKey, "spaces-secret": spacesSecret, "spaces-bucket": config.spacesBucket, "spaces-endpoint": `https://${config.spacesRegion}.digitaloceanspaces.com`, "spaces-prefix": `${slug}/`, }); await db`UPDATE customers SET spaces_key = ${spacesKey}, updated_at = NOW() WHERE slug = ${slug}`; await setStep("storage", "done"); addCustomerChart(slug, body.appVersion); await setStep("chart", "done"); await db`UPDATE customers SET status = 'provisioned', updated_at = NOW() WHERE slug = ${slug}`; } catch (err) { const failedStep = Object.entries(steps).find(([, v]) => v === "pending")?.[0]; if (failedStep) await setStep(failedStep, "failed"); await db`UPDATE customers SET status = 'failed', updated_at = NOW() WHERE slug = ${slug}`; app.log.error({ slug, err }, "provisioning failed"); return reply.code(500).send({ message: (err as Error).message ?? "Provisioning failed" }); } app.log.info({ slug }, "customer provisioned"); return reply.code(201).send({ slug, status: "provisioned" }); }); app.delete("/customers/:slug", async (req, reply) => { const { slug } = req.params as { slug: string }; const namespace = `customer-${slug}`; app.log.info({ slug }, "deprovisioning customer"); const [customer] = await db`SELECT spaces_key FROM customers WHERE slug = ${slug}`; removeCustomerChart(slug); await removeCustomerFromPool(slug); await teardownCustomerDatabase(slug, slug); if (customer?.spaces_key) { try { const secrets = await getSecret(namespace, "lunarfront-secrets"); await deleteSpacesObjects(customer.spaces_key, secrets["spaces-secret"], config.spacesBucket, config.spacesRegion, `${slug}/`); } catch {} try { await deleteSpacesKey(customer.spaces_key); } catch {} } await Promise.all([ deleteDatabase(slug), deleteDatabaseUser(slug), deleteNamespace(namespace), ]); await db`DELETE FROM customers WHERE slug = ${slug}`; app.log.info({ slug }, "customer deprovisioned"); return reply.code(204).send(); }); // Live overview: cached status (pods + ArgoCD) + latest size snapshot + 30d history app.get("/customers/:slug/overview", async (req, reply) => { const { slug } = req.params as { slug: string }; const namespace = `customer-${slug}`; const { refresh } = req.query as { refresh?: string }; const [customer] = await db`SELECT * FROM customers WHERE slug = ${slug}`; if (!customer) return reply.code(404).send({ message: "Not found" }); // ── Status (Redis cache, 2min TTL) ──────────────────────────────────────── let statusEntry = refresh ? null : await getCachedStatus(slug); if (!statusEntry) { const [podsResult, argoResult] = await Promise.allSettled([ k8sFetch(`/api/v1/namespaces/${namespace}/pods`).then(r => r.json()), k8sFetch(`/apis/argoproj.io/v1alpha1/namespaces/argocd/applications/customer-${slug}`).then(r => r.json()), ]); const podsRaw = podsResult.status === "fulfilled" ? podsResult.value : null; const argoRaw = argoResult.status === "fulfilled" ? argoResult.value : null; const pods = (podsRaw?.items ?? []).map((pod: any) => ({ name: pod.metadata.name, ready: (pod.status.containerStatuses ?? []).every((c: any) => c.ready), readyCount: (pod.status.containerStatuses ?? []).filter((c: any) => c.ready).length, totalCount: (pod.status.containerStatuses ?? []).length, status: pod.status.phase ?? "Unknown", restarts: (pod.status.containerStatuses ?? []).reduce((s: number, c: any) => s + (c.restartCount ?? 0), 0), startedAt: pod.status.startTime ?? null, })); const argocd = argoRaw?.status ? { syncStatus: argoRaw.status.sync?.status ?? "Unknown", healthStatus: argoRaw.status.health?.status ?? "Unknown", conditions: (argoRaw.status.conditions ?? []).map((c: any) => ({ type: c.type, message: c.message })), } : null; const liveStatus = { pods, argocd }; await setCachedStatus(slug, liveStatus); statusEntry = { data: liveStatus, cachedAt: new Date().toISOString() }; } // ── Size history (last 30 days) ─────────────────────────────────────────── const sizeHistory = await db` SELECT recorded_at, db_size_bytes, spaces_size_bytes, spaces_object_count FROM customer_size_snapshots WHERE slug = ${slug} ORDER BY recorded_at DESC LIMIT 30 `; return reply.send({ customer, status: { ...statusEntry.data, cachedAt: statusEntry.cachedAt }, sizeHistory, }); }); // Remove only the manager DB record without touching infrastructure — // useful for cleaning up failed partial deployments app.delete("/customers/:slug/record", async (req, reply) => { const { slug } = req.params as { slug: string }; await db`DELETE FROM customers WHERE slug = ${slug}`; app.log.info({ slug }, "customer record removed"); return reply.code(204).send(); }); }