257 lines
11 KiB
TypeScript
257 lines
11 KiB
TypeScript
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<string, StepStatus> = {
|
|
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();
|
|
});
|
|
}
|