feat: improve resource reconciliation and observability
This commit is contained in:
+61
-2
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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==" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user