fix: preserve adopted resource specs
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user