fix: preserve bound PVC storage classes

This commit is contained in:
2026-09-06 03:04:02 +00:00 Unverified
parent 2f0afaadc7
commit 053f28a367
4 changed files with 172 additions and 9 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.1", version: "2.3.2",
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.1", "version": "2.3.2",
"description": "Docker Compose to Kubernetes translation layer", "description": "Docker Compose to Kubernetes translation layer",
"bin": { "bin": {
"kuber": "dist/index.js" "kuber": "dist/index.js"
+67 -5
View File
@@ -984,20 +984,35 @@ export class KubernetesTrustStore implements TrustStore {
} }
async list(project: string): Promise<string[]> { async list(project: string): Promise<string[]> {
return (await list(this.objects, "ConfigMap", "trust", project)) 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 } => .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); .map((record) => record.fingerprint);
} }
async has(project: string, fingerprint: string): Promise<boolean> { async has(project: string, fingerprint: string): Promise<boolean> {
const item = await read(this.objects, "ConfigMap", this.name(project, fingerprint)); const item = await read(
const record = item && parsePayload<{ project: string; fingerprint: string }>(item); this.objects,
"ConfigMap",
this.name(project, fingerprint),
);
const record =
item && parsePayload<{ project: string; fingerprint: string }>(item);
return record?.project === project && record.fingerprint === fingerprint; return record?.project === project && record.fingerprint === fingerprint;
} }
async revoke(project: string, fingerprint: string): Promise<boolean> { async revoke(project: string, fingerprint: string): Promise<boolean> {
if (!(await this.has(project, fingerprint))) return false; 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<ManagementDependencies> = { const overrides: Partial<ManagementDependencies> = {
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) => listDeployments: async (project) =>
( (
await apps.listNamespacedDeployment({ await apps.listNamespacedDeployment({
+103 -2
View File
@@ -836,12 +836,12 @@ type ManagementClientsType = NonNullable<
const managementClients = ( const managementClients = (
deployments: Array<Record<string, unknown>>, deployments: Array<Record<string, unknown>>,
replicaSets: Array<Record<string, unknown>>, replicaSets: Array<Record<string, unknown>>,
objects = {} as KubernetesObjectApi,
): ManagementClientsType => { ): ManagementClientsType => {
const apps = new FakeApps(deployments, replicaSets); const apps = new FakeApps(deployments, replicaSets);
const objects = {} as KubernetesObjectApi;
return { return {
apps: apps as unknown as ManagementClientsType["apps"], apps: apps as unknown as ManagementClientsType["apps"],
objects: {} as KubernetesObjectApi, objects,
config: {} as ManagementClientsType["config"], config: {} as ManagementClientsType["config"],
batch: {} as ManagementClientsType["batch"], batch: {} as ManagementClientsType["batch"],
core: {} as ManagementClientsType["core"], 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", () => { describe("rollback ReplicaSet discovery", () => {
test("discovers owned revisions without the managed-by selector", async () => { test("discovers owned revisions without the managed-by selector", async () => {
const depUid = "deployment-uid"; const depUid = "deployment-uid";