feat: prepare 2.6.1-rc5 shared databases and build SSE

This commit is contained in:
2026-10-05 11:09:52 +00:00 Unverified
parent f76165603e
commit 828bf3a328
25 changed files with 2557 additions and 196 deletions
+77 -37
View File
@@ -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 });
}