feat: prepare 2.6.1-rc5 shared databases and build SSE
This commit is contained in:
@@ -4,6 +4,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Writable } from "node:stream";
|
||||
import { stripVTControlCharacters } from "node:util";
|
||||
import type { ApiRequestInit, ApiRequestOptions } from "../../lib/api";
|
||||
import { KuberApiError } from "../../lib/api";
|
||||
import type { ApiRequester } from "../../lib/build";
|
||||
@@ -25,8 +26,21 @@ const snapshot = {
|
||||
blobs: [],
|
||||
};
|
||||
|
||||
// DefaultRenderer.create returns a complete TTY frame before the cursor redraw.
|
||||
function frameRows(frame: string): string[] {
|
||||
return stripVTControlCharacters(frame).split("\n");
|
||||
}
|
||||
|
||||
function taskRow(rows: string[], title: string): string {
|
||||
return rows.find((row) => row.includes(title)) ?? "";
|
||||
}
|
||||
|
||||
function depth(row: string): number {
|
||||
return row.match(/^\s*/)?.[0].length ?? 0;
|
||||
}
|
||||
|
||||
describe("up API pipeline", () => {
|
||||
test("keeps queued builds in the TTY render tree without duplicate rows", async () => {
|
||||
test("renders build lifecycle in child titles without duplicate phase output", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-up-api-"));
|
||||
const previousCwd = process.cwd();
|
||||
const tty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
@@ -37,9 +51,7 @@ describe("up API pipeline", () => {
|
||||
frames.push(frame);
|
||||
return frame;
|
||||
});
|
||||
const writes = spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => {
|
||||
return true;
|
||||
}) as typeof process.stdout.write);
|
||||
const writes = spyOn(process.stdout, "write").mockImplementation((() => true) as typeof process.stdout.write);
|
||||
const polls = new Map<string, number>();
|
||||
const builds = new Map<string, string>();
|
||||
try {
|
||||
@@ -60,13 +72,13 @@ describe("up API pipeline", () => {
|
||||
if (path.includes("/events"))
|
||||
return [{ type: "status", status: {
|
||||
state: "queued",
|
||||
phase: ["queued", "preparing", "waiting", "waiting"][polls.get(service) ?? 0],
|
||||
phase: ["queued", "creating", "starting", "running", "done"][polls.get(service) ?? 0],
|
||||
} }] as T;
|
||||
if (path.endsWith("/reconcile")) {
|
||||
const count = (polls.get(service) ?? 0) + 1;
|
||||
polls.set(service, count);
|
||||
await Bun.sleep(110);
|
||||
return count < 3
|
||||
return count < 4
|
||||
? { state: "queued" } as T
|
||||
: service === "client"
|
||||
? { state: "failed", error: "build stopped\nstack detail" } as T
|
||||
@@ -76,29 +88,27 @@ describe("up API pipeline", () => {
|
||||
throw new Error(path);
|
||||
};
|
||||
await expect(provideContext(() => runUp(true, request, { trust }))).rejects.toThrow("build stopped");
|
||||
expect(polls.get("client")).toBe(3);
|
||||
const active = frames.filter((frame) =>
|
||||
frame.includes("Build images") && frame.includes("Build app") && frame.includes("Build client") &&
|
||||
frame.split("\n").filter((row) => row.includes("Build queued")).length === 2);
|
||||
expect(polls.get("client")).toBe(4);
|
||||
const active = frames.map(frameRows).filter((rows) =>
|
||||
taskRow(rows, "Build images") && taskRow(rows, "Build app") && taskRow(rows, "Build client"));
|
||||
expect(active.length).toBeGreaterThan(0);
|
||||
for (const frame of frames.filter((frame) => frame.includes("Build images") && frame.includes("Build app") && frame.includes("Build client"))) {
|
||||
const rows = frame.split("\n");
|
||||
const parent = rows.find((row) => row.includes("Build images"))!;
|
||||
for (const rows of active) {
|
||||
const parent = taskRow(rows, "Build images");
|
||||
for (const name of ["app", "client"]) {
|
||||
const children = rows.filter((row) => row.includes(`Build ${name}`));
|
||||
expect(children).toHaveLength(1);
|
||||
expect(children[0]!.search(/\S/)).toBeGreaterThan(parent.search(/\S/));
|
||||
expect(depth(children[0]!)).toBeGreaterThan(depth(parent));
|
||||
}
|
||||
expect(rows.filter((row) => row.includes("Build queued")).length).toBeLessThanOrEqual(2);
|
||||
expect(rows.filter((row) => /^\s*› Build (?:queued|creating|starting|running|done)/.test(row))).toHaveLength(0);
|
||||
}
|
||||
expect(active[0]).toMatch(/Build app\n {2,}.*Build queued[\s\S]*Build client\n {2,}.*Build queued/);
|
||||
const final = frames.at(-1)!;
|
||||
expect(final).toMatch(/Build images[\s\S]* .*Build app[\s\S]* .*Build client/);
|
||||
expect(final).toMatch(/✔.*Build app/);
|
||||
expect(final).toMatch(/✖.*Build client/);
|
||||
expect(frames.some((frame) => frame.includes("Build preparing") && frame.includes("Build waiting"))).toBe(true);
|
||||
expect(final).toContain("build stopped");
|
||||
expect(final).toContain("stack detail");
|
||||
for (const phase of ["queued", "creating", "starting", "running", "done"])
|
||||
expect(frames.some((frame) => frame.includes(`Build app: ${phase}`))).toBe(true);
|
||||
const final = frameRows(frames.at(-1)!);
|
||||
expect(taskRow(final, "Build app: done")).toMatch(/✔/);
|
||||
expect(taskRow(final, "Build client: failed")).toMatch(/✖/);
|
||||
expect(final.join("\n")).toContain("build stopped");
|
||||
expect(final.join("\n")).toContain("stack detail");
|
||||
expect(final.join("\n").split("build stopped")).toHaveLength(2);
|
||||
} finally {
|
||||
renderSpy.mockRestore();
|
||||
writes.mockRestore();
|
||||
@@ -292,11 +302,18 @@ describe("up API pipeline", () => {
|
||||
const order: string[] = [];
|
||||
const started: string[] = [];
|
||||
let rootTasks: Listr["tasks"] | undefined;
|
||||
let backingTasks: Listr["tasks"] | undefined;
|
||||
const originalRun = Listr.prototype.run;
|
||||
const frames: string[] = [];
|
||||
const create = DefaultRenderer.prototype.create;
|
||||
const renderSpy = spyOn(DefaultRenderer.prototype, "create").mockImplementation(function (this: DefaultRenderer, options) {
|
||||
const frame = create.call(this, options);
|
||||
frames.push(frame);
|
||||
return frame;
|
||||
});
|
||||
const tty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
const writes = spyOn(process.stdout, "write").mockImplementation((() => true) as typeof process.stdout.write);
|
||||
const runSpy = spyOn(Listr.prototype, "run").mockImplementation(function (this: Listr) {
|
||||
if (this.tasks[0]?.title === "Read compose") rootTasks = this.tasks;
|
||||
if (this.tasks[0]?.title === "Reconcile databases") backingTasks = this.tasks;
|
||||
return originalRun.call(this);
|
||||
});
|
||||
let bothReady!: () => void;
|
||||
@@ -312,6 +329,7 @@ describe("up API pipeline", () => {
|
||||
releaseStorage = resolve;
|
||||
});
|
||||
try {
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
await writeFile(
|
||||
join(root, "compose.yml"),
|
||||
"services:\n app:\n image: nginx\n volumes:\n - postgresql:app\n - s3:assets\n",
|
||||
@@ -356,10 +374,21 @@ describe("up API pipeline", () => {
|
||||
expect(titles.indexOf("Render manifests")).toBeLessThan(
|
||||
titles.indexOf("Reconcile resources"),
|
||||
);
|
||||
expect(backingTasks!.map(({ title }) => title)).toEqual([
|
||||
const backing = rootTasks!.find(({ title }) => title === "Reconcile backing services")!;
|
||||
expect(backing.subtasks.map(({ title }) => title)).toEqual([
|
||||
"Reconcile databases",
|
||||
"Reconcile S3 storage",
|
||||
]);
|
||||
const active = frames.map(frameRows).filter((rows) =>
|
||||
taskRow(rows, "Reconcile backing services") &&
|
||||
taskRow(rows, "Reconcile databases") &&
|
||||
taskRow(rows, "Reconcile S3 storage"));
|
||||
expect(active.length).toBeGreaterThan(0);
|
||||
for (const rows of active) {
|
||||
const parent = taskRow(rows, "Reconcile backing services");
|
||||
expect(depth(taskRow(rows, "Reconcile databases"))).toBeGreaterThan(depth(parent));
|
||||
expect(depth(taskRow(rows, "Reconcile S3 storage"))).toBeGreaterThan(depth(parent));
|
||||
}
|
||||
expect(rootTasks!.filter(({ title }) => title === "Reconcile databases")).toEqual([]);
|
||||
expect(started).toEqual(["database", "storage"]);
|
||||
expect(order.indexOf("/workspaces/shop/adopt")).toBeLessThan(
|
||||
@@ -388,6 +417,10 @@ describe("up API pipeline", () => {
|
||||
}
|
||||
} finally {
|
||||
runSpy.mockRestore();
|
||||
renderSpy.mockRestore();
|
||||
writes.mockRestore();
|
||||
if (tty) Object.defineProperty(process.stdout, "isTTY", tty);
|
||||
else Reflect.deleteProperty(process.stdout, "isTTY");
|
||||
process.chdir(previousCwd);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -396,17 +429,18 @@ describe("up API pipeline", () => {
|
||||
test("shows database API problem details on the database task without starting resources", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-up-api-"));
|
||||
const previousCwd = process.cwd();
|
||||
const rendered: string[] = [];
|
||||
const writes = spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => {
|
||||
rendered.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write);
|
||||
const errors = spyOn(process.stderr, "write").mockImplementation(((chunk: string | Uint8Array) => {
|
||||
rendered.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stderr.write);
|
||||
const frames: string[] = [];
|
||||
const create = DefaultRenderer.prototype.create;
|
||||
const renderSpy = spyOn(DefaultRenderer.prototype, "create").mockImplementation(function (this: DefaultRenderer, options) {
|
||||
const frame = create.call(this, options);
|
||||
frames.push(frame);
|
||||
return frame;
|
||||
});
|
||||
const tty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
const writes = spyOn(process.stdout, "write").mockImplementation((() => true) as typeof process.stdout.write);
|
||||
const calls: string[] = [];
|
||||
try {
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
await writeFile(join(root, "compose.yml"), "services:\n web:\n image: nginx\n volumes:\n - postgresql:web_db\n");
|
||||
await writeFile(join(root, ".kuberrc.ts"), 'export default { project: "shop" };\n');
|
||||
process.chdir(root);
|
||||
@@ -423,11 +457,17 @@ describe("up API pipeline", () => {
|
||||
throw new Error(`Unexpected request: ${path}`);
|
||||
};
|
||||
await expect(provideContext(() => runUp(false, request, { trust }))).rejects.toThrow(detail);
|
||||
expect(rendered.join("")).toContain("database apply for database web_db");
|
||||
const rows = frameRows(frames.at(-1)!);
|
||||
expect(depth(taskRow(rows, "Reconcile databases"))).toBeGreaterThan(depth(taskRow(rows, "Reconcile backing services")));
|
||||
expect(taskRow(rows, "Reconcile databases")).toMatch(/✖/);
|
||||
const visible = rows.join(" ").replace(/\s+/g, " ");
|
||||
expect(visible.split(detail)).toHaveLength(2);
|
||||
expect(calls).not.toContain("/workspaces/shop/resources/plan");
|
||||
} finally {
|
||||
errors.mockRestore();
|
||||
renderSpy.mockRestore();
|
||||
writes.mockRestore();
|
||||
if (tty) Object.defineProperty(process.stdout, "isTTY", tty);
|
||||
else Reflect.deleteProperty(process.stdout, "isTTY");
|
||||
process.chdir(previousCwd);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { apiStreamEvents, ApiStreamUnsupportedError } from "../../lib/api";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("parses split UTF-8 SSE frames, comments and ids with authenticated request", async () => {
|
||||
let headers: Headers | undefined;
|
||||
const versions: Array<string | null> = [];
|
||||
globalThis.fetch = (async (
|
||||
_url: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
headers = new Headers(init?.headers);
|
||||
const bytes = new TextEncoder().encode(
|
||||
': ping\r\nid: 3\r\nevent: log\r\ndata: {"type":"log","message":"hé"}\r\n\r\nevent: status\ndata: {"type":"status"}\n\n',
|
||||
);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (let i = 0; i < bytes.length; i += 2)
|
||||
controller.enqueue(bytes.slice(i, i + 2));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
"x-kuber-version": "2.6.1-rc5",
|
||||
},
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
const events = [];
|
||||
for await (const event of apiStreamEvents("/builds/a/events", 2, undefined, {
|
||||
baseUrl: "https://test",
|
||||
session: { token: "secret" } as never,
|
||||
onServerVersion: (value) => versions.push(value),
|
||||
}))
|
||||
events.push(event);
|
||||
expect(headers?.get("authorization")).toBe("Bearer secret");
|
||||
expect(headers?.get("last-event-id")).toBe("2");
|
||||
expect(versions).toEqual(["2.6.1-rc5"]);
|
||||
expect(events).toEqual([
|
||||
{ id: 3, event: { type: "log", message: "hé" } },
|
||||
{ id: undefined, event: { type: "status" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("identifies existing JSON-only servers without parsing their response as SSE", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("[]", {
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof fetch;
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of apiStreamEvents("/builds/a/events", 0, undefined, {
|
||||
baseUrl: "https://test",
|
||||
session: { token: "secret" } as never,
|
||||
})) {
|
||||
/* consume */
|
||||
}
|
||||
})(),
|
||||
).rejects.toBeInstanceOf(ApiStreamUnsupportedError);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
apiUpload,
|
||||
} from "../../lib/api";
|
||||
import type { KuberSession } from "../../lib/session";
|
||||
import { createVersionObserver } from "../../lib/version-update";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const session: KuberSession = {
|
||||
@@ -19,6 +20,73 @@ afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("observes the response header on JSON, errors, uploads and streaming without delaying payloads", async () => {
|
||||
const seen: Array<string | null> = [];
|
||||
const onServerVersion = (version: string | null) => {
|
||||
seen.push(version);
|
||||
};
|
||||
const options = {
|
||||
authenticated: false,
|
||||
baseUrl: "https://api.test",
|
||||
onServerVersion,
|
||||
};
|
||||
const encoder = new TextEncoder();
|
||||
globalThis.fetch = mock(async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
const headers = { "x-kuber-version": "2.7.0" };
|
||||
if (path === "/error")
|
||||
return Response.json({ detail: "failure" }, { status: 503, headers });
|
||||
if (path === "/upload") return new Response(null, { status: 204, headers });
|
||||
if (path === "/stream")
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('{"n":1}\n'));
|
||||
},
|
||||
}),
|
||||
{ headers },
|
||||
);
|
||||
return Response.json({ ok: true }, { headers });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
expect(await apiRequest<{ ok: boolean }>("/json", {}, options)).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
await expect(apiRequest("/error", {}, options)).rejects.toBeInstanceOf(
|
||||
KuberApiError,
|
||||
);
|
||||
await apiUpload("/upload", new Uint8Array([1]), { ...options, offset: 0 });
|
||||
|
||||
let complete!: (success: boolean) => void;
|
||||
const pending = new Promise<boolean>((resolve) => {
|
||||
complete = resolve;
|
||||
});
|
||||
const lines: string[] = [];
|
||||
const observer = createVersionObserver({
|
||||
currentVersion: "2.6.1",
|
||||
runner: () => pending,
|
||||
report: (line) => lines.push(line),
|
||||
});
|
||||
const records = apiStreamNdjson<{ n: number }>(
|
||||
"/stream",
|
||||
{},
|
||||
{
|
||||
...options,
|
||||
onServerVersion: (version) => {
|
||||
onServerVersion(version);
|
||||
observer(version);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect((await records.next()).value).toEqual({ n: 1 });
|
||||
expect(seen).toEqual(["2.7.0", "2.7.0", "2.7.0", "2.7.0"]);
|
||||
expect(lines).toEqual([]);
|
||||
await records.return();
|
||||
complete(false);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(lines).toEqual(["\x1b[90m+ New version available: 2.7.0\x1b[0m\n"]);
|
||||
});
|
||||
|
||||
describe("authenticated API transport", () => {
|
||||
test("sends authentication and serializes JSON", async () => {
|
||||
const fetchMock = mock(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type WorkspaceSnapshot,
|
||||
} from "../../lib/workspace";
|
||||
import type { BuildRequest } from "../../shared/build-protocol";
|
||||
import { ApiStreamUnsupportedError } from "../../lib/api";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
@@ -582,6 +583,88 @@ describe("authenticated build API pipeline", () => {
|
||||
expect(calls.some(({ path }) => path.endsWith("/result"))).toBe(true);
|
||||
});
|
||||
|
||||
test("streams one physical build, resumes cursor, and never polls JSON while SSE is active", async () => {
|
||||
const snapshot = emptySnapshot();
|
||||
const calls: string[] = [];
|
||||
const cursors: number[] = [];
|
||||
const output: string[] = [];
|
||||
const request: ApiRequester = async <T>(path: string) => {
|
||||
calls.push(path);
|
||||
if (path === "/snapshots/negotiate") return { ready: true } as T;
|
||||
if (path === "/builds") return { state: "queued" } as T;
|
||||
if (path.endsWith("/result")) return { reference: "image:web", references: { worker: "image:worker" } } as T;
|
||||
throw new Error(`Unexpected JSON request ${path}`);
|
||||
};
|
||||
const result = await buildServices("shop", {
|
||||
services: { web: { build: "." }, worker: { build: "." } },
|
||||
}, process.cwd(), { stream: new Writable({ write(chunk, _, done) {
|
||||
output.push(String(chunk)); done();
|
||||
} }) }, { request, snapshot, pollIntervalMs: 0, sleep: async () => {},
|
||||
streamEvents: async function* (_path, after) {
|
||||
cursors.push(after);
|
||||
if (after === 0) {
|
||||
yield { id: 1, event: { type: "log", sequence: 1, id: "build", message: "once\n" } };
|
||||
yield { event: { type: "status", status: { version: 1, id: "build", state: "running", createdAt: "now" } } };
|
||||
} else {
|
||||
yield { id: 1, event: { type: "log", sequence: 1, id: "build", message: "once\n" } };
|
||||
yield { event: { type: "status", status: { version: 1, id: "build", state: "succeeded", createdAt: "now" } } };
|
||||
}
|
||||
},
|
||||
});
|
||||
expect(result.images).toEqual({ web: "image:web", worker: "image:worker" });
|
||||
expect(cursors).toEqual([0, 1]);
|
||||
expect(output).toEqual(["[web] once\n"]);
|
||||
expect(calls.filter((path) => path.includes("/events") || path.includes("/reconcile"))).toEqual([]);
|
||||
});
|
||||
|
||||
test("falls back to JSON events/reconcile only after SSE is unsupported", async () => {
|
||||
const calls: string[] = [];
|
||||
const request: ApiRequester = async <T>(path: string) => {
|
||||
calls.push(path);
|
||||
if (path === "/snapshots/negotiate") return { ready: true } as T;
|
||||
if (path === "/builds") return { state: "queued" } as T;
|
||||
if (path.includes("/events")) return [{ type: "log", sequence: 1, message: "fallback\n" }] as T;
|
||||
if (path.endsWith("/reconcile")) return { state: "succeeded" } as T;
|
||||
if (path.endsWith("/result")) return { reference: "image:web" } as T;
|
||||
throw new Error(path);
|
||||
};
|
||||
expect((await buildServices("shop", { services: { web: { build: "." } } },
|
||||
process.cwd(), undefined, { request, snapshot: emptySnapshot(),
|
||||
streamEvents: async function* () { yield* []; throw new ApiStreamUnsupportedError(); },
|
||||
})).images).toEqual({ web: "image:web" });
|
||||
expect(calls.filter((path) => path.includes("/events") || path.includes("/reconcile"))).toEqual([
|
||||
expect.stringContaining("/events?after=0"),
|
||||
expect.stringContaining("/reconcile"),
|
||||
expect.stringContaining("/events?after=1"),
|
||||
]);
|
||||
});
|
||||
|
||||
test("aborts an active stream and does not issue JSON polling requests", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new DOMException("Stopped", "AbortError");
|
||||
let listening!: () => void;
|
||||
const ready = new Promise<void>((resolve) => { listening = resolve; });
|
||||
const calls: string[] = [];
|
||||
const request: ApiRequester = async <T>(path: string) => {
|
||||
calls.push(path);
|
||||
if (path === "/snapshots/negotiate") return { ready: true } as T;
|
||||
if (path === "/builds") return { state: "queued" } as T;
|
||||
throw new Error(path);
|
||||
};
|
||||
const result = buildServices("shop", { services: { web: { build: "." } } },
|
||||
process.cwd(), undefined, { request, snapshot: emptySnapshot(), signal: controller.signal,
|
||||
streamEvents: async function* (_path, _after, signal) {
|
||||
yield* [];
|
||||
listening();
|
||||
await new Promise<void>((_resolve, reject) => signal?.addEventListener("abort", () => reject(signal.reason), { once: true }));
|
||||
},
|
||||
});
|
||||
await ready;
|
||||
controller.abort(reason);
|
||||
await expect(result).rejects.toBe(reason);
|
||||
expect(calls).toEqual(["/snapshots/negotiate", "/builds"]);
|
||||
});
|
||||
|
||||
test("builds equivalent services once and attributes events and image results to each", async () => {
|
||||
const snapshot = emptySnapshot();
|
||||
const submitted: BuildRequest[] = [];
|
||||
|
||||
+158
-3
@@ -8,6 +8,7 @@ import {
|
||||
getServicePostgresClaim,
|
||||
isPostgresVolumeEntry,
|
||||
reconcilePostgresClaim,
|
||||
reconcilePostgresClaims,
|
||||
} from "../../lib/database";
|
||||
import { objectApi } from "../../lib/k8s";
|
||||
|
||||
@@ -127,6 +128,7 @@ describe("managed PostgreSQL claims", () => {
|
||||
secretName: "postgres-app",
|
||||
};
|
||||
spyOn(objectApi, "read").mockImplementation(async (resource) => {
|
||||
if (resource.kind === "Database") throw { code: 404 };
|
||||
if (resource.kind === "Secret") {
|
||||
return {
|
||||
...resource,
|
||||
@@ -141,6 +143,7 @@ describe("managed PostgreSQL claims", () => {
|
||||
const patch = spyOn(objectApi, "patch").mockImplementation(
|
||||
async (resource) => resource as never,
|
||||
);
|
||||
const create = spyOn(objectApi, "create").mockImplementation(async (resource) => resource as never);
|
||||
|
||||
expect(await reconcilePostgresClaim("project", claim)).toEqual({
|
||||
username: "app",
|
||||
@@ -149,8 +152,8 @@ describe("managed PostgreSQL claims", () => {
|
||||
expect(patch.mock.calls.map(([resource]) => resource.kind)).toEqual([
|
||||
"Secret",
|
||||
"Cluster",
|
||||
"Database",
|
||||
]);
|
||||
expect(create.mock.calls.map(([resource]) => resource.kind)).toEqual(["Database"]);
|
||||
});
|
||||
|
||||
test("reports database apply phase and claim without exposing provider credentials", async () => {
|
||||
@@ -161,12 +164,14 @@ describe("managed PostgreSQL claims", () => {
|
||||
secretName: "postgres-app_role",
|
||||
};
|
||||
spyOn(objectApi, "read").mockImplementation(async (resource) => {
|
||||
if (resource.kind === "Database") throw { code: 404 };
|
||||
if (resource.kind === "Secret") {
|
||||
return { ...resource, data: { password: Buffer.from("private-value").toString("base64") } } as never;
|
||||
}
|
||||
return { ...resource, spec: { managed: { roles: [] } } } as never;
|
||||
});
|
||||
spyOn(objectApi, "patch").mockImplementation(async (resource) => {
|
||||
spyOn(objectApi, "patch").mockImplementation(async (resource) => resource as never);
|
||||
spyOn(objectApi, "create").mockImplementation(async (resource) => {
|
||||
if (resource.kind === "Database") {
|
||||
throw Object.assign(new Error("Forbidden: password=private-value"), { code: 403 });
|
||||
}
|
||||
@@ -180,11 +185,78 @@ describe("managed PostgreSQL claims", () => {
|
||||
}
|
||||
expect(failure).toBeInstanceOf(DatabaseReconciliationError);
|
||||
expect((failure as Error).message).toBe(
|
||||
"Database reconciliation failed during database apply for database app_db (service app, role app_role): Forbidden (HTTP 403)",
|
||||
"Database reconciliation failed during database apply for requested database claim: Forbidden (HTTP 403)",
|
||||
);
|
||||
expect((failure as Error).message).not.toContain("private-value");
|
||||
});
|
||||
|
||||
test.each(["read", "patch"])("distinguishes role secret %s failure", async (method) => {
|
||||
const secretLike = "DB_PASSWORD_private123";
|
||||
spyOn(objectApi, "read").mockImplementation(async (resource) => {
|
||||
if (resource.kind === "Database") throw { code: 404 };
|
||||
if (method === "read") throw new Error(`token=${secretLike}`);
|
||||
return undefined as never;
|
||||
});
|
||||
spyOn(objectApi, "patch").mockImplementation(async () => {
|
||||
throw new Error(`token=${secretLike}`);
|
||||
});
|
||||
let failure: unknown;
|
||||
try {
|
||||
await reconcilePostgresClaim("project", {
|
||||
service: secretLike, username: secretLike, database: secretLike, secretName: `postgres-${secretLike}`,
|
||||
});
|
||||
} catch (error) { failure = error; }
|
||||
expect(failure).toBeInstanceOf(DatabaseReconciliationError);
|
||||
expect((failure as DatabaseReconciliationError).phase).toBe(`role secret ${method === "read" ? "lookup" : "apply"}`);
|
||||
expect((failure as Error).message).not.toContain(secretLike);
|
||||
});
|
||||
|
||||
test("uses safe provider status and reason without exposing request bodies or unsafe claim identifiers", () => {
|
||||
const cause = Object.assign(new Error("request body DATABASE_URL=postgresql://admin:[email protected]/app"), {
|
||||
statusCode: 422,
|
||||
body: { reason: "Invalid", code: 422, message: "token=private" },
|
||||
});
|
||||
const failure = new DatabaseReconciliationError("managed role update", cause, {
|
||||
service: "web", database: "postgresql://admin:[email protected]/app", username: "web_role", secretName: "secret",
|
||||
});
|
||||
expect(failure.message).toBe(
|
||||
"Database reconciliation failed during managed role update for requested database claim: Invalid (HTTP 422)",
|
||||
);
|
||||
expect(failure.cause).toBe(cause);
|
||||
expect(failure.message).not.toContain("private");
|
||||
});
|
||||
|
||||
test("rejects an unrecognized phase containing a syntactically valid secret-like name", () => {
|
||||
const failure = new DatabaseReconciliationError("DB_PASSWORD_private123", new Error("private"));
|
||||
expect(failure.phase).toBe("operation execution");
|
||||
expect(failure.message).not.toContain("DB_PASSWORD_private123");
|
||||
});
|
||||
|
||||
test("identifies failure while preparing malformed managed roles before a Cluster write", async () => {
|
||||
spyOn(objectApi, "read").mockImplementation(async (resource) => resource.kind === "Secret"
|
||||
? { ...resource, data: { password: Buffer.from("private").toString("base64") } } as never
|
||||
: resource.kind === "Database" ? Promise.reject({ code: 404 }) : { ...resource, spec: { managed: { roles: {} } } } as never);
|
||||
const patch = spyOn(objectApi, "patch").mockImplementation(async (resource) => resource as never);
|
||||
let failure: unknown;
|
||||
try {
|
||||
await reconcilePostgresClaim("project", {
|
||||
service: "DB_PASSWORD_private123", username: "role", database: "db", secretName: "postgres-role",
|
||||
});
|
||||
} catch (error) { failure = error; }
|
||||
expect(failure).toBeInstanceOf(DatabaseReconciliationError);
|
||||
expect((failure as DatabaseReconciliationError).phase).toBe("managed role preparation");
|
||||
expect((failure as Error).message).not.toContain("DB_PASSWORD_private123");
|
||||
expect(patch.mock.calls.map(([resource]) => resource.kind)).toEqual(["Secret"]);
|
||||
});
|
||||
|
||||
test("reports malformed claims as claim discovery failures without echoing compose input", async () => {
|
||||
await expect(reconcilePostgresClaims("project", {
|
||||
services: { web: { volumes: ["postgresql:user:password=private"] } },
|
||||
} as ComposeSpecification)).rejects.toThrow(
|
||||
"Database reconciliation failed during claim discovery: Check PostgreSQL claim declarations",
|
||||
);
|
||||
});
|
||||
|
||||
test("reconciles a CNPG cluster returned with managed fields without sending them back", async () => {
|
||||
const claim = {
|
||||
service: "app",
|
||||
@@ -193,6 +265,7 @@ describe("managed PostgreSQL claims", () => {
|
||||
secretName: "postgres-app",
|
||||
};
|
||||
spyOn(objectApi, "read").mockImplementation(async (resource) => {
|
||||
if (resource.kind === "Database") throw { code: 404 };
|
||||
if (resource.kind === "Secret") {
|
||||
return {
|
||||
...resource,
|
||||
@@ -223,6 +296,7 @@ describe("managed PostgreSQL claims", () => {
|
||||
return resource as never;
|
||||
},
|
||||
);
|
||||
spyOn(objectApi, "create").mockImplementation(async (resource) => resource as never);
|
||||
|
||||
await reconcilePostgresClaim("project", claim);
|
||||
|
||||
@@ -239,4 +313,85 @@ describe("managed PostgreSQL claims", () => {
|
||||
expect(cluster?.metadata).not.toHaveProperty("resourceVersion");
|
||||
expect(cluster).not.toHaveProperty("status");
|
||||
});
|
||||
|
||||
test("two projects reuse a foreign database without applying its metadata", async () => {
|
||||
const claim = { service: "web", username: "sastify", database: "sastify-store", secretName: "postgres-sastify" };
|
||||
const live = {
|
||||
apiVersion: "postgresql.cnpg.io/v1", kind: "Database",
|
||||
metadata: { name: claim.database, namespace: "database", labels: {
|
||||
"kuber.dev/project": "sastify-api", "kuber.dev/workspace-uid": "foreign-uid",
|
||||
} },
|
||||
spec: { owner: claim.username, cluster: { name: "postgres" } },
|
||||
};
|
||||
const original = structuredClone(live);
|
||||
spyOn(objectApi, "read").mockImplementation(async (resource) => resource.kind === "Database"
|
||||
? live as never
|
||||
: resource.kind === "Secret"
|
||||
? { ...resource, data: { password: Buffer.from("shared-password").toString("base64") } } as never
|
||||
: { ...resource, spec: { managed: { roles: [] } } } as never);
|
||||
const patch = spyOn(objectApi, "patch").mockImplementation(async (resource) => resource as never);
|
||||
const create = spyOn(objectApi, "create").mockImplementation(async (resource) => resource as never);
|
||||
const compose = { services: { web: { volumes: ["postgresql:sastify/sastify-store"] } } } as ComposeSpecification;
|
||||
for (const project of ["sastify-api", "another-project"]) {
|
||||
const env = await reconcilePostgresClaims(project, compose);
|
||||
expect(env.web?.DATABASE_URL).toBe("postgresql://sastify:[email protected]:5432/sastify-store");
|
||||
}
|
||||
expect(live).toEqual(original);
|
||||
expect(patch.mock.calls.map(([resource]) => resource.kind)).toEqual(["Secret", "Cluster", "Secret", "Cluster"]);
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("validates all databases in a compose file before writing the first role", async () => {
|
||||
const read = spyOn(objectApi, "read").mockImplementation(async (resource) => {
|
||||
if (resource.kind === "Database" && resource.metadata?.name === "first") throw { code: 404 };
|
||||
if (resource.kind === "Database") return { ...resource, spec: {
|
||||
owner: "another-role", cluster: { name: "postgres" },
|
||||
} } as never;
|
||||
throw new Error("Role lookup must not run");
|
||||
});
|
||||
const patch = spyOn(objectApi, "patch").mockImplementation(async (resource) => resource as never);
|
||||
const create = spyOn(objectApi, "create").mockImplementation(async (resource) => resource as never);
|
||||
await expect(reconcilePostgresClaims("project", { services: {
|
||||
first: { volumes: ["postgresql:first"] },
|
||||
second: { volumes: ["postgresql:second"] },
|
||||
} } as ComposeSpecification)).rejects.toMatchObject({ phase: "database ownership" });
|
||||
expect(read.mock.calls.map(([resource]) => resource.kind)).toEqual(["Database", "Database"]);
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["wrong role", "other", "postgres"],
|
||||
["wrong cluster", "sastify", "other"],
|
||||
])("rejects existing database with %s before any writes", async (_case, owner, cluster) => {
|
||||
const claim = { service: "web", username: "sastify", database: "sastify-store", secretName: "postgres-sastify" };
|
||||
spyOn(objectApi, "read").mockImplementation(async (resource) => resource.kind === "Database"
|
||||
? { ...resource, spec: { owner, cluster: { name: cluster } } } as never
|
||||
: undefined as never);
|
||||
const patch = spyOn(objectApi, "patch").mockImplementation(async (resource) => resource as never);
|
||||
const create = spyOn(objectApi, "create").mockImplementation(async (resource) => resource as never);
|
||||
await expect(reconcilePostgresClaim("project", claim)).rejects.toMatchObject({ phase: "database ownership" });
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("creates missing databases only once and fails closed on create races", async () => {
|
||||
spyOn(objectApi, "read").mockImplementation(async (resource) => {
|
||||
if (resource.kind === "Database") throw { code: 404 };
|
||||
return { ...resource, spec: { managed: { roles: [] } } } as never;
|
||||
});
|
||||
spyOn(objectApi, "patch").mockImplementation(async (resource) => resource as never);
|
||||
const create = spyOn(objectApi, "create").mockImplementation(async () => {
|
||||
throw Object.assign(new Error("Conflict"), { code: 409 });
|
||||
});
|
||||
await expect(reconcilePostgresClaims("project", { services: {
|
||||
web: { volumes: ["postgresql:sastify/sastify-store"] },
|
||||
worker: { volumes: ["postgresql:sastify/sastify-store"] },
|
||||
} } as ComposeSpecification)).rejects.toMatchObject({ phase: "database apply" });
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
expect(create.mock.calls[0]?.[0]).toMatchObject({
|
||||
metadata: { name: "sastify-store", labels: { "kuber.dev/project": "project" } },
|
||||
spec: { owner: "sastify", cluster: { name: "postgres" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("a short CLI exits while its version updater remains pending", async () => {
|
||||
const moduleUrl = new URL("../../lib/version-update.ts", import.meta.url)
|
||||
.href;
|
||||
const script = `
|
||||
const { createVersionObserver } = await import(${JSON.stringify(moduleUrl)});
|
||||
createVersionObserver({
|
||||
currentVersion: "2.6.1-rc3",
|
||||
runner: () => new Promise(() => {}),
|
||||
})("2.7.0");
|
||||
`;
|
||||
const child = Bun.spawn([process.execPath, "-e", script], {
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exit = await Promise.race([
|
||||
child.exited,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("short CLI stayed alive")), 1_000),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(exit).toBe(0);
|
||||
expect(await new Response(child.stderr).text()).toBe("");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["success", "return true", "+ Updated to 2.7.0"],
|
||||
["failure", "throw new Error('install failed')", "+ New version available: 2.7.0"],
|
||||
])("reports exactly one %s outcome when it settles", async (_name, outcome, message) => {
|
||||
const moduleUrl = new URL("../../lib/version-update.ts", import.meta.url).href;
|
||||
const script = `
|
||||
const { createVersionObserver } = await import(${JSON.stringify(moduleUrl)});
|
||||
createVersionObserver({
|
||||
currentVersion: "2.6.1-rc3",
|
||||
runner: async () => { ${outcome}; },
|
||||
})("2.7.0");
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
`;
|
||||
const child = Bun.spawn([process.execPath, "-e", script], {
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
expect(await child.exited).toBe(0);
|
||||
const stderr = await new Response(child.stderr).text();
|
||||
expect(stderr.split("\n").filter((line) => line.includes(message))).toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
compareVersions,
|
||||
createVersionObserver,
|
||||
installVersion,
|
||||
} from "../../lib/version-update";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe("server version updates", () => {
|
||||
test("compares strict SemVer precedence including prereleases and ignores build metadata", () => {
|
||||
expect(compareVersions("2.6.1", "2.6.1-rc3")).toBe(1);
|
||||
expect(compareVersions("2.6.1-rc.10", "2.6.1-rc.3")).toBe(1);
|
||||
expect(compareVersions("2.6.1-alpha.1", "2.6.1-alpha.beta")).toBe(-1);
|
||||
expect(compareVersions("2.6.1-rc3.1", "2.6.1-rc3")).toBe(1);
|
||||
expect(compareVersions("2.6.1+new", "2.6.1+old")).toBe(0);
|
||||
expect(compareVersions("3.0.0", "2.6.1")).toBe(1);
|
||||
for (const invalid of [
|
||||
"",
|
||||
"v3.0.0",
|
||||
"3.0",
|
||||
"03.0.0",
|
||||
"3.0.0-rc.01",
|
||||
"3.0.0-",
|
||||
"3.0.0+",
|
||||
"3.0.0;touch /tmp/pwned",
|
||||
"3.0.0\n",
|
||||
"3.0.0-💥",
|
||||
"9".repeat(129) + ".0.0",
|
||||
]) {
|
||||
expect(compareVersions(invalid, "2.6.1-rc3")).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invalid install arguments before spawning", async () => {
|
||||
expect(await installVersion("2.7.0;echo hacked")).toBe(false);
|
||||
});
|
||||
|
||||
test("uses a quiet detached timeout argument vector", async () => {
|
||||
const calls: string[][] = [];
|
||||
let unrefs = 0;
|
||||
const spawn = (
|
||||
argv: string[],
|
||||
options: {
|
||||
stdin: "ignore";
|
||||
stdout: "ignore";
|
||||
stderr: "ignore";
|
||||
detached: true;
|
||||
},
|
||||
) => {
|
||||
calls.push(argv);
|
||||
expect(options).toEqual({
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
return {
|
||||
exited: Promise.resolve(0),
|
||||
unref: () => {
|
||||
unrefs++;
|
||||
},
|
||||
};
|
||||
};
|
||||
expect(await installVersion("2.7.0-rc.1+build.2", spawn, 1)).toBe(true);
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
"timeout",
|
||||
"--signal=TERM",
|
||||
"--kill-after=2s",
|
||||
"1s",
|
||||
"bun",
|
||||
"i",
|
||||
"-g",
|
||||
"--no-cache",
|
||||
"@dmgnr/[email protected]+build.2",
|
||||
],
|
||||
]);
|
||||
expect(unrefs).toBe(1);
|
||||
});
|
||||
|
||||
test("ignores malformed, equal, and older headers without attempting an install", async () => {
|
||||
const runs: string[] = [];
|
||||
const lines: string[] = [];
|
||||
const observe = createVersionObserver({
|
||||
currentVersion: "2.6.1-rc3",
|
||||
runner: async (version) => {
|
||||
runs.push(version);
|
||||
return true;
|
||||
},
|
||||
report: (line) => lines.push(line),
|
||||
});
|
||||
for (const version of [
|
||||
null,
|
||||
"garbage",
|
||||
"2.6.1-rc3",
|
||||
"2.6.0",
|
||||
"2.6.1-rc2",
|
||||
"2.6.1-rc3+build",
|
||||
])
|
||||
observe(version);
|
||||
await tick();
|
||||
expect(runs).toEqual([]);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
|
||||
test("starts once for concurrent newer responses, reports success in gray on stderr", async () => {
|
||||
let finish!: (value: boolean) => void;
|
||||
const pending = new Promise<boolean>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const runs: string[] = [];
|
||||
const lines: string[] = [];
|
||||
const observe = createVersionObserver({
|
||||
currentVersion: "2.6.1-rc3",
|
||||
runner: (version) => {
|
||||
runs.push(version);
|
||||
return pending;
|
||||
},
|
||||
report: (line) => lines.push(line),
|
||||
});
|
||||
observe("2.6.1");
|
||||
observe("9.0.0");
|
||||
expect(runs).toEqual([]);
|
||||
await tick();
|
||||
expect(runs).toEqual(["2.6.1"]);
|
||||
expect(lines).toEqual([]);
|
||||
finish(true);
|
||||
await tick();
|
||||
expect(lines).toEqual(["\x1b[90m+ Updated to 2.6.1\x1b[0m\n"]);
|
||||
observe("10.0.0");
|
||||
await tick();
|
||||
expect(runs).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("reports failure or rejection once without throwing into requests", async () => {
|
||||
for (const runner of [
|
||||
async () => false,
|
||||
async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
]) {
|
||||
const lines: string[] = [];
|
||||
const observe = createVersionObserver({
|
||||
currentVersion: "2.6.1-rc3",
|
||||
runner,
|
||||
report: (line) => lines.push(line),
|
||||
});
|
||||
observe("2.7.0");
|
||||
observe("2.8.0");
|
||||
await tick();
|
||||
expect(lines).toEqual([
|
||||
"\x1b[90m+ New version available: 2.7.0\x1b[0m\n",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a detached pending install does not delay a short CLI or pollute its streams", async () => {
|
||||
const url = new URL("../../lib/version-update.ts", import.meta.url).href;
|
||||
const script = `
|
||||
const { createVersionObserver } = await import(${JSON.stringify(url)});
|
||||
createVersionObserver({ currentVersion: '2.6.1',
|
||||
runner: () => new Promise(() => {}) })('2.7.0');
|
||||
`;
|
||||
const child = Bun.spawn([process.execPath, "-e", script], {
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const timeout = setTimeout(() => child.kill(), 1_000);
|
||||
try {
|
||||
expect(await child.exited).toBe(0);
|
||||
expect(await new Response(child.stdout).text()).toBe("");
|
||||
expect(await new Response(child.stderr).text()).toBe("");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
|
||||
test("a short subprocess emits one outcome to stderr when its runner settles", async () => {
|
||||
const url = new URL("../../lib/version-update.ts", import.meta.url).href;
|
||||
for (const result of ["true", "false", "reject"]) {
|
||||
const script = `
|
||||
const { createVersionObserver } = await import(${JSON.stringify(url)});
|
||||
createVersionObserver({ currentVersion: '2.6.1',
|
||||
runner: async () => ${result === "reject" ? "Promise.reject(Error('offline'))" : result} })('2.7.0');
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
`;
|
||||
const child = Bun.spawn([process.execPath, "-e", script], {
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
expect(await child.exited).toBe(0);
|
||||
expect(await new Response(child.stdout).text()).toBe("");
|
||||
expect(await new Response(child.stderr).text()).toBe(
|
||||
`\x1b[90m+ ${result === "true" ? "Updated to " : "New version available: "}2.7.0\x1b[0m\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,11 @@ import type { ManagementService } from "../../server/management";
|
||||
import { MemoryTrustStore } from "../../server/trust-store";
|
||||
import { MemoryWorkspaceStore } from "../../server/workspace-store";
|
||||
|
||||
const now = Date.parse("2026-09-05T00:00:00.000Z");
|
||||
const now = Date.parse("2030-09-05T00:00:00.000Z");
|
||||
const finiteExpiry = new Date(now + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const delegatedLaterExpiry = new Date(
|
||||
now + 31 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
function request(path: string, init: RequestInit = {}, token = "admin-token") {
|
||||
const headers = new Headers(init.headers);
|
||||
@@ -33,7 +37,7 @@ async function setup() {
|
||||
tokenHash: hashToken("admin-token"),
|
||||
username: "admin",
|
||||
authVersion: 1,
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
return {
|
||||
store,
|
||||
@@ -86,7 +90,7 @@ describe("API keys", () => {
|
||||
tokenHash: hashToken("ci-key"),
|
||||
username: "ci",
|
||||
capabilities: ["kubernetes:read"],
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
expect((await app(request("/api/v2/users", {}, "ci-key"))).status).toBe(
|
||||
403,
|
||||
@@ -104,7 +108,7 @@ describe("API keys", () => {
|
||||
username: "ci",
|
||||
capabilities: ["users:write", "kubernetes:read"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
const create = (body: unknown) =>
|
||||
app(
|
||||
@@ -149,14 +153,14 @@ describe("API keys", () => {
|
||||
const laterExpiry = await create({
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-06T00:00:00.000Z",
|
||||
expiresAt: delegatedLaterExpiry,
|
||||
});
|
||||
expect(laterExpiry.status).toBe(403);
|
||||
|
||||
const subset = await create({
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
expect(subset.status).toBe(201);
|
||||
expect(
|
||||
@@ -194,7 +198,7 @@ describe("API keys", () => {
|
||||
tokenHash: hashToken("unscoped-delegation"),
|
||||
username: "ci",
|
||||
capabilities: ["users:write", "kubernetes:read"],
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
const unscopedSubset = await app(
|
||||
request(
|
||||
@@ -204,7 +208,7 @@ describe("API keys", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
capabilities: ["kubernetes:read"],
|
||||
expiresAt: "2026-09-20T00:00:00.000Z",
|
||||
expiresAt: new Date(now + 15 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
},
|
||||
"unscoped-delegation",
|
||||
@@ -248,7 +252,7 @@ describe("API keys", () => {
|
||||
tokenHash: hashToken("expired-key"),
|
||||
username: "ci",
|
||||
capabilities: ["kubernetes:read"],
|
||||
expiresAt: "2026-09-04T00:00:00.000Z",
|
||||
expiresAt: new Date(now - 24 * 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
expect((await app(request("/api/v2/me", {}, "expired-key"))).status).toBe(
|
||||
401,
|
||||
@@ -261,7 +265,7 @@ describe("API keys", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
capabilities: ["kubernetes:read"],
|
||||
expiresAt: "2027-09-06T00:00:00.000Z",
|
||||
expiresAt: new Date(now + 366 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -291,7 +295,7 @@ describe("API keys", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
capabilities: ["kubernetes:read"],
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -300,7 +304,7 @@ describe("API keys", () => {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
expect(finiteKey.expiresAt).toBe("2026-10-05T00:00:00.000Z");
|
||||
expect(finiteKey.expiresAt).toBe(finiteExpiry);
|
||||
expect(
|
||||
(await app(request("/api/v2/logout", { method: "POST" }, key.token)))
|
||||
.status,
|
||||
@@ -326,7 +330,7 @@ describe("API keys", () => {
|
||||
username: "ci",
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
expect(
|
||||
(await app(request("/api/v2/workspaces/other", {}, "scoped-key"))).status,
|
||||
@@ -366,7 +370,7 @@ describe("API keys", () => {
|
||||
username: "ci",
|
||||
capabilities: ["kubernetes:write"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
const apply = (workspace: string) =>
|
||||
app(
|
||||
@@ -402,7 +406,7 @@ describe("API keys", () => {
|
||||
username: "ci",
|
||||
capabilities: ["users:read"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
await auditStore.append({
|
||||
actor: { username: "admin" },
|
||||
@@ -459,7 +463,7 @@ describe("API keys", () => {
|
||||
username: "ci",
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
|
||||
const unfiltered = await app(
|
||||
@@ -525,7 +529,7 @@ describe("API keys", () => {
|
||||
username: "admin",
|
||||
capabilities: ["kubernetes:write"],
|
||||
workspace: "kuber-system",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
await store.createApiKey({
|
||||
id: "key_platform_scope",
|
||||
@@ -533,7 +537,7 @@ describe("API keys", () => {
|
||||
username: "admin",
|
||||
capabilities: ["platform:adopt"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
await store.createApiKey({
|
||||
id: "key_platform_allowed",
|
||||
@@ -541,7 +545,7 @@ describe("API keys", () => {
|
||||
username: "admin",
|
||||
capabilities: ["platform:adopt"],
|
||||
workspace: "kuber-system",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
const adopt = (token: string) =>
|
||||
app(
|
||||
@@ -569,7 +573,7 @@ describe("API keys", () => {
|
||||
username: "ci",
|
||||
capabilities: ["kubernetes:write"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
expiresAt: finiteExpiry,
|
||||
});
|
||||
const matching = await app(
|
||||
request(
|
||||
|
||||
+220
-3
@@ -9,6 +9,7 @@ import {
|
||||
} from "../../server/operation-store";
|
||||
import { MemoryWorkspaceStore } from "../../server/workspace-store";
|
||||
import { MemoryTrustStore } from "../../server/trust-store";
|
||||
import type { BuildController } from "../../server/build-controller";
|
||||
|
||||
function request(
|
||||
path: string,
|
||||
@@ -340,7 +341,8 @@ describe("operation response safety", () => {
|
||||
expect(immediate.status).toBe(500);
|
||||
expect(immediateBody).not.toContain("database-password");
|
||||
expect(immediateBody).not.toContain("kube-secret");
|
||||
expect(immediateBody).toContain("OPERATION_FAILED");
|
||||
expect(immediateBody).toContain("DATABASE_RECONCILE_FAILED");
|
||||
expect(immediateBody).toContain("during operation execution: Unexpected failure; check server logs using the operation ID");
|
||||
|
||||
const operationId = (await operationStore.list())[0]!.metadata.name;
|
||||
const retrieved = await app(
|
||||
@@ -529,6 +531,98 @@ async function authenticatedStore(role: "viewer" | "operator" | "admin") {
|
||||
}
|
||||
|
||||
describe("kuber v2 HTTP routes", () => {
|
||||
test("streams build status and logs, supports resume cursors, and keeps version headers", async () => {
|
||||
const controller = {
|
||||
getBuildProject: async () => "demo",
|
||||
getBuildStatus: async () => ({ state: "running", phase: "building" }),
|
||||
getBuildEvents: async (_id: string, after = 0) => [
|
||||
{ type: "log", sequence: 4, message: "build output" },
|
||||
].filter((event) => event.sequence > after),
|
||||
reconcileBuild: async () => ({ state: "running", phase: "building" }),
|
||||
} as unknown as BuildController;
|
||||
const app = createApp({ store: await authenticatedStore("operator"), builds: controller });
|
||||
const abort = new AbortController();
|
||||
const response = await app(request(
|
||||
"/api/v2/builds/build-1/events?after=2",
|
||||
{ headers: { accept: "text/event-stream" }, signal: abort.signal },
|
||||
"token",
|
||||
));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||
expect(response.headers.get("x-kuber-version")).toBeTruthy();
|
||||
const reader = response.body!.getReader();
|
||||
let body = "";
|
||||
while (!body.includes("event: status")) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
body += new TextDecoder().decode(value);
|
||||
}
|
||||
expect(body).toContain("id: 4\nevent: log");
|
||||
expect(body).toContain("build output");
|
||||
expect(body).toContain("event: status");
|
||||
abort.abort();
|
||||
await reader.cancel();
|
||||
|
||||
const resumedAbort = new AbortController();
|
||||
const resumed = await app(request(
|
||||
"/api/v2/builds/build-1/events",
|
||||
{ headers: { accept: "text/event-stream", "last-event-id": "4" }, signal: resumedAbort.signal },
|
||||
"token",
|
||||
));
|
||||
const resumedReader = resumed.body!.getReader();
|
||||
const resumedChunk = await resumedReader.read();
|
||||
expect(new TextDecoder().decode(resumedChunk.value)).not.toContain("id: 4");
|
||||
resumedAbort.abort();
|
||||
await resumedReader.cancel();
|
||||
});
|
||||
|
||||
test("checks authentication and workspace scope before opening a build stream", async () => {
|
||||
const controller = {
|
||||
getBuildProject: async () => "other",
|
||||
getBuildStatus: async () => ({ state: "running", phase: "building" }),
|
||||
getBuildEvents: async () => [],
|
||||
reconcileBuild: async () => ({ state: "running", phase: "building" }),
|
||||
} as unknown as BuildController;
|
||||
const app = createApp({ store: await authenticatedStore("operator"), builds: controller });
|
||||
const unauthenticated = await app(request(
|
||||
"/api/v2/builds/build-1/events",
|
||||
{ headers: { accept: "text/event-stream" } },
|
||||
));
|
||||
expect(unauthenticated.status).toBe(401);
|
||||
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({ username: "scoped", passwordHash: "hash", roles: ["operator"] });
|
||||
await store.createApiKey({
|
||||
id: "scoped-key-12345678", username: "scoped", tokenHash: hashToken("scoped-token"),
|
||||
capabilities: ["kubernetes:write"], workspace: "demo",
|
||||
});
|
||||
const scopedApp = createApp({ store, builds: controller });
|
||||
const forbidden = await scopedApp(request(
|
||||
"/api/v2/builds/build-1/events",
|
||||
{ headers: { accept: "text/event-stream" } },
|
||||
"scoped-token",
|
||||
));
|
||||
expect(forbidden.status).toBe(403);
|
||||
expect(forbidden.headers.get("content-type")).not.toContain("text/event-stream");
|
||||
});
|
||||
|
||||
test("rejects malformed SSE cursors before opening the stream", async () => {
|
||||
const controller = {
|
||||
getBuildProject: async () => "demo",
|
||||
getBuildStatus: async () => ({ state: "running", phase: "building" }),
|
||||
getBuildEvents: async () => [],
|
||||
reconcileBuild: async () => ({ state: "running", phase: "building" }),
|
||||
} as unknown as BuildController;
|
||||
const app = createApp({ store: await authenticatedStore("operator"), builds: controller });
|
||||
const response = await app(request(
|
||||
"/api/v2/builds/build-1/events?after=1.5",
|
||||
{ headers: { accept: "text/event-stream" } },
|
||||
"token",
|
||||
));
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get("content-type")).toContain("application/problem+json");
|
||||
});
|
||||
|
||||
test("grants, lists, revokes, and enforces namespace trust for applies", async () => {
|
||||
const workspaceStore = new MemoryWorkspaceStore({
|
||||
uid: () => "workspace-uid",
|
||||
@@ -1788,11 +1882,134 @@ describe("kuber v2 HTTP routes", () => {
|
||||
expect(result.status).toBe(500);
|
||||
const problem = await result.json() as { code: string; detail: string };
|
||||
expect(problem.code).toBe("DATABASE_RECONCILE_FAILED");
|
||||
expect(problem.detail).toContain("database apply for database web_db (service web, role web_role): Forbidden");
|
||||
expect(problem.detail).toContain("database apply for requested database claim: Forbidden");
|
||||
expect(JSON.stringify(problem)).not.toContain("private-value");
|
||||
}
|
||||
expect((await operationStore.get("operation-db-failure"))?.status.error?.message)
|
||||
.toContain("database apply for database web_db");
|
||||
.toContain("database apply for requested database claim");
|
||||
});
|
||||
|
||||
test("never exposes syntactically valid secret-like database claim identifiers", async () => {
|
||||
const workspaceStore = new MemoryWorkspaceStore({ uid: () => "workspace-uid" });
|
||||
await workspaceStore.create({
|
||||
id: "demo",
|
||||
source: { uri: "oci://example/demo", digest: "sha256:abc" },
|
||||
});
|
||||
const operationStore = new MemoryOperationStore(undefined, () => "db-secret-identifier");
|
||||
const { DatabaseReconciliationError } = await import("../../lib/database");
|
||||
const secretLike = ["DB_PASSWORD_private123", "svc_api_token_abc", "role_secret_key_123"];
|
||||
const app = createApp({
|
||||
store: await authenticatedStore("operator"),
|
||||
workspaceStore,
|
||||
operationStore,
|
||||
management: {
|
||||
reconcileDatabases: async () => {
|
||||
throw new DatabaseReconciliationError(
|
||||
"database apply",
|
||||
Object.assign(new Error("Forbidden"), { statusCode: 403 }),
|
||||
{ service: secretLike[1]!, username: secretLike[2]!, database: secretLike[0]!, secretName: "safe-secret-name" },
|
||||
);
|
||||
},
|
||||
} as unknown as ManagementService,
|
||||
});
|
||||
const response = await app(request(
|
||||
"/api/v2/workspaces/demo/databases",
|
||||
{ method: "POST", headers: { "idempotency-key": "db-secret-identifier" }, body: JSON.stringify({ compose: {} }) },
|
||||
"token",
|
||||
));
|
||||
const responseBody = JSON.stringify(await response.json());
|
||||
const operation = await operationStore.get("operation-db-secret-identifier");
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(responseBody).toContain("database apply for requested database claim: Forbidden (HTTP 403)");
|
||||
expect(operation?.status.error?.message).toContain("database apply for requested database claim");
|
||||
for (const identifier of secretLike) {
|
||||
expect(responseBody).not.toContain(identifier);
|
||||
expect(JSON.stringify(operation)).not.toContain(identifier);
|
||||
}
|
||||
});
|
||||
|
||||
test("logs allowlisted database failure metadata with the operation ID, never provider data", async () => {
|
||||
const workspaceStore = new MemoryWorkspaceStore({ uid: () => "workspace-uid" });
|
||||
await workspaceStore.create({ id: "demo", source: { uri: "oci://example/demo", digest: "sha256:abc" } });
|
||||
const operationStore = new MemoryOperationStore(undefined, () => "diagnostic");
|
||||
const logs: Record<string, unknown>[] = [];
|
||||
const { DatabaseReconciliationError } = await import("../../lib/database");
|
||||
const secretLike = "DB_PASSWORD_private123";
|
||||
const cause = Object.assign(new Error(`token=${secretLike}`), {
|
||||
name: secretLike,
|
||||
code: "ECONNREFUSED",
|
||||
statusCode: 503,
|
||||
body: { message: secretLike, headers: { authorization: secretLike } },
|
||||
stack: `Error: ${secretLike}\n at ${secretLike} (/tmp/${secretLike}/lib/database.ts:22:7)`,
|
||||
});
|
||||
const app = createApp({
|
||||
store: await authenticatedStore("operator"), workspaceStore, operationStore,
|
||||
logger: { error: (entry) => logs.push(entry) },
|
||||
management: { reconcileDatabases: async () => {
|
||||
throw new DatabaseReconciliationError("database apply", cause, {
|
||||
service: secretLike, username: secretLike, database: secretLike, secretName: secretLike,
|
||||
});
|
||||
} } as unknown as ManagementService,
|
||||
});
|
||||
const response = await app(request("/api/v2/workspaces/demo/databases", {
|
||||
method: "POST", body: JSON.stringify({ compose: {} }),
|
||||
}, "token"));
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toMatchObject({
|
||||
code: "DATABASE_RECONCILE_FAILED", operationId: "operation-diagnostic",
|
||||
});
|
||||
const diagnostic = logs.filter((entry) => entry.event === "operation.database_reconcile.failed");
|
||||
expect(diagnostic).toHaveLength(1);
|
||||
expect(diagnostic[0]).toMatchObject({
|
||||
operationId: "operation-diagnostic", phase: "database apply",
|
||||
errorClass: "UnknownError", providerCode: "ECONNREFUSED", providerStatus: 503,
|
||||
topFrame: "lib/database.ts:22:7",
|
||||
});
|
||||
expect(JSON.stringify(diagnostic)).not.toContain(secretLike);
|
||||
expect(JSON.stringify(await operationStore.get("operation-diagnostic"))).not.toContain(secretLike);
|
||||
});
|
||||
|
||||
test("retains a safe diagnosis for an ordinary database provider error through HTTP, persistence and polling", async () => {
|
||||
const workspaceStore = new MemoryWorkspaceStore({ uid: () => "workspace-uid" });
|
||||
await workspaceStore.create({
|
||||
id: "demo",
|
||||
source: { uri: "oci://example/demo", digest: "sha256:abc" },
|
||||
});
|
||||
const operationStore = new MemoryOperationStore(undefined, () => "ordinary-db-error");
|
||||
const app = createApp({
|
||||
store: await authenticatedStore("operator"),
|
||||
workspaceStore,
|
||||
operationStore,
|
||||
management: {
|
||||
reconcileDatabases: async () => {
|
||||
throw Object.assign(new Error("provider failed: DATABASE_URL=postgresql://admin:[email protected]/app"), {
|
||||
statusCode: 403,
|
||||
body: { reason: "Forbidden", message: "password=private", code: 403 },
|
||||
});
|
||||
},
|
||||
} as unknown as ManagementService,
|
||||
});
|
||||
const reconcile = () => app(request(
|
||||
"/api/v2/workspaces/demo/databases",
|
||||
{ method: "POST", headers: { "idempotency-key": "ordinary-db-error" }, body: JSON.stringify({ compose: {} }) },
|
||||
"token",
|
||||
));
|
||||
const expected = "Database reconciliation failed during operation execution: Forbidden (HTTP 403)";
|
||||
for (const result of [await reconcile(), await reconcile()]) {
|
||||
expect(result.status).toBe(500);
|
||||
expect(await result.json()).toMatchObject({
|
||||
code: "DATABASE_RECONCILE_FAILED",
|
||||
detail: expected,
|
||||
operationId: "operation-ordinary-db-error",
|
||||
});
|
||||
}
|
||||
const persisted = await operationStore.get("operation-ordinary-db-error");
|
||||
expect(persisted?.status.error).toEqual({ code: "DATABASE_RECONCILE_FAILED", message: expected });
|
||||
const polled = await app(request("/api/v2/operations/operation-ordinary-db-error", {}, "token"));
|
||||
expect(polled.status).toBe(200);
|
||||
expect(await polled.json()).toMatchObject({ status: { error: { message: expected } } });
|
||||
expect(JSON.stringify(persisted)).not.toContain("private");
|
||||
});
|
||||
|
||||
test("routes workspace adoption and keeps platform adoption admin-only", async () => {
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { BuildEventStreamHub } from "../../server/build-event-stream";
|
||||
import type { BuildStatus } from "../../shared/build-protocol";
|
||||
|
||||
const base: BuildStatus = {
|
||||
version: 1,
|
||||
id: "a",
|
||||
state: "running",
|
||||
createdAt: "now",
|
||||
};
|
||||
const tick = () => Bun.sleep(10);
|
||||
|
||||
describe("build SSE hub", () => {
|
||||
test("reports an explicit gap when the resume cursor predates retained logs", async () => {
|
||||
const abort = new AbortController();
|
||||
const hub = new BuildEventStreamHub(
|
||||
{
|
||||
getBuildStatus: async () => base,
|
||||
reconcileBuild: async () => base,
|
||||
getBuildEvents: async (_id, after) => [
|
||||
{ type: "status" as const, status: base },
|
||||
...[4, 5]
|
||||
.filter((sequence) => sequence > (after ?? 0))
|
||||
.map((sequence) => ({
|
||||
type: "log" as const,
|
||||
id: "a",
|
||||
sequence,
|
||||
message: `log ${sequence}\n`,
|
||||
})),
|
||||
],
|
||||
},
|
||||
{ pollMs: 100, heartbeatMs: 1_000 },
|
||||
);
|
||||
const response = await hub.open(
|
||||
"a",
|
||||
new Request("https://test/builds/a/events", {
|
||||
headers: { "last-event-id": "1" },
|
||||
signal: abort.signal,
|
||||
}),
|
||||
);
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const first = decoder.decode((await reader.read()).value);
|
||||
expect(first).toContain("event: gap\n");
|
||||
expect(first).toContain('"missing":2');
|
||||
expect(first).toContain("id: 3\n");
|
||||
expect(decoder.decode((await reader.read()).value)).toContain("id: 4\nevent: log");
|
||||
abort.abort();
|
||||
await reader.cancel();
|
||||
});
|
||||
|
||||
test("coalesces reconciliation, resumes logs, dedupes status and drains terminal", async () => {
|
||||
let calls = 0;
|
||||
let status = base;
|
||||
const hub = new BuildEventStreamHub(
|
||||
{
|
||||
getBuildStatus: async () => status,
|
||||
reconcileBuild: async () => {
|
||||
calls++;
|
||||
return status;
|
||||
},
|
||||
getBuildEvents: async (_id, after) => [
|
||||
{ type: "status" as const, status: base },
|
||||
...[1, 2]
|
||||
.filter((sequence) => sequence > (after ?? 0))
|
||||
.map((sequence) => ({
|
||||
type: "log" as const,
|
||||
id: "a",
|
||||
sequence,
|
||||
message: `log ${sequence}\n`,
|
||||
})),
|
||||
],
|
||||
},
|
||||
{ pollMs: 20, heartbeatMs: 40 },
|
||||
);
|
||||
const a = new AbortController();
|
||||
const b = new AbortController();
|
||||
const request = (signal: AbortSignal, after: string) =>
|
||||
new Request(`https://test/builds/a/events?after=${after}`, { signal });
|
||||
const first = await hub.open("a", request(a.signal, "1"));
|
||||
const second = await hub.open("a", request(b.signal, "2"));
|
||||
expect(first.headers.get("content-type")).toContain("text/event-stream");
|
||||
const reader1 = first.body!.getReader();
|
||||
const reader2 = second.body!.getReader();
|
||||
const text = new TextDecoder();
|
||||
expect(text.decode((await reader1.read()).value)).toContain(
|
||||
"id: 2\nevent: log\n",
|
||||
);
|
||||
expect(text.decode((await reader1.read()).value)).toContain(
|
||||
"event: status\n",
|
||||
);
|
||||
expect(text.decode((await reader2.read()).value)).toContain(
|
||||
"event: status\n",
|
||||
);
|
||||
await tick();
|
||||
expect(calls).toBe(1);
|
||||
status = { ...base, state: "succeeded" };
|
||||
const remaining = async (reader: typeof reader1) => {
|
||||
let output = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) return output;
|
||||
output += text.decode(value);
|
||||
}
|
||||
};
|
||||
expect(await remaining(reader1)).toContain('"state":"succeeded"');
|
||||
expect(await remaining(reader2)).toContain('"state":"succeeded"');
|
||||
a.abort();
|
||||
b.abort();
|
||||
});
|
||||
|
||||
test("validates cursor, stops aborted subscribers and emits heartbeat comments", async () => {
|
||||
const hub = new BuildEventStreamHub(
|
||||
{
|
||||
getBuildStatus: async () => base,
|
||||
reconcileBuild: async () => base,
|
||||
getBuildEvents: async () => [],
|
||||
},
|
||||
{ pollMs: 5, heartbeatMs: 5 },
|
||||
);
|
||||
await expect(
|
||||
hub.open("a", new Request("https://test/events?after=-1")),
|
||||
).rejects.toThrow(RangeError);
|
||||
const abort = new AbortController();
|
||||
const response = await hub.open(
|
||||
"a",
|
||||
new Request("https://test/events", { signal: abort.signal }),
|
||||
);
|
||||
const reader = response.body!.getReader();
|
||||
expect(new TextDecoder().decode((await reader.read()).value)).toContain(
|
||||
"event: status",
|
||||
);
|
||||
expect(new TextDecoder().decode((await reader.read()).value)).toBe(
|
||||
": heartbeat\n\n",
|
||||
);
|
||||
abort.abort();
|
||||
expect((await reader.read()).done).toBe(true);
|
||||
});
|
||||
|
||||
test("reads persisted progress when another replica owns reconciliation", async () => {
|
||||
let status = base;
|
||||
let sequence = 0;
|
||||
let reconciles = 0;
|
||||
const hub = new BuildEventStreamHub(
|
||||
{
|
||||
getBuildStatus: async () => status,
|
||||
reconcileBuild: async () => {
|
||||
reconciles++;
|
||||
throw new Error("build lease held by another replica");
|
||||
},
|
||||
getBuildEvents: async (_id, after) =>
|
||||
sequence > (after ?? 0)
|
||||
? [
|
||||
{
|
||||
type: "log" as const,
|
||||
id: "a",
|
||||
sequence,
|
||||
message: "remote log\n",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
{ pollMs: 5, heartbeatMs: 50 },
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const response = await hub.open(
|
||||
"a",
|
||||
new Request("https://test/events", { signal: abort.signal }),
|
||||
);
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
expect(decoder.decode((await reader.read()).value)).toContain(
|
||||
'"state":"running"',
|
||||
);
|
||||
sequence = 1;
|
||||
expect(decoder.decode((await reader.read()).value)).toContain(
|
||||
"id: 1\nevent: log",
|
||||
);
|
||||
status = { ...base, state: "succeeded" };
|
||||
let terminalFrame = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
terminalFrame += decoder.decode(value);
|
||||
}
|
||||
expect(terminalFrame).toContain('"state":"succeeded"');
|
||||
expect(reconciles).toBeGreaterThan(0);
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
test("heartbeats during slow reconciliation and tears down on abort", async () => {
|
||||
const blocked = new Promise<BuildStatus>(() => {});
|
||||
const signals: AbortSignal[] = [];
|
||||
let observations = 0;
|
||||
const hub = new BuildEventStreamHub(
|
||||
{
|
||||
getBuildStatus: async () => base,
|
||||
reconcileBuild: async (_id, options) => {
|
||||
if (options?.signal) signals.push(options.signal);
|
||||
return blocked;
|
||||
},
|
||||
getBuildEvents: async () => {
|
||||
observations++;
|
||||
return [];
|
||||
},
|
||||
},
|
||||
{ pollMs: 5, heartbeatMs: 5 },
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const response = await hub.open(
|
||||
"a",
|
||||
new Request("https://test/events", { signal: abort.signal }),
|
||||
);
|
||||
const reader = response.body!.getReader();
|
||||
expect(new TextDecoder().decode((await reader.read()).value)).toContain(
|
||||
"event: status",
|
||||
);
|
||||
expect(new TextDecoder().decode((await reader.read()).value)).toBe(
|
||||
": heartbeat\n\n",
|
||||
);
|
||||
await Bun.sleep(25);
|
||||
expect(observations).toBeGreaterThan(1);
|
||||
expect(signals).toHaveLength(1);
|
||||
abort.abort();
|
||||
for (;;) {
|
||||
if ((await reader.read()).done) break;
|
||||
}
|
||||
expect(signals[0]?.aborted).toBe(true);
|
||||
const stoppedAt = observations;
|
||||
await Bun.sleep(20);
|
||||
expect(observations).toBe(stoppedAt);
|
||||
const next = new AbortController();
|
||||
const reopened = await hub.open(
|
||||
"a",
|
||||
new Request("https://test/events", { signal: next.signal }),
|
||||
);
|
||||
const nextReader = reopened.body!.getReader();
|
||||
expect(new TextDecoder().decode((await nextReader.read()).value)).toContain(
|
||||
"event: status",
|
||||
);
|
||||
expect(signals).toHaveLength(2);
|
||||
expect(signals[1]?.aborted).toBe(false);
|
||||
next.abort();
|
||||
await nextReader.cancel();
|
||||
});
|
||||
|
||||
test("bounds shared reconciliation writes while observing two subscribers independently", async () => {
|
||||
let reconciles = 0;
|
||||
let observations = 0;
|
||||
const hub = new BuildEventStreamHub(
|
||||
{
|
||||
getBuildStatus: async () => base,
|
||||
reconcileBuild: async () => {
|
||||
reconciles++;
|
||||
return base;
|
||||
},
|
||||
getBuildEvents: async () => {
|
||||
observations++;
|
||||
return [];
|
||||
},
|
||||
},
|
||||
{ pollMs: 10, reconcileMs: 80, heartbeatMs: 1_000 },
|
||||
);
|
||||
const a = new AbortController();
|
||||
const b = new AbortController();
|
||||
const first = await hub.open(
|
||||
"a",
|
||||
new Request("https://test/events", { signal: a.signal }),
|
||||
);
|
||||
const second = await hub.open(
|
||||
"a",
|
||||
new Request("https://test/events", { signal: b.signal }),
|
||||
);
|
||||
expect(
|
||||
new TextDecoder().decode((await first.body!.getReader().read()).value),
|
||||
).toContain("event: status");
|
||||
expect(
|
||||
new TextDecoder().decode((await second.body!.getReader().read()).value),
|
||||
).toContain("event: status");
|
||||
await Bun.sleep(45);
|
||||
expect(reconciles).toBe(1); // One lease/write attempt, not one per poll or subscriber.
|
||||
expect(observations).toBeGreaterThanOrEqual(3);
|
||||
for (let i = 0; i < 15 && reconciles < 2; i++) await tick();
|
||||
expect(reconciles).toBe(2);
|
||||
a.abort();
|
||||
b.abort();
|
||||
});
|
||||
|
||||
test("closes a slow subscriber at the bounded queue limit for resumable logs", async () => {
|
||||
const hub = new BuildEventStreamHub(
|
||||
{
|
||||
getBuildStatus: async () => base,
|
||||
reconcileBuild: async () => base,
|
||||
getBuildEvents: async (_id, after) =>
|
||||
Array.from({ length: 40 }, (_, index) => index + 1)
|
||||
.filter((sequence) => sequence > (after ?? 0))
|
||||
.map((sequence) => ({
|
||||
type: "log" as const,
|
||||
id: "a",
|
||||
sequence,
|
||||
message: `log ${sequence}\n`,
|
||||
})),
|
||||
},
|
||||
{ pollMs: 5, heartbeatMs: 50 },
|
||||
);
|
||||
const response = await hub.open("a", new Request("https://test/events"));
|
||||
await tick(); // Allow the producer to fill its queue before consumption.
|
||||
const reader = response.body!.getReader();
|
||||
const frames: string[] = [];
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
frames.push(new TextDecoder().decode(value));
|
||||
}
|
||||
expect(frames).toHaveLength(16);
|
||||
expect(frames[0]).toContain("id: 1\nevent: log");
|
||||
expect(frames[15]).toContain("id: 16\nevent: log");
|
||||
const resumed = await hub.open(
|
||||
"a",
|
||||
new Request("https://test/events", {
|
||||
headers: { "last-event-id": "16" },
|
||||
}),
|
||||
);
|
||||
const resumedReader = resumed.body!.getReader();
|
||||
expect(
|
||||
new TextDecoder().decode((await resumedReader.read()).value),
|
||||
).toContain("id: 17\nevent: log");
|
||||
await resumedReader.cancel();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { KubernetesObject, V1Deployment } from "@kubernetes/client-node";
|
||||
import { DatabaseReconciliationError } from "../../lib/database";
|
||||
import {
|
||||
createManagementService,
|
||||
type ManagementDependencies,
|
||||
@@ -67,6 +68,27 @@ function dependencies(
|
||||
}
|
||||
|
||||
describe("server management service", () => {
|
||||
test.each([
|
||||
["namespace precheck", { readNamespace: async () => { throw new Error("token=private"); } }],
|
||||
["database dependency", { reconcileDatabases: async () => { throw new Error("token=private"); } }],
|
||||
["database resource listing", { listDatabaseResources: async () => { throw new Error("token=private"); } }],
|
||||
["database resource ownership", { applyResource: async () => { throw new Error("token=private"); }, listDatabaseResources: async () => [object("Database", "DB_PASSWORD_private123", "db-uid")] }],
|
||||
] as const)("labels database %s failures without leaking details", async (phase, overrides) => {
|
||||
const service = createManagementService(dependencies(overrides));
|
||||
let failure: unknown;
|
||||
try { await service.reconcileDatabases(workspace, { services: {} }); }
|
||||
catch (error) { failure = error; }
|
||||
expect(failure).toBeInstanceOf(DatabaseReconciliationError);
|
||||
expect((failure as DatabaseReconciliationError).phase).toBe(phase);
|
||||
expect((failure as Error).message).not.toContain("private");
|
||||
});
|
||||
|
||||
test("preserves an existing database substep rather than masking it", async () => {
|
||||
const service = createManagementService(dependencies({
|
||||
reconcileDatabases: async () => { throw new DatabaseReconciliationError("database apply", new Error("private")); },
|
||||
}));
|
||||
await expect(service.reconcileDatabases(workspace, { services: {} })).rejects.toMatchObject({ phase: "database apply" });
|
||||
});
|
||||
test("waits on deployments together and cancels siblings after a crash", async () => {
|
||||
const started: string[] = [];
|
||||
let siblingCancelled = false;
|
||||
@@ -344,14 +366,18 @@ describe("server management service", () => {
|
||||
namespace: "database",
|
||||
},
|
||||
};
|
||||
const service = createManagementService(
|
||||
dependencies({ listDatabaseResources: async () => [database] }),
|
||||
);
|
||||
const deleted: ResourceIdentity[] = [];
|
||||
const service = createManagementService(dependencies({
|
||||
listDatabaseResources: async () => [database],
|
||||
deleteResource: async (identity) => { deleted.push(identity); },
|
||||
}));
|
||||
const plan = await service.planDown(workspace, true);
|
||||
expect(plan.delete.map(({ kind, uid }) => [kind, uid])).toEqual([
|
||||
["Database", "database-uid"],
|
||||
["Namespace", "namespace-uid"],
|
||||
]);
|
||||
expect(plan.retained.map(({ kind, uid }) => [kind, uid])).toEqual([["Database", "database-uid"]]);
|
||||
await service.down(workspace, true);
|
||||
expect(deleted.map(({ kind }) => kind)).toEqual(["Namespace"]);
|
||||
|
||||
const missingUid = createManagementService(
|
||||
dependencies({
|
||||
@@ -368,6 +394,87 @@ describe("server management service", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves foreign and owned database CRs on full down while deleting other resources", async () => {
|
||||
const shared = { ...object("Database", "sastify-store", "shared-uid", {
|
||||
"app.kubernetes.io/managed-by": "kuber", [WORKSPACE_PROJECT_LABEL]: workspace.project,
|
||||
[WORKSPACE_UID_LABEL]: "other-workspace",
|
||||
}), metadata: { ...object("Database", "sastify-store", "shared-uid").metadata,
|
||||
namespace: "database", labels: {
|
||||
"app.kubernetes.io/managed-by": "kuber", [WORKSPACE_PROJECT_LABEL]: workspace.project,
|
||||
[WORKSPACE_UID_LABEL]: "other-workspace",
|
||||
} } };
|
||||
const owned = { ...object("Database", "local", "local-uid"), metadata: {
|
||||
...object("Database", "local", "local-uid").metadata, namespace: "database",
|
||||
} };
|
||||
const deleted: string[] = [];
|
||||
const service = createManagementService(dependencies({
|
||||
listProjectResources: async () => [object("Deployment", "api", "deployment-uid")],
|
||||
listDatabaseResources: async () => [shared, owned],
|
||||
deleteResource: async (identity) => { deleted.push(identity.kind); },
|
||||
}));
|
||||
const plan = await service.down(workspace, true);
|
||||
expect(plan.delete.map(({ kind }) => kind)).toEqual(["Deployment", "Namespace"]);
|
||||
expect(plan.retained.map(({ name }) => name)).toEqual(["local"]);
|
||||
expect(deleted).toEqual(["Deployment", "Namespace"]);
|
||||
});
|
||||
|
||||
test("reuses declared foreign database without taking its workspace UID", async () => {
|
||||
const db = { ...object("Database", "sastify-store", "shared-uid", {
|
||||
"app.kubernetes.io/managed-by": "kuber", [WORKSPACE_PROJECT_LABEL]: workspace.project,
|
||||
[WORKSPACE_UID_LABEL]: "other-workspace",
|
||||
}), metadata: {
|
||||
...object("Database", "sastify-store", "shared-uid").metadata,
|
||||
namespace: "database",
|
||||
labels: { "app.kubernetes.io/managed-by": "kuber",
|
||||
[WORKSPACE_PROJECT_LABEL]: workspace.project, [WORKSPACE_UID_LABEL]: "other-workspace" },
|
||||
}, spec: { owner: "sastify", cluster: { name: "postgres" } } };
|
||||
const before = structuredClone(db);
|
||||
const applied: KubernetesObject[] = [];
|
||||
const service = createManagementService(dependencies({
|
||||
readNamespace: async (project) => ({ uid: "namespace-uid", labels: {
|
||||
"app.kubernetes.io/managed-by": "kuber",
|
||||
[WORKSPACE_UID_LABEL]: project === workspace.project ? workspace.uid : "second-uid",
|
||||
} }),
|
||||
listDatabaseResources: async () => [db],
|
||||
applyResource: async (resource) => { applied.push(resource); return resource; },
|
||||
}));
|
||||
const compose = { services: { web: { volumes: ["postgresql:sastify/sastify-store"] } } } as never;
|
||||
for (const project of [workspace, { project: "second-project", uid: "second-uid" }]) {
|
||||
await service.reconcileDatabases(project, compose);
|
||||
}
|
||||
expect(applied).toEqual([]);
|
||||
expect(db).toEqual(before);
|
||||
await expect(service.reconcileDatabases(workspace, { services: {} })).rejects.toMatchObject({ phase: "database resource ownership" });
|
||||
expect(applied).toEqual([]);
|
||||
});
|
||||
|
||||
test("adopts unowned and same-workspace databases and rejects foreign conflicting specs", async () => {
|
||||
const resources: KubernetesObject[] = [
|
||||
{ ...object("Database", "new-db", "new-uid"), metadata: {
|
||||
...object("Database", "new-db", "new-uid").metadata,
|
||||
namespace: "database", labels: { "app.kubernetes.io/managed-by": "kuber" },
|
||||
} },
|
||||
{ ...object("Database", "owned-db", "owned-uid"), metadata: {
|
||||
...object("Database", "owned-db", "owned-uid").metadata, namespace: "database",
|
||||
} },
|
||||
];
|
||||
const applied: KubernetesObject[] = [];
|
||||
const service = createManagementService(dependencies({
|
||||
listDatabaseResources: async () => resources,
|
||||
applyResource: async (resource) => { applied.push(resource); return resource; },
|
||||
}));
|
||||
await service.reconcileDatabases(workspace, { services: {} });
|
||||
expect(applied.map((resource) => resource.metadata?.labels?.[WORKSPACE_UID_LABEL])).toEqual([workspace.uid, workspace.uid]);
|
||||
resources[0] = { ...resources[0]!, metadata: { ...resources[0]!.metadata,
|
||||
labels: { [WORKSPACE_UID_LABEL]: "other-workspace" } }, spec: {
|
||||
owner: "wrong", cluster: { name: "postgres" },
|
||||
} } as KubernetesObject;
|
||||
await expect(service.reconcileDatabases(workspace, { services: { web: {
|
||||
volumes: ["postgresql:sastify/new-db"],
|
||||
} } } as never)).rejects.toMatchObject({ phase: "database resource ownership" });
|
||||
expect(applied).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("stop deletes matching HPAs before scaling selected deployments", async () => {
|
||||
const calls: string[] = [];
|
||||
const service = createManagementService(
|
||||
|
||||
@@ -121,6 +121,13 @@ describe("operation store", () => {
|
||||
code: "RECONCILE_FAILED",
|
||||
message: "Database reconciliation failed",
|
||||
});
|
||||
expect(sanitizeOperationError({
|
||||
code: "DATABASE_RECONCILE_FAILED",
|
||||
message: "Database reconciliation failed during database apply: Forbidden (HTTP 403) DB_PASSWORD=private",
|
||||
}, "databases.reconcile")).toEqual({
|
||||
code: "DATABASE_RECONCILE_FAILED",
|
||||
message: "Database reconciliation failed during database apply: Forbidden (HTTP 403) DB_PASSWORD=[REDACTED]",
|
||||
});
|
||||
});
|
||||
|
||||
test("enforces the operation state machine", async () => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createApp, execProblem } from "../../server/app";
|
||||
import { hashToken, MemoryAuthStore } from "../../server/auth";
|
||||
import { MemoryWorkspaceStore } from "../../server/workspace-store";
|
||||
import type { LogService } from "../../server/log-service";
|
||||
import { KUBER_VERSION, KUBER_VERSION_HEADER } from "../../shared/version";
|
||||
|
||||
test("returns the bundled version on success, errors, preflight, empty and streaming responses", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({
|
||||
username: "viewer",
|
||||
passwordHash: "hash",
|
||||
roles: ["viewer"],
|
||||
});
|
||||
await store.putSession({
|
||||
tokenHash: hashToken("token"),
|
||||
username: "viewer",
|
||||
authVersion: 1,
|
||||
expiresAt: "2030-01-01T00:00:00.000Z",
|
||||
});
|
||||
const workspaceStore = new MemoryWorkspaceStore({
|
||||
uid: () => "workspace-uid",
|
||||
});
|
||||
await workspaceStore.create({
|
||||
id: "demo",
|
||||
source: { uri: "oci://example/demo", digest: "sha256:abc" },
|
||||
});
|
||||
const logs = {
|
||||
async collect() {
|
||||
return [{ type: "log", message: "hello" }];
|
||||
},
|
||||
} as unknown as LogService;
|
||||
const app = createApp({ store, workspaceStore, logs });
|
||||
const request = (path: string, init: RequestInit = {}) =>
|
||||
new Request(`https://kuber.astrxl.dev${path}`, init);
|
||||
const authenticated = (path: string, init: RequestInit = {}) =>
|
||||
request(path, {
|
||||
...init,
|
||||
headers: {
|
||||
authorization: "Bearer token",
|
||||
...Object.fromEntries(new Headers(init.headers)),
|
||||
},
|
||||
});
|
||||
|
||||
const success = await app(request("/api/v2/health"));
|
||||
const error = await app(request("/api/v2/me"));
|
||||
const preflight = await app(
|
||||
request("/api/v2/health", {
|
||||
method: "OPTIONS",
|
||||
headers: { origin: "https://kuber.astrxl.dev" },
|
||||
}),
|
||||
);
|
||||
const stream = await app(authenticated("/api/v2/workspaces/demo/logs"));
|
||||
const empty = await app(authenticated("/api/v2/logout", { method: "POST" }));
|
||||
|
||||
for (const response of [success, error, preflight, empty, stream]) {
|
||||
expect(response.headers.get(KUBER_VERSION_HEADER)).toBe(KUBER_VERSION);
|
||||
}
|
||||
expect([
|
||||
success.status,
|
||||
error.status,
|
||||
preflight.status,
|
||||
empty.status,
|
||||
stream.status,
|
||||
]).toEqual([200, 401, 204, 204, 200]);
|
||||
expect(stream.headers.get("content-type")).toContain("application/x-ndjson");
|
||||
expect(await stream.text()).toContain("hello");
|
||||
});
|
||||
|
||||
test("includes the version on exec upgrade errors outside the ordinary API router", () => {
|
||||
const response = execProblem(new Error("upgrade failed"), "request-id");
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.headers.get(KUBER_VERSION_HEADER)).toBe(KUBER_VERSION);
|
||||
});
|
||||
Reference in New Issue
Block a user