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 @@
@@ -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`