From fe6251451577a9126594c2e5d4b667ba709f74f2 Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Fri, 3 Apr 2026 20:21:51 -0500 Subject: [PATCH] feat: single shared Spaces key, deactivate/reactivate customer, status badge for inactive --- frontend/index.html | 26 +++++++++++++++-- src/lib/config.ts | 2 ++ src/routes/customers.ts | 53 ++++++++++++++++++++++++----------- src/services/sizeCollector.ts | 29 ++++++++----------- 4 files changed, 74 insertions(+), 36 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index b40ec5a..34bc353 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -345,6 +345,8 @@
View Details
+
Deactivate
+
Delete
Remove Record Only
@@ -466,6 +468,7 @@ } function customerStatusBadge(r) { + if (r.status === 'inactive') return 'Inactive'; if (r.status === 'provisioning') return 'Provisioning'; if (r.status === 'failed') { const failedStep = Object.entries(r.steps || {}).find(([,v]) => v === 'failed'); @@ -500,7 +503,7 @@ ${expiry} ${fmtDateTime(r.created_at)} ${fmtDateTime(r.updated_at)} - + `; }).join(''); } @@ -552,9 +555,12 @@ // ── Kebab menu ──────────────────────────────────────────────────────────── - function openKebab(event, slug) { + function openKebab(event, slug, status) { event.stopPropagation(); kebabSlug = slug; + // Show deactivate or reactivate depending on status + document.getElementById('kebab-deactivate').style.display = status === 'inactive' ? 'none' : ''; + document.getElementById('kebab-reactivate').style.display = status === 'inactive' ? '' : 'none'; const menu = document.getElementById('kebab-menu'); const rect = event.currentTarget.getBoundingClientRect(); menu.style.top = (rect.bottom + 4) + 'px'; @@ -572,6 +578,8 @@ closeKebab(); if (!slug) return; if (action === 'view') openDetail(slug); + else if (action === 'deactivate') deactivate(slug); + else if (action === 'reactivate') reactivate(slug); else if (action === 'delete') openDeleteDialog(slug); else if (action === 'record') removeRecord(slug); } @@ -758,6 +766,20 @@ } } + async function deactivate(slug) { + if (!confirm(`Deactivate "${slug}"?\n\nThis removes the deployment but keeps the database and storage. You can reactivate later.`)) return; + const res = await apiFetch(`/api/customers/${slug}/deactivate`, { method: 'POST' }); + if (res.ok) { loadCustomers(); if (currentDetailSlug === slug) loadDetail(slug, false); } + else { const d = await res.json().catch(() => ({})); alert(d.message ?? 'Failed'); } + } + + async function reactivate(slug) { + if (!confirm(`Reactivate "${slug}"?\n\nThis will redeploy the application.`)) return; + const res = await apiFetch(`/api/customers/${slug}/reactivate`, { method: 'POST' }); + if (res.ok) { loadCustomers(); if (currentDetailSlug === slug) loadDetail(slug, false); } + else { const d = await res.json().catch(() => ({})); alert(d.message ?? 'Failed'); } + } + async function removeRecord(slug) { if (!confirm(`Remove "${slug}" from the manager database only?\n\nThis will NOT delete the ArgoCD app or DO database.`)) return; const res = await apiFetch(`/api/customers/${slug}/record`, { method: 'DELETE' }); diff --git a/src/lib/config.ts b/src/lib/config.ts index cdc3ead..ffeda21 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -10,6 +10,8 @@ export const config = { managedValkeyUrl: process.env.MANAGED_VALKEY_URL!, spacesBucket: process.env.SPACES_BUCKET ?? "lunarfront-data", spacesRegion: process.env.SPACES_REGION ?? "nyc3", + spacesKey: process.env.SPACES_KEY!, + spacesSecret: process.env.SPACES_SECRET!, }; for (const [key, val] of Object.entries(config)) { diff --git a/src/routes/customers.ts b/src/routes/customers.ts index 59d79bc..720cc7d 100644 --- a/src/routes/customers.ts +++ b/src/routes/customers.ts @@ -7,7 +7,7 @@ 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 { deleteSpacesObjects, getSpacesUsage } from "../services/spaces"; import { db } from "../db/manager"; import { config } from "../lib/config"; import { getCachedStatus, setCachedStatus } from "../lib/cache"; @@ -126,18 +126,13 @@ 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-key": config.spacesKey, + "spaces-secret": config.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); @@ -162,19 +157,14 @@ 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 {} - } + // Delete all objects under this customer's prefix in Spaces + try { + await deleteSpacesObjects(config.spacesKey, config.spacesSecret, config.spacesBucket, config.spacesRegion, `${slug}/`); + } catch {} await Promise.all([ deleteDatabase(slug), @@ -247,6 +237,35 @@ export async function customerRoutes(app: FastifyInstance) { // Remove only the manager DB record without touching infrastructure — // useful for cleaning up failed partial deployments + // Deactivate: remove ArgoCD chart (stops pods) but keep DB, namespace, secrets, and manager record + app.post("/customers/:slug/deactivate", async (req, reply) => { + const { slug } = req.params as { slug: string }; + removeCustomerChart(slug); + await removeCustomerFromPool(slug); + await db`UPDATE customers SET status = 'inactive', updated_at = NOW() WHERE slug = ${slug}`; + app.log.info({ slug }, "customer deactivated"); + return reply.code(200).send({ slug, status: "inactive" }); + }); + + // Reactivate: push ArgoCD chart back, re-add to pool + app.post("/customers/:slug/reactivate", async (req, reply) => { + const { slug } = req.params as { slug: string }; + const [customer] = await db`SELECT * FROM customers WHERE slug = ${slug}`; + if (!customer) return reply.code(404).send({ message: "Not found" }); + + // Re-add to pgbouncer — need the password from the k8s secret + const namespace = `customer-${slug}`; + const secrets = await getSecret(namespace, "lunarfront-secrets"); + const dbUrl = new URL(secrets["database-url"]); + await addCustomerToPool(slug, dbUrl.password); + + addCustomerChart(slug, "*"); + await db`UPDATE customers SET status = 'provisioned', updated_at = NOW() WHERE slug = ${slug}`; + app.log.info({ slug }, "customer reactivated"); + return reply.code(200).send({ slug, status: "provisioned" }); + }); + + // Remove only the manager DB record without touching infrastructure app.delete("/customers/:slug/record", async (req, reply) => { const { slug } = req.params as { slug: string }; await db`DELETE FROM customers WHERE slug = ${slug}`; diff --git a/src/services/sizeCollector.ts b/src/services/sizeCollector.ts index bce3de1..7784589 100644 --- a/src/services/sizeCollector.ts +++ b/src/services/sizeCollector.ts @@ -1,11 +1,10 @@ import postgres from "postgres"; import { db } from "../db/manager"; import { config } from "../lib/config"; -import { getSecret } from "../lib/k8s"; import { getSpacesUsage } from "./spaces"; async function collectSizes() { - const customers = await db`SELECT slug, spaces_key FROM customers WHERE status = 'provisioned'`; + const customers = await db`SELECT slug FROM customers WHERE status = 'provisioned'`; if (customers.length === 0) return; for (const customer of customers) { @@ -26,21 +25,17 @@ async function collectSizes() { // Spaces size let spacesSizeBytes: number | null = null; let spacesObjectCount: number | null = null; - if (customer.spaces_key) { - try { - const namespace = `customer-${slug}`; - const secrets = await getSecret(namespace, "lunarfront-secrets"); - const result = await getSpacesUsage( - customer.spaces_key, - secrets["spaces-secret"], - config.spacesBucket, - config.spacesRegion, - `${slug}/`, - ); - spacesSizeBytes = result.sizeBytes; - spacesObjectCount = result.objectCount; - } catch {} - } + try { + const result = await getSpacesUsage( + config.spacesKey, + config.spacesSecret, + config.spacesBucket, + config.spacesRegion, + `${slug}/`, + ); + spacesSizeBytes = result.sizeBytes; + spacesObjectCount = result.objectCount; + } catch {} // Upsert today's snapshot (one row per day per customer) await db`