feat: customer detail page, size snapshots table, Spaces provisioning, Redis status cache
Some checks failed
Build & Release / build (push) Has been cancelled
Some checks failed
Build & Release / build (push) Has been cancelled
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
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 } from "../lib/k8s";
|
||||
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";
|
||||
@@ -71,6 +74,7 @@ export async function customerRoutes(app: FastifyInstance) {
|
||||
pool: "pending",
|
||||
namespace: "pending",
|
||||
secrets: "pending",
|
||||
storage: "pending",
|
||||
chart: "pending",
|
||||
};
|
||||
|
||||
@@ -122,6 +126,20 @@ export async function customerRoutes(app: FastifyInstance) {
|
||||
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");
|
||||
|
||||
@@ -144,9 +162,20 @@ export async function customerRoutes(app: FastifyInstance) {
|
||||
|
||||
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),
|
||||
@@ -159,6 +188,63 @@ export async function customerRoutes(app: FastifyInstance) {
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user