fix: preserve adopted resource specs
This commit is contained in:
+174
-23
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string>;
|
||||
stringData?: Record<string, string>;
|
||||
spec?: unknown;
|
||||
};
|
||||
|
||||
class FakeObjects {
|
||||
readonly objects = new Map<string, DataObject>();
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user