diff --git a/frontend/index.html b/frontend/index.html index e46316c..8779caa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -228,6 +228,7 @@
+
@@ -347,6 +348,7 @@
View Details
Deactivate
+
Upgrade Chart
Delete
Remove Record Only
@@ -580,6 +582,7 @@ if (action === 'view') openDetail(slug); else if (action === 'deactivate') deactivate(slug); else if (action === 'reactivate') reactivate(slug); + else if (action === 'upgrade') upgradeCustomer(slug); else if (action === 'delete') openDeleteDialog(slug); else if (action === 'record') removeRecord(slug); } @@ -830,6 +833,43 @@ } } + // ── Upgrade ─────────────────────────────────────────────────────────────── + + async function upgradeCustomer(slug) { + const btn = event?.target; + if (btn) { btn.disabled = true; btn.textContent = 'Upgrading…'; } + try { + const res = await apiFetch(`/api/customers/${slug}/upgrade`, { method: 'POST' }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? 'Failed'); + alert(`${slug} upgraded to chart ${data.version}`); + loadCustomers(); + } catch (err) { + alert(`Upgrade failed: ${err.message}`); + } finally { + if (btn) { btn.disabled = false; btn.textContent = 'Upgrade Chart'; } + } + } + + async function upgradeAll() { + const btn = document.getElementById('upgrade-all-btn'); + btn.disabled = true; + btn.textContent = 'Upgrading…'; + try { + const res = await apiFetch('/api/customers/upgrade-all', { method: 'POST' }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? 'Failed'); + if (!data.upgraded.length) { alert('No provisioned customers to upgrade.'); return; } + alert(`Upgraded ${data.upgraded.length} customer(s) to chart ${data.version}:\n${data.upgraded.join(', ')}`); + loadCustomers(); + } catch (err) { + alert(`Upgrade all failed: ${err.message}`); + } finally { + btn.disabled = false; + btn.textContent = 'Upgrade All'; + } + } + // ── Provision ───────────────────────────────────────────────────────────── function toggleModule(checkbox) { diff --git a/src/routes/customers.ts b/src/routes/customers.ts index 42d1f18..4552495 100644 --- a/src/routes/customers.ts +++ b/src/routes/customers.ts @@ -4,7 +4,7 @@ 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 { addCustomerChart, removeCustomerChart, upgradeCustomerChart, upgradeAllCustomerCharts, getLatestChartVersion } from "../services/git"; import { setupCustomerDatabase, teardownCustomerDatabase } from "../services/db"; import { createNamespace, deleteNamespace, createSecret, createDockerRegistrySecret, patchSecret, getSecret, k8sFetch } from "../lib/k8s"; import { deleteSpacesObjects, getSpacesUsage } from "../services/spaces"; @@ -305,6 +305,27 @@ export async function customerRoutes(app: FastifyInstance) { return reply.code(200).send({ slug, status: "provisioned" }); }); + app.post("/customers/:slug/upgrade", 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" }); + const version = await getLatestChartVersion(); + await upgradeCustomerChart(slug, version); + await db`UPDATE customers SET updated_at = NOW() WHERE slug = ${slug}`; + app.log.info({ slug, version }, "customer chart upgraded"); + return reply.send({ slug, version }); + }); + + app.post("/customers/upgrade-all", async (req, reply) => { + const customers = await db`SELECT slug FROM customers WHERE status = 'provisioned'`; + if (!customers.length) return reply.send({ upgraded: [], version: null }); + const version = await getLatestChartVersion(); + const slugs = customers.map((c: any) => c.slug); + await upgradeAllCustomerCharts(slugs, version); + app.log.info({ slugs, version }, "all customers chart upgraded"); + return reply.send({ upgraded: slugs, version }); + }); + // Remove only the manager DB record without touching infrastructure app.delete("/customers/:slug/record", async (req, reply) => { const { slug } = req.params as { slug: string }; diff --git a/src/services/cloudflare.ts b/src/services/cloudflare.ts index f15e2ea..996bdfa 100644 --- a/src/services/cloudflare.ts +++ b/src/services/cloudflare.ts @@ -44,7 +44,7 @@ export async function getCustomerDnsRecord(slug: string): Promise<{ exists: bool export async function checkCustomerHealth(slug: string): Promise<{ reachable: boolean; status: number | null }> { try { - const res = await fetch(`https://${slug}.lunarfront.tech/api/health`, { + const res = await fetch(`https://${slug}.lunarfront.tech/v1/health`, { signal: AbortSignal.timeout(5000), }); return { reachable: res.ok, status: res.status }; diff --git a/src/services/git.ts b/src/services/git.ts index 5883af1..00fdb58 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -4,7 +4,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { config } from "../lib/config"; -async function getLatestChartVersion(): Promise { +export async function getLatestChartVersion(): Promise { const res = await fetch("https://api.digitalocean.com/v2/registry/lunarfront/repositories/lunarfront/tags?page=1&per_page=100", { headers: { Authorization: `Bearer ${config.doToken}` }, }); @@ -57,6 +57,26 @@ export async function addCustomerChart(slug: string, appVersion: string) { }); } +export async function upgradeCustomerChart(slug: string, version: string) { + withRepo((dir, env) => { + const manifest = buildArgoCDApp(slug, version); + writeFileSync(join(dir, "customers", `${slug}.yaml`), manifest); + execSync(`git -C ${dir} add customers/${slug}.yaml`, { env }); + execSync(`git -C ${dir} commit -m "chore: upgrade customer ${slug} to chart ${version}"`, { env }); + }); +} + +export async function upgradeAllCustomerCharts(slugs: string[], version: string) { + withRepo((dir, env) => { + for (const slug of slugs) { + const manifest = buildArgoCDApp(slug, version); + writeFileSync(join(dir, "customers", `${slug}.yaml`), manifest); + execSync(`git -C ${dir} add customers/${slug}.yaml`, { env }); + } + execSync(`git -C ${dir} commit -m "chore: upgrade all customers to chart ${version}"`, { env }); + }); +} + export function removeCustomerChart(slug: string) { withRepo((dir, env) => { const filePath = join(dir, "customers", `${slug}.yaml`);