Release 2.6.2-rc2

This commit is contained in:
2026-10-06 01:58:22 +00:00 Unverified
parent 198b7866ef
commit cd9fb512cd
7 changed files with 124 additions and 11 deletions
+13
View File
@@ -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
+1 -1
View File
@@ -37,7 +37,7 @@ function cloneCommand<T extends object>(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: {
+8 -3
View File
@@ -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<T>(
request: ApiRequester,
path: string,
init: ApiRequestInit,
options?: ApiRequestOptions,
): Promise<T> {
try {
return await request<T>(path, init);
return await request<T>(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<ResourcePlan> {
const workspacePath = `/workspaces/${encodeURIComponent(project)}`;
return managementRequest<ResourcePlan>(
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<ResourcePlan> {
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
+2 -2
View File
@@ -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",
+26
View File
@@ -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);
});
+68
View File
@@ -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 <T>(
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<void>((resolve) => { planStarted = resolve; });
const request: ApiRequester = async <T>(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<T>((_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 <T>(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<string | null> = [];
+6 -5
View File
@@ -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);