diff --git a/command/main.ts b/command/main.ts index 4740112..f0da5a1 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.1", + version: "2.3.2", description: "Docker Compose -> K8s translation layer", }, args: { diff --git a/package.json b/package.json index 7689d85..ec5d556 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dmgnr/kuber", - "version": "2.3.1", + "version": "2.3.2", "description": "Docker Compose to Kubernetes translation layer", "bin": { "kuber": "dist/index.js" diff --git a/server/kubernetes-state.ts b/server/kubernetes-state.ts index b197986..17941a6 100644 --- a/server/kubernetes-state.ts +++ b/server/kubernetes-state.ts @@ -984,20 +984,35 @@ export class KubernetesTrustStore implements TrustStore { } async list(project: string): Promise { return (await list(this.objects, "ConfigMap", "trust", project)) - .map((item) => parsePayload<{ project: string; fingerprint: string }>(item)) + .map((item) => + parsePayload<{ project: string; fingerprint: string }>(item), + ) .filter((record): record is { project: string; fingerprint: string } => - Boolean(record && record.project === project && /^[a-f0-9]{64}$/.test(record.fingerprint)), + Boolean( + record && + record.project === project && + /^[a-f0-9]{64}$/.test(record.fingerprint), + ), ) .map((record) => record.fingerprint); } async has(project: string, fingerprint: string): Promise { - const item = await read(this.objects, "ConfigMap", this.name(project, fingerprint)); - const record = item && parsePayload<{ project: string; fingerprint: string }>(item); + const item = await read( + this.objects, + "ConfigMap", + this.name(project, fingerprint), + ); + const record = + item && parsePayload<{ project: string; fingerprint: string }>(item); return record?.project === project && record.fingerprint === fingerprint; } async revoke(project: string, fingerprint: string): Promise { if (!(await this.has(project, fingerprint))) return false; - return deleteObject(this.objects, "ConfigMap", this.name(project, fingerprint)); + return deleteObject( + this.objects, + "ConfigMap", + this.name(project, fingerprint), + ); } } @@ -1071,6 +1086,53 @@ export function createKubernetesManagementDependencies( ), ); const overrides: Partial = { + applyResource: async (desired) => { + let resource = desired as KubernetesObject & { + spec?: { storageClassName?: string }; + }; + if ( + desired.apiVersion === "v1" && + desired.kind === "PersistentVolumeClaim" && + desired.metadata?.name + ) { + try { + const existing = (await objects.read({ + apiVersion: "v1", + kind: "PersistentVolumeClaim", + metadata: { + name: desired.metadata.name, + namespace: desired.metadata.namespace, + }, + })) as KubernetesObject & { + spec?: { storageClassName?: string }; + status?: { phase?: string }; + }; + const spec = resource.spec; + if ( + existing.status?.phase === "Bound" && + existing.spec?.storageClassName !== spec?.storageClassName + ) { + resource = { + ...desired, + spec: { + ...spec, + storageClassName: existing.spec?.storageClassName, + }, + }; + } + } catch (error) { + if (!isNotFound(error)) throw error; + } + } + return objects.patch( + resource, + undefined, + undefined, + FIELD_MANAGER, + true, + PatchStrategy.ServerSideApply, + ); + }, listDeployments: async (project) => ( await apps.listNamespacedDeployment({ diff --git a/tests/server/kubernetes-state.test.ts b/tests/server/kubernetes-state.test.ts index e168f67..feb9fdf 100644 --- a/tests/server/kubernetes-state.test.ts +++ b/tests/server/kubernetes-state.test.ts @@ -836,12 +836,12 @@ type ManagementClientsType = NonNullable< const managementClients = ( deployments: Array>, replicaSets: Array>, + objects = {} as KubernetesObjectApi, ): ManagementClientsType => { const apps = new FakeApps(deployments, replicaSets); - const objects = {} as KubernetesObjectApi; return { apps: apps as unknown as ManagementClientsType["apps"], - objects: {} as KubernetesObjectApi, + objects, config: {} as ManagementClientsType["config"], batch: {} as ManagementClientsType["batch"], core: {} as ManagementClientsType["core"], @@ -849,6 +849,107 @@ const managementClients = ( }; }; +type PersistentVolumeClaim = KubernetesObject & { + spec: { storageClassName?: string }; + status?: { phase?: string }; +}; + +function persistentVolumeClaim( + storageClassName = "kuber-longhorn-test", +): PersistentVolumeClaim { + return { + apiVersion: "v1", + kind: "PersistentVolumeClaim", + metadata: { name: "kimaki-kimaki-data", namespace: "kimaki" }, + spec: { storageClassName }, + }; +} + +function managementObjects(existing?: KubernetesObject) { + const applied: PersistentVolumeClaim[] = []; + return { + applied, + objects: { + async read() { + if (!existing) throw { code: 404 }; + return structuredClone(existing); + }, + async patch(resource: KubernetesObject) { + applied.push(structuredClone(resource) as PersistentVolumeClaim); + return resource; + }, + } as unknown as KubernetesObjectApi, + }; +} + +describe("management PVC storage classes", () => { + test("sends the existing storage class for a Bound claim with a different class", async () => { + const existing = { + ...persistentVolumeClaim("longhorn"), + status: { phase: "Bound" }, + }; + const fake = managementObjects(existing); + const dependencies = createKubernetesManagementDependencies( + managementClients([], [], fake.objects), + ); + const desired = persistentVolumeClaim(); + + await dependencies.applyResource(desired); + + expect(fake.applied).toEqual([ + { + ...persistentVolumeClaim(), + spec: { storageClassName: "longhorn" }, + }, + ]); + expect(desired).toEqual(persistentVolumeClaim()); + }); + + test("retains a matching storage class for a Bound claim", async () => { + const existing = { + ...persistentVolumeClaim(), + status: { phase: "Bound" }, + }; + const fake = managementObjects(existing); + const dependencies = createKubernetesManagementDependencies( + managementClients([], [], fake.objects), + ); + const desired = persistentVolumeClaim(); + + await dependencies.applyResource(desired); + + expect(fake.applied).toEqual([desired]); + }); + + test("retains the desired storage class for an absent claim", async () => { + const fake = managementObjects(); + const dependencies = createKubernetesManagementDependencies( + managementClients([], [], fake.objects), + ); + const desired = persistentVolumeClaim(); + + await dependencies.applyResource(desired); + + expect(fake.applied).toEqual([desired]); + }); + + test("retains a different storage class for a Pending claim", async () => { + const existing = { + ...persistentVolumeClaim("longhorn"), + status: { phase: "Pending" }, + }; + const fake = managementObjects(existing); + const dependencies = createKubernetesManagementDependencies( + managementClients([], [], fake.objects), + ); + const desired = persistentVolumeClaim(); + + await dependencies.applyResource(desired); + + expect(fake.applied).toEqual([desired]); + }); +}); + describe("rollback ReplicaSet discovery", () => { test("discovers owned revisions without the managed-by selector", async () => { const depUid = "deployment-uid";