feat: add individual and bulk chart upgrade, fix health check URL
All checks were successful
Build & Release / build (push) Successful in 12s

This commit is contained in:
Ryan Moon
2026-04-03 21:26:35 -05:00
parent 766ad63278
commit 31684f4a15
4 changed files with 84 additions and 3 deletions

View File

@@ -228,6 +228,7 @@
<div class="table-toolbar"> <div class="table-toolbar">
<input class="search-input" id="customers-search" type="text" placeholder="Search customers…" oninput="onSearchInput()" /> <input class="search-input" id="customers-search" type="text" placeholder="Search customers…" oninput="onSearchInput()" />
<button class="btn btn-slate" onclick="loadCustomers()">Refresh</button> <button class="btn btn-slate" onclick="loadCustomers()">Refresh</button>
<button class="btn btn-slate" id="upgrade-all-btn" onclick="upgradeAll()">Upgrade All</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@@ -347,6 +348,7 @@
<div class="kebab-item" onclick="kebabAction('view')">View Details</div> <div class="kebab-item" onclick="kebabAction('view')">View Details</div>
<div class="kebab-item" id="kebab-deactivate" onclick="kebabAction('deactivate')">Deactivate</div> <div class="kebab-item" id="kebab-deactivate" onclick="kebabAction('deactivate')">Deactivate</div>
<div class="kebab-item" id="kebab-reactivate" onclick="kebabAction('reactivate')" style="display:none">Reactivate</div> <div class="kebab-item" id="kebab-reactivate" onclick="kebabAction('reactivate')" style="display:none">Reactivate</div>
<div class="kebab-item" onclick="kebabAction('upgrade')">Upgrade Chart</div>
<div class="kebab-item danger" onclick="kebabAction('delete')">Delete</div> <div class="kebab-item danger" onclick="kebabAction('delete')">Delete</div>
<div class="kebab-item" onclick="kebabAction('record')">Remove Record Only</div> <div class="kebab-item" onclick="kebabAction('record')">Remove Record Only</div>
</div> </div>
@@ -580,6 +582,7 @@
if (action === 'view') openDetail(slug); if (action === 'view') openDetail(slug);
else if (action === 'deactivate') deactivate(slug); else if (action === 'deactivate') deactivate(slug);
else if (action === 'reactivate') reactivate(slug); else if (action === 'reactivate') reactivate(slug);
else if (action === 'upgrade') upgradeCustomer(slug);
else if (action === 'delete') openDeleteDialog(slug); else if (action === 'delete') openDeleteDialog(slug);
else if (action === 'record') removeRecord(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 ───────────────────────────────────────────────────────────── // ── Provision ─────────────────────────────────────────────────────────────
function toggleModule(checkbox) { function toggleModule(checkbox) {

View File

@@ -4,7 +4,7 @@ import crypto from "crypto";
import postgres from "postgres"; import postgres from "postgres";
import { createDatabase, createDatabaseUser, deleteDatabase, deleteDatabaseUser } from "../services/do"; import { createDatabase, createDatabaseUser, deleteDatabase, deleteDatabaseUser } from "../services/do";
import { addCustomerToPool, removeCustomerFromPool } from "../services/pgbouncer"; 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 { setupCustomerDatabase, teardownCustomerDatabase } from "../services/db";
import { createNamespace, deleteNamespace, createSecret, createDockerRegistrySecret, patchSecret, getSecret, k8sFetch } from "../lib/k8s"; import { createNamespace, deleteNamespace, createSecret, createDockerRegistrySecret, patchSecret, getSecret, k8sFetch } from "../lib/k8s";
import { deleteSpacesObjects, getSpacesUsage } from "../services/spaces"; import { deleteSpacesObjects, getSpacesUsage } from "../services/spaces";
@@ -305,6 +305,27 @@ export async function customerRoutes(app: FastifyInstance) {
return reply.code(200).send({ slug, status: "provisioned" }); 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 // Remove only the manager DB record without touching infrastructure
app.delete("/customers/:slug/record", async (req, reply) => { app.delete("/customers/:slug/record", async (req, reply) => {
const { slug } = req.params as { slug: string }; const { slug } = req.params as { slug: string };

View File

@@ -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 }> { export async function checkCustomerHealth(slug: string): Promise<{ reachable: boolean; status: number | null }> {
try { 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), signal: AbortSignal.timeout(5000),
}); });
return { reachable: res.ok, status: res.status }; return { reachable: res.ok, status: res.status };

View File

@@ -4,7 +4,7 @@ import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { config } from "../lib/config"; import { config } from "../lib/config";
async function getLatestChartVersion(): Promise<string> { export async function getLatestChartVersion(): Promise<string> {
const res = await fetch("https://api.digitalocean.com/v2/registry/lunarfront/repositories/lunarfront/tags?page=1&per_page=100", { const res = await fetch("https://api.digitalocean.com/v2/registry/lunarfront/repositories/lunarfront/tags?page=1&per_page=100", {
headers: { Authorization: `Bearer ${config.doToken}` }, 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) { export function removeCustomerChart(slug: string) {
withRepo((dir, env) => { withRepo((dir, env) => {
const filePath = join(dir, "customers", `${slug}.yaml`); const filePath = join(dir, "customers", `${slug}.yaml`);