feat: add maintenance toggle command
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
MaintenanceService,
|
||||
maintenanceMiddleware,
|
||||
maintenanceRoute,
|
||||
normalizeMaintenanceHost,
|
||||
type MaintenancePersistence,
|
||||
} from "../../server/maintenance";
|
||||
import { createApp } from "../../server/app";
|
||||
import { hashToken, MemoryAuthStore } from "../../server/auth";
|
||||
|
||||
class MemoryPersistence implements MaintenancePersistence {
|
||||
state: { hosts: string[] } | undefined;
|
||||
resources: Record<string, unknown>[] = [];
|
||||
deleted = 0;
|
||||
routePresent = false;
|
||||
failApply = false;
|
||||
failWrite = false;
|
||||
async readState() {
|
||||
return this.state;
|
||||
}
|
||||
async writeState(value: { hosts: string[] }) {
|
||||
if (this.failWrite) throw new Error("state write failed");
|
||||
this.state = value;
|
||||
}
|
||||
async routeExists() {
|
||||
return this.routePresent;
|
||||
}
|
||||
async apply(resource: Record<string, unknown>) {
|
||||
if (this.failApply) throw new Error("route apply failed");
|
||||
this.resources.push(resource);
|
||||
if (resource.kind === "IngressRoute") this.routePresent = true;
|
||||
}
|
||||
async deleteRoute() {
|
||||
this.deleted += 1;
|
||||
this.routePresent = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lease = { acquire: async () => ({ release: async () => {} }) };
|
||||
|
||||
describe("maintenance override", () => {
|
||||
test("normalizes only DNS hostnames", () => {
|
||||
expect(normalizeMaintenanceHost(" Sub.Domain.COM. ")).toBe(
|
||||
"sub.domain.com",
|
||||
);
|
||||
for (const host of [
|
||||
"http://example.com",
|
||||
"example.com:443",
|
||||
"127.0.0.1",
|
||||
"[::1]",
|
||||
"*.example.com",
|
||||
"example",
|
||||
"a..com",
|
||||
])
|
||||
expect(() => normalizeMaintenanceHost(host)).toThrow();
|
||||
});
|
||||
|
||||
test("enables, deduplicates, and disables the last host", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const service = new MaintenanceService(persistence, lease);
|
||||
expect((await service.status("a.example.com")).enabled).toBe(false);
|
||||
expect((await service.toggle("A.example.com")).hosts).toEqual([
|
||||
"a.example.com",
|
||||
]);
|
||||
persistence.state = {
|
||||
hosts: ["a.example.com", "b.example.com", "A.example.com"],
|
||||
};
|
||||
expect((await service.toggle("a.example.com")).hosts).toEqual([
|
||||
"b.example.com",
|
||||
]);
|
||||
expect((await service.toggle("b.example.com")).hosts).toEqual([]);
|
||||
expect(persistence.deleted).toBe(2);
|
||||
expect(persistence.state).toEqual({ hosts: [] });
|
||||
});
|
||||
|
||||
test("renders the single shared error route and rewrite middleware", () => {
|
||||
expect(maintenanceMiddleware()).toMatchObject({
|
||||
metadata: { name: "maintenance-override", namespace: "routing" },
|
||||
spec: {
|
||||
replacePathRegex: { regex: "^/.*", replacement: "/__error/1001" },
|
||||
},
|
||||
});
|
||||
expect(maintenanceRoute(["a.example.com", "b.example.com"])).toMatchObject({
|
||||
metadata: { name: "maintenance-override", namespace: "routing" },
|
||||
spec: {
|
||||
routes: [
|
||||
{
|
||||
match: "Host(`a.example.com`) || Host(`b.example.com`)",
|
||||
priority: 1_000_000,
|
||||
services: [
|
||||
{
|
||||
name: "error-page",
|
||||
namespace: "routing",
|
||||
port: 3000,
|
||||
scheme: "http",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("maps an unavailable global lease to a retryable API conflict", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({ username: "operator", passwordHash: "hash", roles: ["operator"] });
|
||||
await store.putSession({ tokenHash: hashToken("operator-token"), username: "operator", roles: ["operator"], expiresAt: "2099-01-01T00:00:00.000Z" });
|
||||
const app = createApp({
|
||||
store,
|
||||
maintenance: new MaintenanceService(persistence, { acquire: async () => undefined }),
|
||||
});
|
||||
const result = await app(new Request("https://kuber.astrxl.dev/api/v2/maintenance/a.example.com", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer operator-token", "content-type": "application/json" },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
}));
|
||||
expect(result.status).toBe(409);
|
||||
expect(result.headers.get("retry-after")).toBe("1");
|
||||
expect(await result.json()).toMatchObject({ code: "MAINTENANCE_BUSY" });
|
||||
expect(persistence.state).toBeUndefined();
|
||||
});
|
||||
|
||||
test("recovers the persisted desired state after route or state failures", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const service = new MaintenanceService(persistence, lease);
|
||||
persistence.failApply = true;
|
||||
await expect(service.set("a.example.com", true)).rejects.toThrow("route apply failed");
|
||||
expect(persistence.state).toBeUndefined();
|
||||
persistence.failApply = false;
|
||||
expect(await service.status("a.example.com")).toMatchObject({ enabled: false });
|
||||
|
||||
persistence.failWrite = true;
|
||||
await expect(service.set("a.example.com", true)).rejects.toThrow("state write failed");
|
||||
expect(persistence.routePresent).toBe(true);
|
||||
persistence.failWrite = false;
|
||||
expect(await service.status("a.example.com")).toMatchObject({ enabled: false });
|
||||
expect(persistence.routePresent).toBe(false);
|
||||
|
||||
expect(await service.set("a.example.com", true)).toMatchObject({ enabled: true });
|
||||
persistence.routePresent = false;
|
||||
expect(await service.status("a.example.com")).toMatchObject({ enabled: true });
|
||||
expect(persistence.routePresent).toBe(true);
|
||||
});
|
||||
|
||||
test("requires kubernetes write without workspace or trust context", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({
|
||||
username: "viewer",
|
||||
passwordHash: "hash",
|
||||
roles: ["viewer"],
|
||||
});
|
||||
await store.putUser({
|
||||
username: "operator",
|
||||
passwordHash: "hash",
|
||||
roles: ["operator"],
|
||||
});
|
||||
for (const token of ["viewer-token", "operator-token"])
|
||||
await store.putSession({
|
||||
tokenHash: hashToken(token),
|
||||
username: token.startsWith("viewer") ? "viewer" : "operator",
|
||||
roles: token.startsWith("viewer") ? ["viewer"] : ["operator"],
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
await store.createApiKey({
|
||||
id: "maintenance-scoped-key",
|
||||
tokenHash: hashToken("scoped-key"),
|
||||
username: "operator",
|
||||
capabilities: ["kubernetes:write"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
await store.createApiKey({
|
||||
id: "maintenance-global-key",
|
||||
tokenHash: hashToken("global-key"),
|
||||
username: "operator",
|
||||
capabilities: ["kubernetes:write"],
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
const persistence = new MemoryPersistence();
|
||||
const app = createApp({
|
||||
store,
|
||||
maintenance: new MaintenanceService(persistence, lease),
|
||||
});
|
||||
const viewer = await app(
|
||||
new Request("https://kuber.astrxl.dev/api/v2/maintenance/a.example.com", {
|
||||
headers: { authorization: "Bearer viewer-token" },
|
||||
}),
|
||||
);
|
||||
expect(viewer.status).toBe(403);
|
||||
const scoped = await app(
|
||||
new Request("https://kuber.astrxl.dev/api/v2/maintenance/a.example.com", {
|
||||
headers: { authorization: "Bearer scoped-key" },
|
||||
}),
|
||||
);
|
||||
expect(scoped.status).toBe(403);
|
||||
expect(await scoped.json()).toMatchObject({ code: "MAINTENANCE_GLOBAL_SCOPE_REQUIRED" });
|
||||
const global = await app(
|
||||
new Request("https://kuber.astrxl.dev/api/v2/maintenance/a.example.com", {
|
||||
headers: { authorization: "Bearer global-key" },
|
||||
}),
|
||||
);
|
||||
expect(global.status).toBe(200);
|
||||
const status = await app(
|
||||
new Request("https://kuber.astrxl.dev/api/v2/maintenance/a.example.com", {
|
||||
headers: { authorization: "Bearer operator-token" },
|
||||
}),
|
||||
);
|
||||
expect(await status.json()).toMatchObject({ enabled: false });
|
||||
const toggle = await app(
|
||||
new Request("https://kuber.astrxl.dev/api/v2/maintenance/a.example.com", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer operator-token",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
}),
|
||||
);
|
||||
expect(await toggle.json()).toMatchObject({ enabled: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user