diff --git a/command/main.ts b/command/main.ts index 25ec5a1..487434e 100644 --- a/command/main.ts +++ b/command/main.ts @@ -22,7 +22,7 @@ import { trust } from "./trust"; export const main = defineCommand({ meta: { name: "kuber", - version: "2.3.3", + version: "2.3.4", description: "Docker Compose -> K8s translation layer", }, args: { diff --git a/package.json b/package.json index a5b69c8..a9060bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dmgnr/kuber", - "version": "2.3.3", + "version": "2.3.4", "description": "Docker Compose to Kubernetes translation layer", "bin": { "kuber": "dist/index.js" diff --git a/server/app.ts b/server/app.ts index 90c0710..34fe039 100644 --- a/server/app.ts +++ b/server/app.ts @@ -64,7 +64,7 @@ import { type Workspace, type WorkspaceStore, } from "./workspace-store"; -import { REDACTED, redactString } from "./redact"; +import { redactString } from "./redact"; const API_PREFIX = "/api/v2"; const RUNTIME_SESSION_MS = 24 * 60 * 60 * 1000; @@ -121,6 +121,7 @@ export type UnknownFailureLog = { code: string; errorName: string; message: string; + stack?: string; kubernetesStatus?: { status?: string; reason?: string; code?: number }; }; @@ -334,8 +335,10 @@ export function createApp( ? error.body : undefined; if (!candidate) return; - const status = typeof candidate.status === "string" ? REDACTED : undefined; - const reason = typeof candidate.reason === "string" ? REDACTED : undefined; + const status = + typeof candidate.status === "string" ? candidate.status : undefined; + const reason = + typeof candidate.reason === "string" ? candidate.reason : undefined; const code = typeof candidate.code === "number" ? candidate.code : undefined; return status || reason || code !== undefined @@ -343,6 +346,51 @@ export function createApp( : undefined; } + function kubernetesError( + error: unknown, + ): { status: number; detail: string } | undefined { + if (!isRecord(error) || typeof error.statusCode !== "number") return; + const status = error.statusCode; + if (!Number.isInteger(status) || status < 100 || status > 599) return; + const body = isRecord(error.body) ? error.body : undefined; + if ( + !body || + body.kind !== "Status" || + body.apiVersion !== "v1" || + body.status !== "Failure" || + typeof body.reason !== "string" || + typeof body.message !== "string" || + !Number.isInteger(body.code) || + body.code !== status + ) + return; + return { status, detail: body.message }; + } + + function errorDiagnostics(error: unknown): { + errorName: string; + message: string; + stack?: string; + } { + if (error instanceof Error) + return { + errorName: error.name, + message: error.message, + ...(error.stack && { stack: error.stack }), + }; + if (isRecord(error)) + return { + errorName: typeof error.name === "string" ? error.name : "UnknownError", + message: + typeof error.message === "string" ? error.message : "Unknown error", + ...(typeof error.stack === "string" && { stack: error.stack }), + }; + return { + errorName: "UnknownError", + message: typeof error === "string" ? error : "Unknown error", + }; + } + function logRequestError( request: Request, error: unknown, @@ -358,6 +406,9 @@ export function createApp( }, ): void { const pathname = new URL(request.url).pathname; + const diagnostics = errorDiagnostics(error); + const unknownFailure = + event === "request.failed" && code === "INTERNAL_ERROR"; logger.error({ event, requestId: requestIds.get(request) ?? makeRequestId(), @@ -367,11 +418,13 @@ export function createApp( ...(operationId && { operationId }), status: 500, code, - errorName: error instanceof Error ? error.name : "UnknownError", - message: redactString(message), - ...(kubernetesStatus(error) && { - kubernetesStatus: kubernetesStatus(error), - }), + errorName: diagnostics.errorName, + message: unknownFailure ? diagnostics.message : redactString(message), + ...(unknownFailure && diagnostics.stack && { stack: diagnostics.stack }), + ...(unknownFailure && + kubernetesStatus(error) && { + kubernetesStatus: kubernetesStatus(error), + }), }); } @@ -1781,10 +1834,36 @@ export function createApp( const workspace = await getWorkspace(id); if (subpath === "adopt" && request.method === "POST") { await requireCapability(identity, request, "kubernetes:write"); - const adopted = await requireAdoption().adopt( - workspace.metadata.name, - workspace.metadata.uid, - ); + let adopted: WorkspaceAdoptionResult; + try { + adopted = await requireAdoption().adopt( + workspace.metadata.name, + workspace.metadata.uid, + ); + } catch (error) { + try { + await audit( + identity, + request, + "workspace.adopt", + "failure", + { + route: path, + requestId: requestIds.get(request) ?? makeRequestId(), + }, + id, + ); + } catch (auditError) { + logRequestError(request, auditError, { + event: "workspace.adopt.audit.failed", + code: "AUDIT_APPEND_FAILED", + message: + "Failed workspace adoption audit event could not be appended", + workspaceId: id, + }); + } + throw error; + } await audit( identity, request, @@ -2202,6 +2281,14 @@ export function createApp( function normalizeError(error: unknown): HttpError { if (error instanceof HttpError) return error; + const kubernetes = kubernetesError(error); + if (kubernetes) + return new HttpError( + kubernetes.status, + "Kubernetes error", + "KUBERNETES_ERROR", + kubernetes.detail, + ); if (error instanceof WorkspaceNotFoundError) return new HttpError(404, "Not found", error.code, error.message); if (error instanceof OperationNotFoundError) diff --git a/server/kubernetes-state.ts b/server/kubernetes-state.ts index 17941a6..1bd53e8 100644 --- a/server/kubernetes-state.ts +++ b/server/kubernetes-state.ts @@ -799,7 +799,7 @@ export class KubernetesWorkspaceAdoptionService implements WorkspaceAdoptionServ } } - const labels = { + const adoptionLabels = { ...LABELS, [WORKSPACE_PROJECT_LABEL]: workspaceId, [WORKSPACE_UID_LABEL]: workspaceUid, @@ -808,13 +808,16 @@ export class KubernetesWorkspaceAdoptionService implements WorkspaceAdoptionServ { apiVersion: "v1", kind: "Namespace", - metadata: { name: workspaceId, labels }, + metadata: { + name: workspaceId, + labels: { ...namespace.metadata?.labels, ...adoptionLabels }, + }, }, undefined, undefined, - FIELD_MANAGER, - false, - PatchStrategy.ServerSideApply, + undefined, + undefined, + PatchStrategy.MergePatch, ); for (const { apiVersion, kind, item } of resources) { await this.objects.patch( @@ -824,14 +827,14 @@ export class KubernetesWorkspaceAdoptionService implements WorkspaceAdoptionServ metadata: { name: item.metadata?.name, namespace: workspaceId, - labels, + labels: { ...item.metadata?.labels, ...adoptionLabels }, }, }, undefined, undefined, - FIELD_MANAGER, - false, - PatchStrategy.ServerSideApply, + undefined, + undefined, + PatchStrategy.MergePatch, ); } return { diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index 409e690..f880a2b 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -681,7 +681,7 @@ describe("kuber v2 HTTP routes", () => { expect(await operationStore.list("demo")).toHaveLength(1); }); - test("redacts provider status and reason in correlated generic failure logs", async () => { + test("logs raw diagnostics for correlated generic failures", async () => { const workspaceStore = new MemoryWorkspaceStore({ uid: () => "workspace-uid", }); @@ -690,22 +690,23 @@ describe("kuber v2 HTTP routes", () => { source: { uri: "oci://example/demo", digest: "sha256:abc" }, }); const logs: unknown[] = []; - spyOn(workspaceStore, "update").mockRejectedValue( - Object.assign( - new Error( - 'provider failed password=top-secret config={"compose":"private"}', - ), - { - name: "KubernetesError", - body: { - status: "Failure", - reason: "InternalError", - code: 500, - message: "contains top-secret", - }, - }, + const providerError = Object.assign( + new Error( + 'provider failed password=top-secret config={"compose":"private"}', ), + { + name: "KubernetesError", + stack: + "KubernetesError: provider failed\n at provider (test.ts:1:1)", + body: { + status: "Failure", + reason: "InternalError", + code: 500, + message: "contains top-secret", + }, + }, ); + spyOn(workspaceStore, "update").mockRejectedValue(providerError); const app = createApp({ store: await authenticatedStore("operator"), workspaceStore, @@ -732,6 +733,7 @@ describe("kuber v2 HTTP routes", () => { expect(await result.json()).toMatchObject({ code: "INTERNAL_ERROR", requestId: "request-123", + detail: "The request could not be completed", }); expect(logs).toEqual([ { @@ -743,19 +745,17 @@ describe("kuber v2 HTTP routes", () => { status: 500, code: "INTERNAL_ERROR", errorName: "KubernetesError", - message: "The request could not be completed", + message: + 'provider failed password=top-secret config={"compose":"private"}', + stack: + "KubernetesError: provider failed\n at provider (test.ts:1:1)", kubernetesStatus: { - status: "[REDACTED]", - reason: "[REDACTED]", + status: "Failure", + reason: "InternalError", code: 500, }, }, ]); - expect(JSON.stringify(logs)).not.toContain("top-secret"); - expect(JSON.stringify(logs)).not.toContain("private-config"); - expect(JSON.stringify(logs)).not.toContain("secret-source"); - expect(JSON.stringify(logs)).not.toContain('"Failure"'); - expect(JSON.stringify(logs)).not.toContain('"InternalError"'); }); test("lists persisted operation events with capability and workspace scope checks", async () => { @@ -1512,4 +1512,155 @@ describe("kuber v2 HTTP routes", () => { ); expect(platform.status).toBe(200); }); + + test("audits failed workspace adoption with request correlation", async () => { + const workspaceStore = new MemoryWorkspaceStore({ + uid: () => "workspace-uid", + }); + await workspaceStore.create({ + id: "demo", + source: { uri: "oci://example/demo", digest: "sha256:abc" }, + }); + const auditStore = new MemoryAuditStore(); + const logs: unknown[] = []; + const app = createApp({ + store: await authenticatedStore("operator"), + workspaceStore, + auditStore, + requestId: () => "adopt-request-123", + logger: { error: (entry) => logs.push(entry) }, + adoption: { + adopt: async () => { + throw new Error("provider adoption failed"); + }, + adoptPlatform: async () => ({ + workspaceId: "kuber-system", + workspaceUid: "platform", + resourcesAdopted: 0, + }), + }, + }); + + const result = await app( + request("/api/v2/workspaces/demo/adopt", { method: "POST" }, "token"), + ); + + expect(result.status).toBe(500); + expect(await result.json()).toMatchObject({ + code: "INTERNAL_ERROR", + detail: "The request could not be completed", + requestId: "adopt-request-123", + }); + expect(await auditStore.list("demo")).toMatchObject([ + { + spec: { + action: "workspace.adopt", + outcome: "failure", + workspaceId: "demo", + details: { + route: "/api/v2/workspaces/demo/adopt", + requestId: "adopt-request-123", + }, + }, + }, + ]); + expect(logs).toMatchObject([ + { + event: "request.failed", + requestId: "adopt-request-123", + method: "POST", + pathname: "/api/v2/workspaces/demo/adopt", + workspaceId: "demo", + status: 500, + code: "INTERNAL_ERROR", + errorName: "Error", + message: "provider adoption failed", + }, + ]); + }); + + test("forwards Kubernetes adoption errors with request correlation", async () => { + const workspaceStore = new MemoryWorkspaceStore({ + uid: () => "workspace-uid", + }); + await workspaceStore.create({ + id: "demo", + source: { uri: "oci://example/demo", digest: "sha256:abc" }, + }); + const app = createApp({ + store: await authenticatedStore("operator"), + workspaceStore, + requestId: () => "adopt-kubernetes-request-123", + adoption: { + adopt: async () => { + throw { + statusCode: 422, + body: { + apiVersion: "v1", + kind: "Status", + status: "Failure", + reason: "Invalid", + message: + 'Ingress.networking.k8s.io "web" is invalid: spec: Required value', + code: 422, + }, + }; + }, + adoptPlatform: async () => ({ + workspaceId: "kuber-system", + workspaceUid: "platform", + resourcesAdopted: 0, + }), + }, + }); + + const result = await app( + request("/api/v2/workspaces/demo/adopt", { method: "POST" }, "token"), + ); + + expect(result.status).toBe(422); + expect(await result.json()).toMatchObject({ + code: "KUBERNETES_ERROR", + detail: + 'Ingress.networking.k8s.io "web" is invalid: spec: Required value', + requestId: "adopt-kubernetes-request-123", + }); + }); + + test("does not forward arbitrary adoption errors as Kubernetes errors", async () => { + const workspaceStore = new MemoryWorkspaceStore({ + uid: () => "workspace-uid", + }); + await workspaceStore.create({ + id: "demo", + source: { uri: "oci://example/demo", digest: "sha256:abc" }, + }); + const app = createApp({ + store: await authenticatedStore("operator"), + workspaceStore, + adoption: { + adopt: async () => { + throw { + statusCode: 422, + body: { message: "arbitrary provider failure" }, + }; + }, + adoptPlatform: async () => ({ + workspaceId: "kuber-system", + workspaceUid: "platform", + resourcesAdopted: 0, + }), + }, + }); + + const result = await app( + request("/api/v2/workspaces/demo/adopt", { method: "POST" }, "token"), + ); + + expect(result.status).toBe(500); + expect(await result.json()).toMatchObject({ + code: "INTERNAL_ERROR", + detail: "The request could not be completed", + }); + }); }); diff --git a/tests/server/kubernetes-state.test.ts b/tests/server/kubernetes-state.test.ts index feb9fdf..5b6ef05 100644 --- a/tests/server/kubernetes-state.test.ts +++ b/tests/server/kubernetes-state.test.ts @@ -22,16 +22,25 @@ import type { Workspace, WorkspaceRevision, } from "../../server/workspace-store"; -import { WORKSPACE_UID_LABEL } from "../../server/management"; +import { + WORKSPACE_PROJECT_LABEL, + WORKSPACE_UID_LABEL, +} from "../../server/management"; type DataObject = KubernetesObject & { data?: Record; stringData?: Record; + spec?: unknown; }; class FakeObjects { readonly objects = new Map(); readonly patches: KubernetesObject[] = []; + readonly patchOptions: Array<{ + fieldManager?: string; + force?: boolean; + strategy?: string; + }> = []; key(value: KubernetesObject) { return `${value.kind}:${value.metadata?.namespace ?? ""}:${value.metadata?.name}`; @@ -105,8 +114,16 @@ class FakeObjects { }; } - async patch(value: KubernetesObject) { + async patch( + value: KubernetesObject, + _pretty?: string, + _dryRun?: string, + fieldManager?: string, + force?: boolean, + strategy?: string, + ) { this.patches.push(structuredClone(value)); + this.patchOptions.push({ fieldManager, force, strategy }); return value; } } @@ -352,6 +369,97 @@ describe("Kubernetes state persistence", () => { metadata: { name: "managed", namespace: "demo" }, }); }); + + test("adoption merge-patches existing namespace and resource labels without affecting spec", async () => { + const fake = new FakeObjects(); + const spec = { + ingressClassName: "nginx", + rules: [ + { + host: "demo.example.test", + http: { + paths: [ + { + path: "/", + pathType: "Prefix", + backend: { + service: { name: "web", port: { number: 80 } }, + }, + }, + ], + }, + }, + ], + }; + fake.objects.set("Namespace::demo", { + apiVersion: "v1", + kind: "Namespace", + metadata: { + name: "demo", + labels: { + "app.kubernetes.io/managed-by": "kuber", + "example.test/namespace-label": "preserve-me", + }, + }, + }); + fake.objects.set("Ingress:demo:web", { + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { + name: "web", + namespace: "demo", + labels: { + "app.kubernetes.io/managed-by": "kuber", + "example.test/resource-label": "preserve-me", + }, + }, + spec, + }); + + const adoption = new KubernetesWorkspaceAdoptionService( + fake as unknown as KubernetesObjectApi, + ); + await adoption.adopt("demo", "workspace-uid"); + + const namespacePatch = fake.patches.find( + (patch) => patch.kind === "Namespace", + ); + const ingressPatch = fake.patches.find((patch) => patch.kind === "Ingress"); + expect(namespacePatch).toMatchObject({ + apiVersion: "v1", + kind: "Namespace", + metadata: { + name: "demo", + labels: { + "app.kubernetes.io/managed-by": "kuber", + "example.test/namespace-label": "preserve-me", + [WORKSPACE_PROJECT_LABEL]: "demo", + [WORKSPACE_UID_LABEL]: "workspace-uid", + }, + }, + }); + expect(namespacePatch).not.toHaveProperty("spec"); + expect(ingressPatch).toMatchObject({ + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { + name: "web", + namespace: "demo", + labels: { + "app.kubernetes.io/managed-by": "kuber", + "example.test/resource-label": "preserve-me", + [WORKSPACE_PROJECT_LABEL]: "demo", + [WORKSPACE_UID_LABEL]: "workspace-uid", + }, + }, + }); + expect(ingressPatch).not.toHaveProperty("spec"); + expect(fake.objects.get("Ingress:demo:web")?.spec).toEqual(spec); + expect(fake.patchOptions).toEqual([ + { strategy: "application/merge-patch+json" }, + { strategy: "application/merge-patch+json" }, + ]); + }); }); class FakeLeaseStore implements LeaseObjects {