diff --git a/changelogs/2.6.2.md b/changelogs/2.6.2.md index b0d30fc..71a4a24 100644 --- a/changelogs/2.6.2.md +++ b/changelogs/2.6.2.md @@ -1,3 +1,16 @@ +# 2.6.2-rc2 + +## Fixed + +- Build the CLI with Bun `--env disable` to restore the runtime passive updater guard. +- Bound plan requests to 120 seconds and support cancellation. +- Use a deterministic clock in the server test fixture. + +## Upgrade note + +- 2.6.1 and 2.6.2-rc1 clients need one manual install to receive this updater fix. +- The root cause of late build Job creation remains unproven. + # 2.6.2-rc1 ## Fixed diff --git a/command/main.ts b/command/main.ts index 6bfebce..f5745be 100644 --- a/command/main.ts +++ b/command/main.ts @@ -37,7 +37,7 @@ function cloneCommand(command: T): T { export const main = defineCommand({ meta: { name: "kuber", - version: "2.6.2-rc1", + version: "2.6.2-rc2", description: "Docker Compose -> K8s translation layer", }, args: { diff --git a/command/up.ts b/command/up.ts index c7211ad..3fbd3b5 100644 --- a/command/up.ts +++ b/command/up.ts @@ -114,6 +114,7 @@ export const WORKSPACE_ADOPTION_METHOD = "POST"; const OPERATION_RESUME_INITIAL_BACKOFF_MS = 250; const OPERATION_RESUME_MAX_BACKOFF_MS = 5_000; const OPERATION_RESUME_GRACE_MS = 60_000; +const RESOURCE_PLAN_TIMEOUT_MS = 120_000; const RECOVERABLE_API_ERROR_CODES = new Set([ "HTTP_502", "HTTP_503", @@ -297,9 +298,10 @@ async function managementRequest( request: ApiRequester, path: string, init: ApiRequestInit, + options?: ApiRequestOptions, ): Promise { try { - return await request(path, init); + return await request(path, init, options); } catch (error) { throw adoptionHint(project, error); } @@ -548,13 +550,15 @@ async function planResources( project: string, resources: KubernetesResource[], request: ApiRequester = apiRequest, + signal?: AbortSignal, ): Promise { const workspacePath = `/workspaces/${encodeURIComponent(project)}`; return managementRequest( project, request, `${workspacePath}/resources/plan`, - { method: "POST", json: { resources } }, + { method: "POST", json: { resources }, signal }, + { timeoutMs: RESOURCE_PLAN_TIMEOUT_MS }, ); } @@ -621,7 +625,7 @@ export async function reconcileResources( request: ApiRequester = apiRequest, resumeOptions: OperationResumeOptions = {}, ): Promise { - const plan = await planResources(project, resources, request); + const plan = await planResources(project, resources, request, resumeOptions.signal); await applyResourcePlan( project, plan, @@ -1004,6 +1008,7 @@ export async function runUp( project, taskCtx.resources!, request, + task.signal, ); const plan = taskCtx.plan; const desired = plan.desired diff --git a/package.json b/package.json index f912378..ae54cce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dmgnr/kuber", - "version": "2.6.2-rc1", + "version": "2.6.2-rc2", "description": "Docker Compose to Kubernetes translation layer", "bin": { "kuber": "dist/index.js" @@ -13,7 +13,7 @@ "type": "module", "types": "types.d.ts", "scripts": { - "build": "bun build index.ts --target bun --minify --outdir dist", + "build": "bun build index.ts --target bun --minify --env disable --outdir dist", "build:server": "bun build server/index.ts --target bun --minify --outfile dist/kuber-server.js", "server": "bun server/index.ts", "prepack": "bun run build", diff --git a/tests/command/module-graph.test.ts b/tests/command/module-graph.test.ts index 3d22b78..0a3a4a8 100644 --- a/tests/command/module-graph.test.ts +++ b/tests/command/module-graph.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; describe("CLI module graph", () => { @@ -11,4 +13,28 @@ describe("CLI module graph", () => { expect(probe.exitCode).toBe(0); expect(new TextDecoder().decode(probe.stdout)).toContain("USAGE"); }); + + test("production build retains the passive updater's runtime environment guard", () => { + const root = join(import.meta.dir, "../.."); + const { scripts } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const args = (scripts.build as string).split(/\s+/); + expect(args).toContain("--env"); + expect(args[args.indexOf("--env") + 1]).toBe("disable"); + + const outdir = mkdtempSync(join(tmpdir(), "kuber-cli-build-")); + try { + args[args.indexOf("--outdir") + 1] = outdir; + const build = Bun.spawnSync(args, { + cwd: root, + env: { ...process.env, NODE_ENV: "production" }, + }); + expect(new TextDecoder().decode(build.stderr)).toBe(""); + expect(build.exitCode).toBe(0); + const bundle = readFileSync(join(outdir, "index.js"), "utf8"); + expect(bundle).toMatch(/process\.env\.NODE_ENV\s*===\s*["']test["']/); + expect(bundle).toMatch(/process\.env\.NODE_ENV\s*===\s*["']development["']/); + } finally { + rmSync(outdir, { recursive: true, force: true }); + } + }, 20_000); }); diff --git a/tests/command/up-api.test.ts b/tests/command/up-api.test.ts index 9301f21..ff9e09e 100644 --- a/tests/command/up-api.test.ts +++ b/tests/command/up-api.test.ts @@ -288,6 +288,11 @@ describe("up API pipeline", () => { expect( new Headers(build.init?.headers).get("x-kuber-trust-project"), ).toBe("shop"); + const plans = calls.filter(({ path }) => path === "/workspaces/shop/resources/plan"); + expect(plans).toHaveLength(1); + expect(plans[0]?.options).toEqual({ timeoutMs: 120_000 }); + expect(plans[0]?.init?.signal).toBeInstanceOf(AbortSignal); + expect(new Headers(plans[0]?.init?.headers).get("x-kuber-trust-project")).toBe("shop"); } finally { process.chdir(previousCwd); if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; @@ -714,6 +719,69 @@ describe("up API pipeline", () => { expect(order).toEqual(["plan", "apply", "hook", "wait", "delete"]); }); + test("allows a slow plan beyond the default deadline before applying once", async () => { + const calls: string[] = []; + const controller = new AbortController(); + const request: ApiRequester = async ( + path: string, + init?: ApiRequestInit, + options?: ApiRequestOptions, + ) => { + calls.push(path.split("/").at(-1)!); + if (path.endsWith("/plan")) { + expect(init?.method).toBe("POST"); + expect(init?.signal).toBe(controller.signal); + // Scale 1 simulated second to 1ms: the plan completes at 35s. + const deadline = options?.timeoutMs ?? 30_000; + if (deadline < 35_000) throw new DOMException("Timed out", "TimeoutError"); + await Bun.sleep(35); + return { desired: [], stale: [] } as T; + } + if (path.endsWith("/apply")) return {} as T; + throw new Error(`Unexpected request: ${path}`); + }; + + await reconcileResources("shop", [], 1, undefined, request, { + signal: controller.signal, + }); + expect(calls).toEqual(["plan", "apply"]); + }); + + test("cancels a pending plan without starting a mutation", async () => { + const controller = new AbortController(); + const cancelled = new DOMException("Cancelled", "AbortError"); + const calls: string[] = []; + let planStarted!: () => void; + const started = new Promise((resolve) => { planStarted = resolve; }); + const request: ApiRequester = async (path: string, init?: ApiRequestInit) => { + calls.push(path); + if (!path.endsWith("/plan")) throw new Error(`Unexpected request: ${path}`); + expect(init?.signal).toBe(controller.signal); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + planStarted(); + }); + }; + const run = reconcileResources("shop", [], 1, undefined, request, { + signal: controller.signal, + }); + await started; + controller.abort(cancelled); + await expect(run).rejects.toBe(cancelled); + expect(calls).toEqual(["/workspaces/shop/resources/plan"]); + }); + + test("does not retry an HTTP 500 plan or start an apply", async () => { + const calls: string[] = []; + const failure = new KuberApiError("plan failed", 500); + const request: ApiRequester = async (path: string) => { + calls.push(path); + throw failure; + }; + await expect(reconcileResources("shop", [], 1, undefined, request)).rejects.toBe(failure); + expect(calls).toEqual(["/workspaces/shop/resources/plan"]); + }); + test("resumes an interrupted reconcile operation by its persisted ID", async () => { const calls: string[] = []; const applyIdempotencyKeys: Array = []; diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index 5930334..0f9a542 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -447,9 +447,10 @@ describe("API key route expiry", () => { tokenHash: hashToken("admin-token"), username: "admin", authVersion: 1, - expiresAt: "2027-01-01T00:00:00.000Z", + expiresAt: "2030-01-03T00:00:00.000Z", }); - let time = Date.parse("2026-10-05T00:00:00.000Z"); + const fixedTime = Date.parse("2030-01-01T00:00:00.000Z"); + let time = fixedTime; const app = createApp({ store, now: () => time }); const create = (expiresAt: unknown) => app( @@ -465,7 +466,7 @@ describe("API key route expiry", () => { "admin-token", ), ); - const expiry = "2026-10-06T00:00:00.000Z"; + const expiry = "2030-01-02T00:00:00.000Z"; const created = await create(expiry); expect(created.status).toBe(201); const key = (await created.json()) as { token: string; expiresAt: string }; @@ -475,8 +476,8 @@ describe("API key route expiry", () => { null, 0, "", - "2026-10-05T00:00:00.000Z", - "2027-10-06T00:00:00.000Z", + "2029-12-31T23:59:59.999Z", + "2031-01-02T00:00:00.000Z", ]) expect((await create(invalid)).status).toBe(400); time = Date.parse(expiry);