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
Reactivate
+ 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`);