diff --git a/packages/plugins/sandbox-providers/kubernetes/src/tenant-orchestrator.ts b/packages/plugins/sandbox-providers/kubernetes/src/tenant-orchestrator.ts index 072d0ed2..29a3fc40 100644 --- a/packages/plugins/sandbox-providers/kubernetes/src/tenant-orchestrator.ts +++ b/packages/plugins/sandbox-providers/kubernetes/src/tenant-orchestrator.ts @@ -41,34 +41,48 @@ export async function ensureTenant(clients: KubeClients, input: EnsureTenantInpu } async function ensureNamespace(clients: KubeClients, input: EnsureTenantInput): Promise { + const manifest = buildNamespaceManifest(input); try { - await clients.core.readNamespace({ name: input.namespace }); + const existing = await clients.core.readNamespace({ name: input.namespace }); + await clients.core.replaceNamespace({ + name: input.namespace, + body: withResourceVersion(buildNamespaceManifest(input, existing), existing) as never, + }); return; } catch (err) { if (!isNotFound(err)) throw err; } try { - await clients.core.createNamespace({ - body: { - apiVersion: "v1", - kind: "Namespace", - metadata: { - name: input.namespace, - labels: { - "paperclip.io/company-id": input.companyId, - "paperclip.io/managed-by": "paperclip-k8s-plugin", - "pod-security.kubernetes.io/enforce": "restricted", - "pod-security.kubernetes.io/audit": "restricted", - "pod-security.kubernetes.io/warn": "restricted", - }, - }, - }, - }); + await clients.core.createNamespace({ body: manifest }); } catch (err) { if (!isAlreadyExists(err)) throw err; + const existing = await clients.core.readNamespace({ name: input.namespace }); + await clients.core.replaceNamespace({ + name: input.namespace, + body: withResourceVersion(buildNamespaceManifest(input, existing), existing) as never, + }); } } +function buildNamespaceManifest(input: EnsureTenantInput, existing?: unknown): Record { + const existingLabels = (existing as { metadata?: { labels?: Record } })?.metadata?.labels ?? {}; + return { + apiVersion: "v1", + kind: "Namespace", + metadata: { + name: input.namespace, + labels: { + ...existingLabels, + "paperclip.io/company-id": input.companyId, + "paperclip.io/managed-by": "paperclip-k8s-plugin", + "pod-security.kubernetes.io/enforce": "restricted", + "pod-security.kubernetes.io/audit": "restricted", + "pod-security.kubernetes.io/warn": "restricted", + }, + }, + }; +} + async function ensureServiceAccount(clients: KubeClients, input: EnsureTenantInput): Promise { const manifest = { apiVersion: "v1", diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/tenant-orchestrator.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/tenant-orchestrator.test.ts index 6b412c76..4696fa1a 100644 --- a/packages/plugins/sandbox-providers/kubernetes/test/unit/tenant-orchestrator.test.ts +++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/tenant-orchestrator.test.ts @@ -24,6 +24,7 @@ function makeMockClients() { createNamespacedLimitRange: track("LimitRange"), replaceNamespacedLimitRange: track("LimitRangeReplace"), readNamespace: vi.fn().mockRejectedValue({ code: 404 }), + replaceNamespace: track("NamespaceReplace"), }, rbac: { readNamespacedRole: vi.fn().mockRejectedValue({ code: 404 }), @@ -97,17 +98,39 @@ describe("ensureTenant", () => { expect(sa.metadata.annotations["eks.amazonaws.com/role-arn"]).toBe("arn:aws:iam::123:role/paperclip"); }); - it("does not recreate a namespace that already exists", async () => { + it("reconciles a namespace that already exists", async () => { const clients = makeMockClients(); - clients.core.readNamespace.mockResolvedValue({ body: { metadata: { name: baseInput.namespace } } }); + clients.core.readNamespace.mockResolvedValue({ + metadata: { + name: baseInput.namespace, + resourceVersion: "rv-namespace", + labels: { "operator.example.com/team": "infra" }, + }, + }); await ensureTenant(clients as never, baseInput); expect(clients.core.createNamespace).not.toHaveBeenCalled(); + expect(clients.core.replaceNamespace).toHaveBeenCalledWith({ + name: baseInput.namespace, + body: expect.objectContaining({ + metadata: expect.objectContaining({ + resourceVersion: "rv-namespace", + labels: expect.objectContaining({ + "operator.example.com/team": "infra", + "paperclip.io/company-id": baseInput.companyId, + "paperclip.io/managed-by": "paperclip-k8s-plugin", + "pod-security.kubernetes.io/enforce": "restricted", + "pod-security.kubernetes.io/audit": "restricted", + "pod-security.kubernetes.io/warn": "restricted", + }), + }), + }), + }); }); it("reconciles existing managed resources with the latest desired manifests", async () => { const clients = makeMockClients(); const existing = { metadata: { resourceVersion: "rv-1" } }; - clients.core.readNamespace.mockResolvedValue({ metadata: { name: baseInput.namespace } }); + clients.core.readNamespace.mockResolvedValue({ metadata: { name: baseInput.namespace, resourceVersion: "rv-ns" } }); clients.core.readNamespacedServiceAccount.mockResolvedValue(existing); clients.rbac.readNamespacedRole.mockResolvedValue(existing); clients.rbac.readNamespacedRoleBinding.mockResolvedValue(existing); @@ -140,6 +163,18 @@ describe("ensureTenant", () => { }), ); expect(clients.networking.replaceNamespacedNetworkPolicy).toHaveBeenCalled(); + expect(clients.core.replaceNamespace).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + metadata: expect.objectContaining({ + resourceVersion: "rv-ns", + labels: expect.objectContaining({ + "pod-security.kubernetes.io/enforce": "restricted", + }), + }), + }), + }), + ); }); it("removes stale standard egress NetworkPolicy when cilium mode is selected", async () => { @@ -155,6 +190,9 @@ describe("ensureTenant", () => { const clients = makeMockClients(); const existing = { metadata: { resourceVersion: "rv-race" } }; clients.core.createNamespace.mockRejectedValueOnce({ code: 409 }); + clients.core.readNamespace + .mockRejectedValueOnce({ code: 404 }) + .mockResolvedValue({ metadata: { resourceVersion: "rv-namespace-race" } }); clients.core.readNamespacedServiceAccount .mockRejectedValueOnce({ code: 404 }) .mockResolvedValue(existing); @@ -163,6 +201,13 @@ describe("ensureTenant", () => { await ensureTenant(clients as never, baseInput); expect(clients.core.createNamespace).toHaveBeenCalled(); + expect(clients.core.replaceNamespace).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + metadata: expect.objectContaining({ resourceVersion: "rv-namespace-race" }), + }), + }), + ); expect(clients.core.replaceNamespacedServiceAccount).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({