Release 2.6.2-rc2
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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> = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user