feat: improve API keys and build workflows
This commit is contained in:
@@ -136,9 +136,27 @@ describe("API keys", () => {
|
||||
});
|
||||
expect(differentWorkspace.status).toBe(403);
|
||||
|
||||
const noExpiry = await create({
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "shop",
|
||||
});
|
||||
expect(noExpiry.status).toBe(403);
|
||||
expect((await noExpiry.json()) as { code: string }).toHaveProperty(
|
||||
"code",
|
||||
"API_KEY_DELEGATION_FORBIDDEN",
|
||||
);
|
||||
|
||||
const laterExpiry = await create({
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-06T00:00:00.000Z",
|
||||
});
|
||||
expect(laterExpiry.status).toBe(403);
|
||||
|
||||
const subset = await create({
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
expect(subset.status).toBe(201);
|
||||
expect(
|
||||
@@ -168,7 +186,7 @@ describe("API keys", () => {
|
||||
event.spec.action === "api_key.create" &&
|
||||
event.spec.outcome === "denied",
|
||||
),
|
||||
).toHaveLength(4);
|
||||
).toHaveLength(6);
|
||||
expect(JSON.stringify(denied)).not.toContain("delegation-parent");
|
||||
|
||||
await store.createApiKey({
|
||||
@@ -184,7 +202,10 @@ describe("API keys", () => {
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ capabilities: ["kubernetes:read"] }),
|
||||
body: JSON.stringify({
|
||||
capabilities: ["kubernetes:read"],
|
||||
expiresAt: "2026-09-20T00:00:00.000Z",
|
||||
}),
|
||||
},
|
||||
"unscoped-delegation",
|
||||
),
|
||||
@@ -193,6 +214,33 @@ describe("API keys", () => {
|
||||
expect(await unscopedSubset.json()).not.toHaveProperty("workspace");
|
||||
});
|
||||
|
||||
test("allows a non-expiring parent to delegate a non-expiring child", async () => {
|
||||
const { app, store } = await setup();
|
||||
await store.createApiKey({
|
||||
id: "key_nonexpiring_parent",
|
||||
tokenHash: hashToken("nonexpiring-parent"),
|
||||
username: "ci",
|
||||
capabilities: ["users:write", "kubernetes:read"],
|
||||
});
|
||||
const created = await app(
|
||||
request(
|
||||
"/api/v2/users/ci/keys",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ capabilities: ["kubernetes:read"] }),
|
||||
},
|
||||
"nonexpiring-parent",
|
||||
),
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
const child = (await created.json()) as { token: string };
|
||||
expect(child).not.toHaveProperty("expiresAt");
|
||||
expect((await app(request("/api/v2/me", {}, child.token))).status).toBe(
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects expired keys and expiry longer than 365 days", async () => {
|
||||
const { app, store } = await setup();
|
||||
await store.createApiKey({
|
||||
@@ -221,7 +269,7 @@ describe("API keys", () => {
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
test("uses the default expiry, cleans up expired keys, and does not log keys out", async () => {
|
||||
test("preserves non-expiring keys while cleaning up finite keys and sessions", async () => {
|
||||
const { app, store } = await setup();
|
||||
const created = await app(
|
||||
request("/api/v2/users/ci/keys", {
|
||||
@@ -230,17 +278,44 @@ describe("API keys", () => {
|
||||
body: JSON.stringify({ capabilities: ["kubernetes:read"] }),
|
||||
}),
|
||||
);
|
||||
const key = (await created.json()) as { token: string; expiresAt: string };
|
||||
expect(key.expiresAt).toBe("2026-12-04T00:00:00.000Z");
|
||||
expect(created.status).toBe(201);
|
||||
const key = (await created.json()) as { token: string };
|
||||
expect(key).not.toHaveProperty("expiresAt");
|
||||
const listed = await app(request("/api/v2/users/ci/keys"));
|
||||
const { items } = (await listed.json()) as { items: object[] };
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).not.toHaveProperty("expiresAt");
|
||||
const finite = await app(
|
||||
request("/api/v2/users/ci/keys", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
capabilities: ["kubernetes:read"],
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(finite.status).toBe(201);
|
||||
const finiteKey = (await finite.json()) as {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
expect(finiteKey.expiresAt).toBe("2026-10-05T00:00:00.000Z");
|
||||
expect(
|
||||
(await app(request("/api/v2/logout", { method: "POST" }, key.token)))
|
||||
.status,
|
||||
).toBe(204);
|
||||
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(200);
|
||||
expect(await cleanupExpiredSessions(store, Date.parse(key.expiresAt))).toBe(
|
||||
2,
|
||||
expect((await app(request("/api/v2/me", {}, finiteKey.token))).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(401);
|
||||
expect(
|
||||
await cleanupExpiredSessions(store, Date.parse(finiteKey.expiresAt)),
|
||||
).toBe(2);
|
||||
expect((await app(request("/api/v2/me", {}, finiteKey.token))).status).toBe(
|
||||
401,
|
||||
);
|
||||
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(200);
|
||||
});
|
||||
|
||||
test("denies a workspace-scoped key outside its workspace", async () => {
|
||||
|
||||
+179
-4
@@ -238,9 +238,7 @@ describe("operation response safety", () => {
|
||||
});
|
||||
|
||||
test("redacts database and storage reconciliation results immediately", async () => {
|
||||
const workspaceStore = new MemoryWorkspaceStore({
|
||||
uid: () => "workspace-uid",
|
||||
});
|
||||
const workspaceStore = new MemoryWorkspaceStore({ uid: () => "workspace-uid" });
|
||||
await workspaceStore.create({
|
||||
id: "demo",
|
||||
source: { uri: "oci://example/demo", digest: "sha256:abc" },
|
||||
@@ -381,6 +379,143 @@ describe("operation response safety", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("API key route expiry", () => {
|
||||
test("omitted expiry creates a non-expiring key that authenticates and survives cleanup", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({
|
||||
username: "admin",
|
||||
passwordHash: "hash",
|
||||
roles: ["admin"],
|
||||
});
|
||||
await store.putUser({
|
||||
username: "ci",
|
||||
passwordHash: "hash",
|
||||
roles: ["viewer"],
|
||||
});
|
||||
await store.putSession({
|
||||
tokenHash: hashToken("admin-token"),
|
||||
username: "admin",
|
||||
authVersion: 1,
|
||||
expiresAt: "2027-01-01T00:00:00.000Z",
|
||||
});
|
||||
let time = Date.parse("2026-10-05T00:00:00.000Z");
|
||||
const app = createApp({ store, now: () => time });
|
||||
const created = await app(
|
||||
request(
|
||||
"/api/v2/users/ci/keys",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ capabilities: ["kubernetes:read"] }),
|
||||
},
|
||||
"admin-token",
|
||||
),
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
const key = (await created.json()) as {
|
||||
id: string;
|
||||
token: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
expect(key).not.toHaveProperty("expiresAt");
|
||||
expect((await store.listApiKeys("ci"))[0]).not.toHaveProperty("expiresAt");
|
||||
const listed = await app(
|
||||
request("/api/v2/users/ci/keys", {}, "admin-token"),
|
||||
);
|
||||
const list = (await listed.json()) as { items: Record<string, unknown>[] };
|
||||
expect(list.items).toEqual([expect.objectContaining({ id: key.id })]);
|
||||
expect(list.items[0]).not.toHaveProperty("expiresAt");
|
||||
time = Date.parse("2028-01-01T00:00:00.000Z");
|
||||
expect(await cleanupExpiredSessions(store, time)).toBe(1);
|
||||
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(200);
|
||||
});
|
||||
|
||||
test("explicit finite expiry is enforced and malformed expiry is rejected", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({
|
||||
username: "admin",
|
||||
passwordHash: "hash",
|
||||
roles: ["admin"],
|
||||
});
|
||||
await store.putUser({
|
||||
username: "ci",
|
||||
passwordHash: "hash",
|
||||
roles: ["viewer"],
|
||||
});
|
||||
await store.putSession({
|
||||
tokenHash: hashToken("admin-token"),
|
||||
username: "admin",
|
||||
authVersion: 1,
|
||||
expiresAt: "2027-01-01T00:00:00.000Z",
|
||||
});
|
||||
let time = Date.parse("2026-10-05T00:00:00.000Z");
|
||||
const app = createApp({ store, now: () => time });
|
||||
const create = (expiresAt: unknown) =>
|
||||
app(
|
||||
request(
|
||||
"/api/v2/users/ci/keys",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
capabilities: ["kubernetes:read"],
|
||||
expiresAt,
|
||||
}),
|
||||
},
|
||||
"admin-token",
|
||||
),
|
||||
);
|
||||
const expiry = "2026-10-06T00:00:00.000Z";
|
||||
const created = await create(expiry);
|
||||
expect(created.status).toBe(201);
|
||||
const key = (await created.json()) as { token: string; expiresAt: string };
|
||||
expect(key.expiresAt).toBe(expiry);
|
||||
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(200);
|
||||
for (const invalid of [
|
||||
null,
|
||||
0,
|
||||
"",
|
||||
"2026-10-05T00:00:00.000Z",
|
||||
"2027-10-06T00:00:00.000Z",
|
||||
])
|
||||
expect((await create(invalid)).status).toBe(400);
|
||||
time = Date.parse(expiry);
|
||||
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(401);
|
||||
});
|
||||
|
||||
test("a finite parent API key cannot delegate a non-expiring child", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({
|
||||
username: "ci",
|
||||
passwordHash: "hash",
|
||||
roles: ["viewer"],
|
||||
});
|
||||
await store.createApiKey({
|
||||
id: "finite-parent-key-1",
|
||||
tokenHash: hashToken("parent-token"),
|
||||
username: "ci",
|
||||
capabilities: ["users:write", "kubernetes:read"],
|
||||
expiresAt: "2027-01-01T00:00:00.000Z",
|
||||
});
|
||||
const app = createApp({
|
||||
store,
|
||||
now: () => Date.parse("2026-10-05T00:00:00.000Z"),
|
||||
});
|
||||
const created = await app(
|
||||
request(
|
||||
"/api/v2/users/ci/keys",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ capabilities: ["kubernetes:read"] }),
|
||||
},
|
||||
"parent-token",
|
||||
),
|
||||
);
|
||||
expect(created.status).toBe(403);
|
||||
expect((await created.json()) as { code: string }).toMatchObject({
|
||||
code: "API_KEY_DELEGATION_FORBIDDEN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function authenticatedStore(role: "viewer" | "operator" | "admin") {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({ username: role, passwordHash: "hash", roles: [role] });
|
||||
@@ -1078,7 +1213,9 @@ describe("kuber v2 HTTP routes", () => {
|
||||
});
|
||||
|
||||
test("a stalled observation-only wait does not block mutation and remains idempotent", async () => {
|
||||
const workspaceStore = new MemoryWorkspaceStore({ uid: () => "workspace-uid" });
|
||||
const workspaceStore = new MemoryWorkspaceStore({
|
||||
uid: () => "workspace-uid",
|
||||
});
|
||||
await workspaceStore.create({
|
||||
id: "demo",
|
||||
source: { uri: "oci://example/demo", digest: "sha256:abc" },
|
||||
@@ -1620,6 +1757,44 @@ describe("kuber v2 HTTP routes", () => {
|
||||
).toBe("failed");
|
||||
});
|
||||
|
||||
test("returns safe database phase and claim details on first and repeated failures", 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-failure");
|
||||
const { DatabaseReconciliationError } = await import("../../lib/database");
|
||||
const app = createApp({
|
||||
store: await authenticatedStore("operator"),
|
||||
workspaceStore,
|
||||
operationStore,
|
||||
management: {
|
||||
reconcileDatabases: async () => {
|
||||
throw new DatabaseReconciliationError(
|
||||
"database apply",
|
||||
new Error("Forbidden: DB_PASSWORD=private-value"),
|
||||
{ service: "web", username: "web_role", database: "web_db", secretName: "web" },
|
||||
);
|
||||
},
|
||||
} as unknown as ManagementService,
|
||||
});
|
||||
const reconcile = () => app(request(
|
||||
"/api/v2/workspaces/demo/databases",
|
||||
{ method: "POST", headers: { "idempotency-key": "db-failure" }, body: JSON.stringify({ compose: {} }) },
|
||||
"token",
|
||||
));
|
||||
for (const result of [await reconcile(), await reconcile()]) {
|
||||
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(JSON.stringify(problem)).not.toContain("private-value");
|
||||
}
|
||||
expect((await operationStore.get("operation-db-failure"))?.status.error?.message)
|
||||
.toContain("database apply for database web_db");
|
||||
});
|
||||
|
||||
test("routes workspace adoption and keeps platform adoption admin-only", async () => {
|
||||
const workspaceStore = new MemoryWorkspaceStore({
|
||||
uid: () => "workspace-uid",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
MemoryAuthStore,
|
||||
hashToken,
|
||||
normalizeApiKey,
|
||||
tokenHashesEqual,
|
||||
} from "../../server/auth";
|
||||
import {
|
||||
@@ -23,6 +24,54 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
describe("memory auth store", () => {
|
||||
test("keeps non-expiring keys active and preserves finite expiry validation", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({
|
||||
username: "alice",
|
||||
passwordHash: "hash",
|
||||
roles: ["viewer"],
|
||||
});
|
||||
const key = {
|
||||
id: "never-expiring-key-123",
|
||||
tokenHash: hashToken("never"),
|
||||
username: "alice",
|
||||
capabilities: ["kubernetes:read" as const],
|
||||
};
|
||||
await store.createApiKey(key);
|
||||
expect(await store.getApiKey(key.tokenHash)).toMatchObject(key);
|
||||
expect((await store.listApiKeys("alice"))[0]?.expiresAt).toBeUndefined();
|
||||
await store.createApiKey({
|
||||
...key,
|
||||
id: "finite-expiry-key-123",
|
||||
tokenHash: hashToken("finite"),
|
||||
expiresAt: "2020-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(await store.deleteExpiredApiKeys()).toBe(1);
|
||||
expect(await store.getApiKey(key.tokenHash)).toMatchObject(key);
|
||||
expect(
|
||||
normalizeApiKey({ ...key, expiresAt: undefined }).expiresAt,
|
||||
).toBeUndefined();
|
||||
for (const expiresAt of ["none", "not-a-date", "2026-09-03", ""]) {
|
||||
expect(() => normalizeApiKey({ ...key, expiresAt })).toThrow(
|
||||
"ISO timestamp",
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
store.createApiKey({
|
||||
...key,
|
||||
id: "duplicate-key-id-123",
|
||||
tokenHash: hashToken("duplicate"),
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(await store.revokeApiKey("bob", key.id)).toBe(false);
|
||||
expect(await store.revokeApiKey("alice", key.id)).toBe(true);
|
||||
expect(await store.getApiKey(key.tokenHash)).toBeUndefined();
|
||||
await expect(
|
||||
store.createApiKey({ ...key, username: "missing" }),
|
||||
).rejects.toThrow("not active");
|
||||
await store.updateUser("alice", { disabled: true });
|
||||
await expect(store.createApiKey({ ...key })).rejects.toThrow("not active");
|
||||
});
|
||||
test("maintains the API-key hash index across key mutations", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({
|
||||
|
||||
@@ -167,7 +167,10 @@ async function fixture(
|
||||
};
|
||||
}
|
||||
|
||||
async function internalFixture(maxLogBytes = 1024) {
|
||||
async function internalFixture(
|
||||
maxLogBytes = 1024,
|
||||
resolveDigest?: (image: string) => Promise<Sha256Digest>,
|
||||
) {
|
||||
const root = await mkdtemp(join(tmpdir(), "kuber-controller-internal-"));
|
||||
roots.push(root);
|
||||
const cas = new FilesystemCas(join(root, "cas"));
|
||||
@@ -205,7 +208,7 @@ async function internalFixture(maxLogBytes = 1024) {
|
||||
pushRegistryInsecure: true,
|
||||
maxLogBytes,
|
||||
now: () => new Date(Date.UTC(2026, 8, 2, 0, 0, now++)),
|
||||
resolveDigest: async () => `sha256:${"f".repeat(64)}`,
|
||||
resolveDigest: resolveDigest ?? (async () => `sha256:${"f".repeat(64)}`),
|
||||
});
|
||||
const request: BuildRequest = {
|
||||
version: BUILD_PROTOCOL_VERSION,
|
||||
@@ -233,6 +236,86 @@ async function internalFixture(maxLogBytes = 1024) {
|
||||
}
|
||||
|
||||
describe("build controller", () => {
|
||||
test("publishes multiple service destinations in one Job and resolves every canonical reference", async () => {
|
||||
const resolved: string[] = [];
|
||||
const { controller, request, kubernetes } = await internalFixture(
|
||||
1024,
|
||||
async (image) => {
|
||||
resolved.push(image);
|
||||
return `sha256:${"f".repeat(64)}`;
|
||||
},
|
||||
);
|
||||
request.destinations = [
|
||||
{ service: "worker", image: "external.example/other:latest" },
|
||||
];
|
||||
await controller.submitBuild(request);
|
||||
expect(kubernetes.jobs).toHaveLength(1);
|
||||
expect(
|
||||
(kubernetes.jobs[0]!.spec as any).template.spec.containers[0].args,
|
||||
).toContain(
|
||||
'--output=type=image,"name=cncf-distribution-svc.registry.svc.cluster.local:5000/kuber/demo-web:latest,cncf-distribution-svc.registry.svc.cluster.local:5000/kuber/demo-worker:latest",push=true,registry.insecure=true',
|
||||
);
|
||||
kubernetes.observation = { phase: "succeeded" };
|
||||
expect(await controller.reconcileBuild(request.id)).toMatchObject({
|
||||
state: "succeeded",
|
||||
});
|
||||
expect(resolved).toEqual([
|
||||
"registry.neko-piranha.ts.net/kuber/demo-web:latest",
|
||||
"registry.neko-piranha.ts.net/kuber/demo-worker:latest",
|
||||
]);
|
||||
expect(await controller.getBuildResult(request.id)).toMatchObject({
|
||||
reference: `registry.neko-piranha.ts.net/kuber/demo-web@sha256:${"f".repeat(64)}`,
|
||||
references: {
|
||||
worker: `registry.neko-piranha.ts.net/kuber/demo-worker@sha256:${"f".repeat(64)}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("fails the shared job if an alias resolves to a different digest", async () => {
|
||||
const { controller, request, kubernetes } = await internalFixture(
|
||||
1024,
|
||||
async (image) =>
|
||||
`sha256:${(image.includes("worker") ? "e" : "f").repeat(64)}`,
|
||||
);
|
||||
request.destinations = [
|
||||
{ service: "worker", image: "registry.test/worker:latest" },
|
||||
];
|
||||
await controller.submitBuild(request);
|
||||
kubernetes.observation = { phase: "succeeded" };
|
||||
expect(await controller.reconcileBuild(request.id)).toMatchObject({
|
||||
state: "failed",
|
||||
error: expect.stringContaining("different digest"),
|
||||
});
|
||||
await expect(controller.getBuildResult(request.id)).rejects.toBeInstanceOf(
|
||||
BuildConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects duplicate alias destinations and unsafe exporter names", async () => {
|
||||
const { controller, request, kubernetes } = await fixture();
|
||||
request.destinations = [
|
||||
{ service: "web", image: "registry.test/demo/other:latest" },
|
||||
];
|
||||
await expect(controller.submitBuild(request)).rejects.toBeInstanceOf(
|
||||
BuildValidationError,
|
||||
);
|
||||
request.destinations = [
|
||||
{ service: "worker", image: "registry.test/demo/web:latest" },
|
||||
];
|
||||
await expect(controller.submitBuild(request)).rejects.toBeInstanceOf(
|
||||
BuildValidationError,
|
||||
);
|
||||
request.destinations = [
|
||||
{
|
||||
service: "worker",
|
||||
image: "registry.test/demo/worker:latest,push=false",
|
||||
},
|
||||
];
|
||||
await expect(controller.submitBuild(request)).rejects.toBeInstanceOf(
|
||||
BuildValidationError,
|
||||
);
|
||||
expect(kubernetes.jobs).toHaveLength(0);
|
||||
});
|
||||
test("negotiates snapshots and resumes verified blob uploads", async () => {
|
||||
const { controller, cas } = await fixture();
|
||||
const content = Buffer.from("resumable");
|
||||
@@ -365,6 +448,40 @@ describe("build controller", () => {
|
||||
expect(await controller.reconcileBuild(replacement.id)).toEqual(status);
|
||||
});
|
||||
|
||||
test("persists creating and starting phases without claiming the build has started", async () => {
|
||||
const { controller, kubernetes, request } = await fixture();
|
||||
expect(await controller.submitBuild(request)).toMatchObject({
|
||||
state: "queued",
|
||||
phase: "queued",
|
||||
});
|
||||
for (const phase of ["creating", "starting"] as const) {
|
||||
kubernetes.observation = { phase };
|
||||
expect(await controller.reconcileBuild(request.id)).toMatchObject({
|
||||
state: "queued",
|
||||
phase,
|
||||
});
|
||||
}
|
||||
kubernetes.observation = { phase: "running" };
|
||||
expect(await controller.reconcileBuild(request.id)).toMatchObject({
|
||||
state: "running",
|
||||
phase: "running",
|
||||
});
|
||||
kubernetes.observation = {
|
||||
phase: "failed",
|
||||
error: "buildkit crashed\nstack line",
|
||||
};
|
||||
expect(await controller.reconcileBuild(request.id)).toMatchObject({
|
||||
state: "failed",
|
||||
phase: "done",
|
||||
error: "buildkit crashed\nstack line",
|
||||
});
|
||||
expect(
|
||||
(await controller.getBuildEvents(request.id))
|
||||
.filter((event) => event.type === "status")
|
||||
.map((event) => event.type === "status" && event.status.phase),
|
||||
).toEqual(["queued", "creating", "starting", "running", "done"]);
|
||||
});
|
||||
|
||||
test("concurrent controllers converge on one same-ID record and Job", async () => {
|
||||
const first = await fixture();
|
||||
const second = new BuildController({
|
||||
|
||||
@@ -15,6 +15,20 @@ function spec(architecture: "arm64" | "amd64"): BuildSpec {
|
||||
}
|
||||
|
||||
describe("BuildKit Job generation", () => {
|
||||
test("quotes multiple image names as one BuildKit image exporter", () => {
|
||||
const job = createBuildJob({
|
||||
name: "build-multi",
|
||||
namespace: "default",
|
||||
spec: spec("amd64"),
|
||||
workspaceClaimName: "workspace",
|
||||
cacheImage: "registry.example.com/cache/demo-web",
|
||||
pushImage: "registry.example.com/kuber/demo-web:latest",
|
||||
pushImages: ["registry.example.com/kuber/demo-worker:latest"],
|
||||
});
|
||||
expect((job.spec as any).template.spec.containers[0].args).toContain(
|
||||
'--output=type=image,"name=registry.example.com/kuber/demo-web:latest,registry.example.com/kuber/demo-worker:latest",push=true',
|
||||
);
|
||||
});
|
||||
test.each(["arm64", "amd64"] as const)(
|
||||
"generates a rootless %s job",
|
||||
(architecture) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type BuildRecord,
|
||||
} from "../../server/build-store";
|
||||
import {
|
||||
KubernetesBuildOperations,
|
||||
KubernetesBuildStore,
|
||||
type BuildObjectApi,
|
||||
} from "../../server/build-kubernetes";
|
||||
@@ -19,6 +20,50 @@ const namespace = "kuber-test";
|
||||
const imageKey = "project\0service\0registry.test/app:latest";
|
||||
const workspace = `sha256:${"a".repeat(64)}` as Sha256Digest;
|
||||
|
||||
test("observes the build container lifecycle rather than treating an active Job as running", async () => {
|
||||
const job = {
|
||||
status: { active: 1, startTime: new Date("2026-01-01T00:00:00Z") },
|
||||
};
|
||||
let pods: any[] = [];
|
||||
const operations = new KubernetesBuildOperations(
|
||||
{ readNamespacedJob: async () => job } as any,
|
||||
{ listNamespacedPod: async () => ({ items: pods }) } as any,
|
||||
);
|
||||
expect((await operations.getJob("builds", "job"))?.phase).toBe("creating");
|
||||
pods = [{ spec: {}, status: { phase: "Pending" } }];
|
||||
expect((await operations.getJob("builds", "job"))?.phase).toBe("creating");
|
||||
pods = [
|
||||
{
|
||||
spec: { nodeName: "node" },
|
||||
status: {
|
||||
phase: "Pending",
|
||||
containerStatuses: [
|
||||
{
|
||||
name: "buildkit",
|
||||
state: { waiting: { reason: "ContainerCreating" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
expect((await operations.getJob("builds", "job"))?.phase).toBe("starting");
|
||||
pods[0].status.phase = "Running";
|
||||
expect((await operations.getJob("builds", "job"))?.phase).toBe("starting");
|
||||
pods[0].status.containerStatuses[0].state = {
|
||||
running: { startedAt: new Date() },
|
||||
};
|
||||
expect(await operations.getJob("builds", "job")).toMatchObject({
|
||||
phase: "running",
|
||||
});
|
||||
(job.status as any).conditions = [
|
||||
{ type: "Failed", status: "True", message: "crashed" },
|
||||
];
|
||||
expect(await operations.getJob("builds", "job")).toMatchObject({
|
||||
phase: "failed",
|
||||
error: "crashed",
|
||||
});
|
||||
});
|
||||
|
||||
function validLabelValue(value: string): boolean {
|
||||
return (
|
||||
value.length <= 63 &&
|
||||
@@ -221,11 +266,12 @@ describe("KubernetesBuildStore", () => {
|
||||
new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString(),
|
||||
);
|
||||
record.status.state = i === 0 ? "queued" : "failed";
|
||||
if (i === 1) record.status.reconcileLease = {
|
||||
holder: "worker",
|
||||
token: "active-lease",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
};
|
||||
if (i === 1)
|
||||
record.status.reconcileLease = {
|
||||
holder: "worker",
|
||||
token: "active-lease",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
};
|
||||
fake.maps.set(name("build", record.metadata.name), configMap(record));
|
||||
}
|
||||
const old = build(
|
||||
@@ -253,7 +299,9 @@ describe("KubernetesBuildStore", () => {
|
||||
expect(fake.maps.has(name("build", "protected-lock"))).toBe(true);
|
||||
expect(fake.maps.has(name("build", "history-2"))).toBe(false);
|
||||
expect(
|
||||
[...fake.maps.values()].filter((value) => value.metadata.labels?.["kuber.astrxl.dev/type"] === "build"),
|
||||
[...fake.maps.values()].filter(
|
||||
(value) => value.metadata.labels?.["kuber.astrxl.dev/type"] === "build",
|
||||
),
|
||||
).toHaveLength(50);
|
||||
expect(fake.deletes.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
|
||||
@@ -26,7 +26,7 @@ function encode(value: string): string {
|
||||
}
|
||||
|
||||
function secret(
|
||||
recordType: "user" | "session",
|
||||
recordType: "user" | "session" | "api-key",
|
||||
name: string,
|
||||
values: Record<string, string>,
|
||||
): StoredSecret {
|
||||
@@ -111,6 +111,65 @@ function setup(): {
|
||||
}
|
||||
|
||||
describe("KubernetesAuthStore", () => {
|
||||
test("persists absent expiry, reads finite legacy keys, and rejects malformed expiries", async () => {
|
||||
const { fake, store } = setup();
|
||||
await store.putUser({
|
||||
username: "alice",
|
||||
passwordHash: "hash",
|
||||
roles: ["viewer"],
|
||||
});
|
||||
const never = {
|
||||
id: "never-expiring-key-123",
|
||||
tokenHash: hashToken("never"),
|
||||
username: "alice",
|
||||
capabilities: ["kubernetes:read" as const],
|
||||
};
|
||||
await store.createApiKey(never);
|
||||
expect(fake.patches.at(-1)?.stringData).not.toHaveProperty("expiresAt");
|
||||
expect(await store.getApiKey(never.tokenHash)).toMatchObject(never);
|
||||
const finite = {
|
||||
...never,
|
||||
id: "finite-expiry-key-123",
|
||||
tokenHash: hashToken("finite"),
|
||||
expiresAt: "2020-01-01T00:00:00.000Z",
|
||||
};
|
||||
await store.createApiKey(finite);
|
||||
expect(
|
||||
(await store.listApiKeys("alice")).map((key) => key.expiresAt),
|
||||
).toEqual([finite.expiresAt, undefined]);
|
||||
expect(await store.deleteExpiredApiKeys()).toBe(1);
|
||||
expect(await store.getApiKey(never.tokenHash)).toMatchObject(never);
|
||||
const active = {
|
||||
...finite,
|
||||
id: "active-finite-key-123",
|
||||
tokenHash: hashToken("active-finite"),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
};
|
||||
await store.createApiKey(active);
|
||||
expect(await store.getApiKey(active.tokenHash)).toMatchObject(active);
|
||||
const name = objectName("api-key", never.tokenHash);
|
||||
for (const expiry of ["none", "", "2026-09-03"]) {
|
||||
fake.secrets.set(
|
||||
name,
|
||||
secret("api-key", name, {
|
||||
id: never.id,
|
||||
tokenHash: never.tokenHash,
|
||||
username: never.username,
|
||||
capabilities: JSON.stringify(never.capabilities),
|
||||
workspace: "",
|
||||
disabled: "false",
|
||||
expiresAt: expiry,
|
||||
}),
|
||||
);
|
||||
expect(await store.getApiKey(never.tokenHash)).toBeUndefined();
|
||||
}
|
||||
expect(await store.revokeApiKey("bob", finite.id)).toBe(false);
|
||||
await expect(
|
||||
store.createApiKey({ ...never, username: "missing" }),
|
||||
).rejects.toThrow("not active");
|
||||
await store.updateUser("alice", { disabled: true });
|
||||
await expect(store.createApiKey(never)).rejects.toThrow("not active");
|
||||
});
|
||||
test("validates the session and user from the store on every request", async () => {
|
||||
const { fake, store } = setup();
|
||||
const tokenHash = hashToken("fresh");
|
||||
|
||||
@@ -25,6 +25,59 @@ describe("OCI registry digest resolution", () => {
|
||||
).toThrow("Invalid image reference");
|
||||
});
|
||||
|
||||
test("accepts distribution tags and digest-pinned references", () => {
|
||||
for (const tag of [
|
||||
"latest",
|
||||
"v1.2.3",
|
||||
"Release_2026-10",
|
||||
"_build",
|
||||
"a".repeat(128),
|
||||
]) {
|
||||
expect(parseImageReference(`localhost:5000/team/my_image:${tag}`)).toEqual({
|
||||
registry: "localhost:5000",
|
||||
repository: "team/my_image",
|
||||
reference: tag,
|
||||
});
|
||||
}
|
||||
const digest = `sha256:${"a".repeat(64)}` as const;
|
||||
expect(
|
||||
parseImageReference(`registry.example.com/team/my-image:Release_1@${digest}`),
|
||||
).toEqual({
|
||||
registry: "registry.example.com",
|
||||
repository: "team/my-image",
|
||||
reference: digest,
|
||||
digest,
|
||||
});
|
||||
expect(
|
||||
parseImageReference(`registry.example.com/team/my-image@${digest}`).digest,
|
||||
).toBe(digest);
|
||||
});
|
||||
|
||||
test("rejects malformed tags and uppercase repository names", () => {
|
||||
for (const tag of [
|
||||
"bad+tag",
|
||||
".leading",
|
||||
"-leading",
|
||||
"bad:tag",
|
||||
"bad@tag",
|
||||
"bad/tag",
|
||||
"é",
|
||||
"a".repeat(129),
|
||||
"",
|
||||
]) {
|
||||
expect(() =>
|
||||
parseImageReference(`registry.example.com/team/image:${tag}`),
|
||||
).toThrow();
|
||||
}
|
||||
expect(() =>
|
||||
parseImageReference("registry.example.com/Team/image:Release_1"),
|
||||
).toThrow("Invalid image reference");
|
||||
const digest = `sha256:${"a".repeat(64)}` as const;
|
||||
expect(() =>
|
||||
parseImageReference(`registry.example.com/team/image:bad+tag@${digest}`),
|
||||
).toThrow("Invalid image reference");
|
||||
});
|
||||
|
||||
test("resolves an anonymous manifest using the advertised digest", async () => {
|
||||
const expected = `sha256:${"b".repeat(64)}` as const;
|
||||
const calls: Array<{ url: string; authorization: string | null }> = [];
|
||||
|
||||
Reference in New Issue
Block a user