fix: preserve adopted resource specs

This commit is contained in:
2026-09-07 03:54:19 +00:00 Unverified
parent f14e976bcd
commit 0067a2cdcd
6 changed files with 397 additions and 48 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ import { trust } from "./trust";
export const main = defineCommand({ export const main = defineCommand({
meta: { meta: {
name: "kuber", name: "kuber",
version: "2.3.3", version: "2.3.4",
description: "Docker Compose -> K8s translation layer", description: "Docker Compose -> K8s translation layer",
}, },
args: { args: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dmgnr/kuber", "name": "@dmgnr/kuber",
"version": "2.3.3", "version": "2.3.4",
"description": "Docker Compose to Kubernetes translation layer", "description": "Docker Compose to Kubernetes translation layer",
"bin": { "bin": {
"kuber": "dist/index.js" "kuber": "dist/index.js"
+94 -7
View File
@@ -64,7 +64,7 @@ import {
type Workspace, type Workspace,
type WorkspaceStore, type WorkspaceStore,
} from "./workspace-store"; } from "./workspace-store";
import { REDACTED, redactString } from "./redact"; import { redactString } from "./redact";
const API_PREFIX = "/api/v2"; const API_PREFIX = "/api/v2";
const RUNTIME_SESSION_MS = 24 * 60 * 60 * 1000; const RUNTIME_SESSION_MS = 24 * 60 * 60 * 1000;
@@ -121,6 +121,7 @@ export type UnknownFailureLog = {
code: string; code: string;
errorName: string; errorName: string;
message: string; message: string;
stack?: string;
kubernetesStatus?: { status?: string; reason?: string; code?: number }; kubernetesStatus?: { status?: string; reason?: string; code?: number };
}; };
@@ -334,8 +335,10 @@ export function createApp(
? error.body ? error.body
: undefined; : undefined;
if (!candidate) return; if (!candidate) return;
const status = typeof candidate.status === "string" ? REDACTED : undefined; const status =
const reason = typeof candidate.reason === "string" ? REDACTED : undefined; typeof candidate.status === "string" ? candidate.status : undefined;
const reason =
typeof candidate.reason === "string" ? candidate.reason : undefined;
const code = const code =
typeof candidate.code === "number" ? candidate.code : undefined; typeof candidate.code === "number" ? candidate.code : undefined;
return status || reason || code !== undefined return status || reason || code !== undefined
@@ -343,6 +346,51 @@ export function createApp(
: undefined; : 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( function logRequestError(
request: Request, request: Request,
error: unknown, error: unknown,
@@ -358,6 +406,9 @@ export function createApp(
}, },
): void { ): void {
const pathname = new URL(request.url).pathname; const pathname = new URL(request.url).pathname;
const diagnostics = errorDiagnostics(error);
const unknownFailure =
event === "request.failed" && code === "INTERNAL_ERROR";
logger.error({ logger.error({
event, event,
requestId: requestIds.get(request) ?? makeRequestId(), requestId: requestIds.get(request) ?? makeRequestId(),
@@ -367,9 +418,11 @@ export function createApp(
...(operationId && { operationId }), ...(operationId && { operationId }),
status: 500, status: 500,
code, code,
errorName: error instanceof Error ? error.name : "UnknownError", errorName: diagnostics.errorName,
message: redactString(message), message: unknownFailure ? diagnostics.message : redactString(message),
...(kubernetesStatus(error) && { ...(unknownFailure && diagnostics.stack && { stack: diagnostics.stack }),
...(unknownFailure &&
kubernetesStatus(error) && {
kubernetesStatus: kubernetesStatus(error), kubernetesStatus: kubernetesStatus(error),
}), }),
}); });
@@ -1781,10 +1834,36 @@ export function createApp(
const workspace = await getWorkspace(id); const workspace = await getWorkspace(id);
if (subpath === "adopt" && request.method === "POST") { if (subpath === "adopt" && request.method === "POST") {
await requireCapability(identity, request, "kubernetes:write"); await requireCapability(identity, request, "kubernetes:write");
const adopted = await requireAdoption().adopt( let adopted: WorkspaceAdoptionResult;
try {
adopted = await requireAdoption().adopt(
workspace.metadata.name, workspace.metadata.name,
workspace.metadata.uid, 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( await audit(
identity, identity,
request, request,
@@ -2202,6 +2281,14 @@ export function createApp(
function normalizeError(error: unknown): HttpError { function normalizeError(error: unknown): HttpError {
if (error instanceof HttpError) return error; 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) if (error instanceof WorkspaceNotFoundError)
return new HttpError(404, "Not found", error.code, error.message); return new HttpError(404, "Not found", error.code, error.message);
if (error instanceof OperationNotFoundError) if (error instanceof OperationNotFoundError)
+12 -9
View File
@@ -799,7 +799,7 @@ export class KubernetesWorkspaceAdoptionService implements WorkspaceAdoptionServ
} }
} }
const labels = { const adoptionLabels = {
...LABELS, ...LABELS,
[WORKSPACE_PROJECT_LABEL]: workspaceId, [WORKSPACE_PROJECT_LABEL]: workspaceId,
[WORKSPACE_UID_LABEL]: workspaceUid, [WORKSPACE_UID_LABEL]: workspaceUid,
@@ -808,13 +808,16 @@ export class KubernetesWorkspaceAdoptionService implements WorkspaceAdoptionServ
{ {
apiVersion: "v1", apiVersion: "v1",
kind: "Namespace", kind: "Namespace",
metadata: { name: workspaceId, labels }, metadata: {
name: workspaceId,
labels: { ...namespace.metadata?.labels, ...adoptionLabels },
},
}, },
undefined, undefined,
undefined, undefined,
FIELD_MANAGER, undefined,
false, undefined,
PatchStrategy.ServerSideApply, PatchStrategy.MergePatch,
); );
for (const { apiVersion, kind, item } of resources) { for (const { apiVersion, kind, item } of resources) {
await this.objects.patch( await this.objects.patch(
@@ -824,14 +827,14 @@ export class KubernetesWorkspaceAdoptionService implements WorkspaceAdoptionServ
metadata: { metadata: {
name: item.metadata?.name, name: item.metadata?.name,
namespace: workspaceId, namespace: workspaceId,
labels, labels: { ...item.metadata?.labels, ...adoptionLabels },
}, },
}, },
undefined, undefined,
undefined, undefined,
FIELD_MANAGER, undefined,
false, undefined,
PatchStrategy.ServerSideApply, PatchStrategy.MergePatch,
); );
} }
return { return {
+163 -12
View File
@@ -681,7 +681,7 @@ describe("kuber v2 HTTP routes", () => {
expect(await operationStore.list("demo")).toHaveLength(1); 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({ const workspaceStore = new MemoryWorkspaceStore({
uid: () => "workspace-uid", uid: () => "workspace-uid",
}); });
@@ -690,13 +690,14 @@ describe("kuber v2 HTTP routes", () => {
source: { uri: "oci://example/demo", digest: "sha256:abc" }, source: { uri: "oci://example/demo", digest: "sha256:abc" },
}); });
const logs: unknown[] = []; const logs: unknown[] = [];
spyOn(workspaceStore, "update").mockRejectedValue( const providerError = Object.assign(
Object.assign(
new Error( new Error(
'provider failed password=top-secret config={"compose":"private"}', 'provider failed password=top-secret config={"compose":"private"}',
), ),
{ {
name: "KubernetesError", name: "KubernetesError",
stack:
"KubernetesError: provider failed\n at provider (test.ts:1:1)",
body: { body: {
status: "Failure", status: "Failure",
reason: "InternalError", reason: "InternalError",
@@ -704,8 +705,8 @@ describe("kuber v2 HTTP routes", () => {
message: "contains top-secret", message: "contains top-secret",
}, },
}, },
),
); );
spyOn(workspaceStore, "update").mockRejectedValue(providerError);
const app = createApp({ const app = createApp({
store: await authenticatedStore("operator"), store: await authenticatedStore("operator"),
workspaceStore, workspaceStore,
@@ -732,6 +733,7 @@ describe("kuber v2 HTTP routes", () => {
expect(await result.json()).toMatchObject({ expect(await result.json()).toMatchObject({
code: "INTERNAL_ERROR", code: "INTERNAL_ERROR",
requestId: "request-123", requestId: "request-123",
detail: "The request could not be completed",
}); });
expect(logs).toEqual([ expect(logs).toEqual([
{ {
@@ -743,19 +745,17 @@ describe("kuber v2 HTTP routes", () => {
status: 500, status: 500,
code: "INTERNAL_ERROR", code: "INTERNAL_ERROR",
errorName: "KubernetesError", 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: { kubernetesStatus: {
status: "[REDACTED]", status: "Failure",
reason: "[REDACTED]", reason: "InternalError",
code: 500, 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 () => { 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); 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",
});
});
}); });
+110 -2
View File
@@ -22,16 +22,25 @@ import type {
Workspace, Workspace,
WorkspaceRevision, WorkspaceRevision,
} from "../../server/workspace-store"; } from "../../server/workspace-store";
import { WORKSPACE_UID_LABEL } from "../../server/management"; import {
WORKSPACE_PROJECT_LABEL,
WORKSPACE_UID_LABEL,
} from "../../server/management";
type DataObject = KubernetesObject & { type DataObject = KubernetesObject & {
data?: Record<string, string>; data?: Record<string, string>;
stringData?: Record<string, string>; stringData?: Record<string, string>;
spec?: unknown;
}; };
class FakeObjects { class FakeObjects {
readonly objects = new Map<string, DataObject>(); readonly objects = new Map<string, DataObject>();
readonly patches: KubernetesObject[] = []; readonly patches: KubernetesObject[] = [];
readonly patchOptions: Array<{
fieldManager?: string;
force?: boolean;
strategy?: string;
}> = [];
key(value: KubernetesObject) { key(value: KubernetesObject) {
return `${value.kind}:${value.metadata?.namespace ?? ""}:${value.metadata?.name}`; 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.patches.push(structuredClone(value));
this.patchOptions.push({ fieldManager, force, strategy });
return value; return value;
} }
} }
@@ -352,6 +369,97 @@ describe("Kubernetes state persistence", () => {
metadata: { name: "managed", namespace: "demo" }, 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 { class FakeLeaseStore implements LeaseObjects {