feat: improve resource reconciliation and observability

This commit is contained in:
2026-09-11 10:51:57 +00:00 Unverified
parent 0067a2cdcd
commit e3df2f4d76
28 changed files with 2407 additions and 217 deletions
+61 -2
View File
@@ -1,6 +1,9 @@
import { describe, expect, test } from "bun:test";
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import type { KubernetesObject } from "@kubernetes/client-node";
import { getResourceKey, sortResources } from "../../lib/apply";
import { applyResource, getResourceKey, sortResources } from "../../lib/apply";
import { objectApi } from "../../lib/k8s";
afterEach(() => mock.restore());
function resource(kind: string, name = kind): KubernetesObject {
return { apiVersion: "v1", kind, metadata: { name, namespace: "project" } };
@@ -72,3 +75,59 @@ describe("resource ordering", () => {
).toBe("Namespace::app");
});
});
describe("resource apply", () => {
test("does not send server-managed fields from a resource read from Kubernetes", async () => {
const returned = {
apiVersion: "postgresql.cnpg.io/v1",
kind: "Database",
metadata: {
name: "orders",
namespace: "database",
labels: { "kuber.dev/project": "demo" },
annotations: { "example.dev/setting": "enabled" },
managedFields: [{ manager: "kube-controller-manager" }],
resourceVersion: "42",
uid: "database-uid",
creationTimestamp: new Date("2026-09-08T00:00:00Z"),
generation: 3,
selfLink:
"/apis/postgresql.cnpg.io/v1/namespaces/database/databases/orders",
},
spec: { name: "orders" },
status: { phase: "Ready" },
} as KubernetesObject;
const patch = spyOn(objectApi, "patch").mockImplementation(
async (resource) => resource as never,
);
await applyResource(returned);
expect(patch).toHaveBeenCalledWith(
expect.objectContaining({
metadata: {
name: "orders",
namespace: "database",
labels: { "kuber.dev/project": "demo" },
annotations: { "example.dev/setting": "enabled" },
},
spec: { name: "orders" },
}),
undefined,
undefined,
"kuber",
true,
"application/apply-patch+yaml",
);
const sent = patch.mock.calls[0]?.[0] as typeof returned;
expect(sent.metadata).not.toHaveProperty("managedFields");
expect(sent.metadata).not.toHaveProperty("resourceVersion");
expect(sent.metadata).not.toHaveProperty("uid");
expect(sent.metadata).not.toHaveProperty("creationTimestamp");
expect(sent.metadata).not.toHaveProperty("generation");
expect(sent.metadata).not.toHaveProperty("selfLink");
expect(sent).not.toHaveProperty("status");
expect(returned.metadata).toHaveProperty("managedFields");
expect(returned).toHaveProperty("status");
});
});
+142
View File
@@ -170,6 +170,133 @@ describe("Deployment conversion", () => {
).spec?.template.spec?.containers[0]?.envFrom,
).toEqual([{ secretRef: { name: "app-env" } }]);
});
test("maps deploy resource limits and reservations to container resources", () => {
const service = {
image: "app",
deploy: {
resources: {
limits: { cpus: "0.25", memory: "3072M" },
reservations: { cpus: 0.5, memory: "1g" },
},
},
} as Service;
const deployment = serviceToDeployment(
"project",
"app",
compose(service),
service,
);
expect(deployment.spec?.template.spec?.containers[0]?.resources).toEqual({
limits: { cpu: "250m", memory: "3072M" },
requests: { cpu: "500m", memory: "1G" },
});
});
test("normalizes Compose memory units to decimal Kubernetes quantities", () => {
const service = {
image: "app",
deploy: {
resources: {
limits: { cpus: 2, memory: "256M" },
reservations: { cpus: "2.0", memory: "100m" },
},
},
} as Service;
const deployment = serviceToDeployment(
"project",
"app",
compose(service),
service,
);
expect(deployment.spec?.template.spec?.containers[0]?.resources).toEqual({
limits: { cpu: "2", memory: "256M" },
requests: { cpu: "2", memory: "100M" },
});
});
test.each(["1Ki", "1Mi", "1Gi", "1Ti", "1Pi", "1Ei"])(
"preserves Kubernetes binary memory quantity %s",
(memory) => {
const service = {
image: "app",
deploy: {
resources: {
limits: { memory },
},
},
} as Service;
const deployment = serviceToDeployment(
"project",
"app",
compose(service),
service,
);
expect(deployment.spec?.template.spec?.containers[0]?.resources).toEqual({
limits: { memory },
});
},
);
test.each(["", "-1M", "1e3", "1MBi", "1foo"])(
"rejects invalid memory resource quantity %s",
(memory) => {
const service = {
image: "app",
deploy: { resources: { limits: { memory } } },
} as Service;
expect(() =>
serviceToDeployment("project", "app", compose(service), service),
).toThrow("deploy.resources.limits.memory");
},
);
test("rejects invalid CPU resource quantities", () => {
for (const cpus of [
"NaN",
"Infinity",
Number.POSITIVE_INFINITY,
"-0.5",
"0.0005",
]) {
const service = {
image: "app",
deploy: { resources: { limits: { cpus } } },
} as Service;
expect(() =>
serviceToDeployment("project", "app", compose(service), service),
).toThrow("deploy.resources.limits.cpus");
}
});
test("lets x-container resource fields override deploy resources", () => {
const service = {
image: "app",
deploy: {
resources: {
limits: { cpus: "0.5", memory: "256M" },
reservations: { cpus: "0.25", memory: "128M" },
},
},
"x-container": {
resources: { limits: { memory: "768Mi" }, requests: { cpu: "900m" } },
},
} as Service;
const deployment = serviceToDeployment(
"project",
"app",
compose(service),
service,
);
expect(deployment.spec?.template.spec?.containers[0]?.resources).toEqual({
limits: { cpu: "500m", memory: "768Mi" },
requests: { cpu: "900m", memory: "128M" },
});
});
});
describe("port and routing conversion", () => {
@@ -439,6 +566,21 @@ describe("autoscaled (range) conversion", () => {
).toEqual({ cpu: "500m" });
});
test("uses a deploy CPU reservation instead of the HPA fallback request", async () => {
const service = {
image: "app",
deploy: {
replicas: "1-4",
resources: { reservations: { cpus: "0.25" } },
},
} as Service;
const resources = await composeToKubernetes("project", compose(service));
const deployment = findDeployment(resources);
expect(
deployment.spec?.template.spec?.containers?.[0]?.resources?.requests,
).toEqual({ cpu: "250m" });
});
test("does not inject a CPU request on fixed-replica services", async () => {
const service = { image: "app" } as Service;
const resources = await composeToKubernetes("project", compose(service));
+55
View File
@@ -151,4 +151,59 @@ describe("managed PostgreSQL claims", () => {
"Database",
]);
});
test("reconciles a CNPG cluster returned with managed fields without sending them back", async () => {
const claim = {
service: "app",
username: "app",
database: "app",
secretName: "postgres-app",
};
spyOn(objectApi, "read").mockImplementation(async (resource) => {
if (resource.kind === "Secret") {
return {
...resource,
data: {
username: Buffer.from("app").toString("base64"),
password: Buffer.from("secret").toString("base64"),
},
} as never;
}
return {
...resource,
metadata: {
...resource.metadata,
managedFields: [{ manager: "cloudnative-pg" }],
resourceVersion: "42",
uid: "cluster-uid",
creationTimestamp: "2026-09-08T00:00:00Z",
generation: 3,
},
spec: { managed: { roles: [{ name: "existing" }] } },
status: { phase: "Ready" },
} as never;
});
const patch = spyOn(objectApi, "patch").mockImplementation(
async (resource) => {
if (resource.metadata?.managedFields)
throw new Error("metadata.managedFields must be nil");
return resource as never;
},
);
await reconcilePostgresClaim("project", claim);
const cluster = patch.mock.calls.find(
([resource]) => resource.kind === "Cluster",
)?.[0];
expect(cluster).toMatchObject({
metadata: { name: "postgres", namespace: "database" },
spec: {
managed: { roles: [{ name: "existing" }, { name: "app" }] },
},
});
expect(cluster?.metadata).not.toHaveProperty("managedFields");
expect(cluster?.metadata).not.toHaveProperty("resourceVersion");
expect(cluster).not.toHaveProperty("status");
});
});
+45
View File
@@ -74,6 +74,51 @@ describe("Kubernetes HTTP transport", () => {
expect(requests).toBe(2);
});
test("logs Kubernetes request starts, completions, and failures", async () => {
const logs: Record<string, unknown>[] = [];
const logger = {
log: (entry: Record<string, unknown>) => logs.push(entry),
};
const { url } = await serve((_request, response) => response.end("ok"));
const client = createKubernetesHttpLibrary({ minIntervalMs: 0, logger });
await client.send(new RequestContext(url, HttpMethod.GET)).toPromise();
expect(logs).toEqual(
expect.arrayContaining([
expect.objectContaining({
event: "kuber.k8s.request.start",
method: "GET",
attempt: 1,
}),
expect.objectContaining({
event: "kuber.k8s.request.end",
status: 200,
responseBody: "ok",
}),
]),
);
const failedLogs: Record<string, unknown>[] = [];
const { url: failedUrl } = await serve((request) =>
request.socket.destroy(),
);
const failedClient = createKubernetesHttpLibrary({
minIntervalMs: 0,
logger: { log: (entry) => failedLogs.push(entry) },
});
await expect(
failedClient
.send(new RequestContext(failedUrl, HttpMethod.GET))
.toPromise(),
).rejects.toBeDefined();
expect(failedLogs).toEqual(
expect.arrayContaining([
expect.objectContaining({ event: "kuber.k8s.request.start" }),
expect.objectContaining({ event: "kuber.k8s.request.failed" }),
]),
);
});
test("paces concurrent request starts", async () => {
const starts: number[] = [];
const { url } = await serve((_request, response) => {
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import { logServerRequest } from "../../lib/request-log";
describe("server response logging", () => {
test("does not consume streaming response bodies", async () => {
let pulls = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1;
controller.enqueue(new TextEncoder().encode('{"message":"live"}\n'));
},
});
const logs: Record<string, unknown>[] = [];
const response = new Response(body, {
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
});
await Bun.sleep(0);
const pullsBeforeLogging = pulls;
const loggedResponse = await logServerRequest(
new Request("https://kuber.astrxl.dev/logs"),
"streaming-response",
() => response,
{ log: (entry) => logs.push(entry) },
);
expect(pulls).toBe(pullsBeforeLogging);
expect(logs).toContainEqual(
expect.objectContaining({
event: "kuber.server.response.body",
bodySkipped: true,
streaming: true,
}),
);
const reader = loggedResponse.body!.getReader();
expect(new TextDecoder().decode((await reader.read()).value)).toBe(
'{"message":"live"}\n',
);
await reader.cancel();
});
test("logs bounded non-streaming response bodies", async () => {
const logs: Record<string, unknown>[] = [];
await logServerRequest(
new Request("https://kuber.astrxl.dev/health"),
"bounded-response",
() =>
new Response("healthy", {
headers: {
"content-length": "7",
"content-type": "text/plain; charset=utf-8",
},
}),
{ log: (entry) => logs.push(entry) },
);
await Bun.sleep(0);
expect(logs).toContainEqual(
expect.objectContaining({
event: "kuber.server.response.body",
body: { encoding: "base64", data: "aGVhbHRoeQ==" },
}),
);
});
});