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
+2 -1
View File
@@ -2,6 +2,7 @@ import { PatchStrategy } from "@kubernetes/client-node";
import type { KubernetesObject } from "@kubernetes/client-node";
import { LABELS } from "../const";
import { objectApi } from "./k8s";
import { sanitizeKubernetesObject } from "./kubernetes-object";
const FIELD_MANAGER = "kuber";
@@ -94,7 +95,7 @@ export async function applyResource<T extends KubernetesObject>(
resource: T,
): Promise<T> {
return objectApi.patch(
resource,
sanitizeKubernetesObject(resource),
undefined,
undefined,
FIELD_MANAGER,
+151
View File
@@ -8,6 +8,7 @@ import type {
V1Namespace,
V1PersistentVolumeClaim,
V1PodSpec,
V1ResourceRequirements,
V1Secret,
V1Service,
V1ServicePort,
@@ -228,6 +229,155 @@ function toHostname(value: string | undefined): string | undefined {
return /[a-z]/i.test(value) ? value : undefined;
}
const CPU_DECIMAL_PATTERN = /^(\d+)(?:\.(\d+))?$/;
const MEMORY_QUANTITY_PATTERN = /^(?:\d+(?:\.\d+)?|\.\d+)([a-zA-Z]*)$/;
function resourceValue(value: unknown): string {
return typeof value === "string" ? JSON.stringify(value) : String(value);
}
function toKubernetesCpu(value: number | string, source: string): string {
const text = String(value).trim();
const match = CPU_DECIMAL_PATTERN.exec(text);
if (!match) {
throw new Error(
`Invalid ${source} value ${resourceValue(value)}. Expected a non-negative finite decimal number of CPU cores.`,
);
}
const whole = BigInt(match[1] ?? "0");
const fraction = match[2] ?? "";
const millicores =
whole * 1000n +
(fraction ? BigInt(fraction.padEnd(3, "0").slice(0, 3)) : 0n);
if (fraction.slice(3).replaceAll("0", "") !== "") {
throw new Error(
`Invalid ${source} value ${resourceValue(value)}. CPU cores must be representable in whole millicores.`,
);
}
return millicores % 1000n === 0n
? String(millicores / 1000n)
: `${millicores}m`;
}
function toKubernetesMemory(value: string, source: string): string {
const text = value.trim();
const match = MEMORY_QUANTITY_PATTERN.exec(text);
if (!match) {
throw new Error(
`Invalid ${source} value ${resourceValue(value)}. Expected a non-negative memory quantity.`,
);
}
const suffix = match[1] ?? "";
const normalizedSuffix = (() => {
switch (suffix.toLowerCase()) {
case "b":
return "";
case "k":
case "kb":
return "k";
case "m":
case "mb":
// Compose uses lowercase m for decimal megabytes; Kubernetes uses it for milli.
return "M";
case "g":
case "gb":
return "G";
case "t":
case "tb":
return "T";
case "p":
case "pb":
return "P";
case "e":
case "eb":
return "E";
default:
return suffix;
}
})();
if (
![
"",
"n",
"u",
"m",
"k",
"M",
"G",
"T",
"P",
"E",
"Ki",
"Mi",
"Gi",
"Ti",
"Pi",
"Ei",
].includes(normalizedSuffix)
) {
throw new Error(
`Invalid ${source} value ${resourceValue(value)}. Expected a valid Kubernetes or Compose memory quantity.`,
);
}
return `${text.slice(0, text.length - suffix.length)}${normalizedSuffix}`;
}
function toContainerResources(
service: Service,
): V1ResourceRequirements | undefined {
const resources = service.deploy?.resources;
if (!resources) return;
const limits = {
...(resources.limits?.cpus !== undefined
? {
cpu: toKubernetesCpu(
resources.limits.cpus,
"deploy.resources.limits.cpus",
),
}
: {}),
...(resources.limits?.memory !== undefined
? {
memory: toKubernetesMemory(
resources.limits.memory,
"deploy.resources.limits.memory",
),
}
: {}),
};
const requests = {
...(resources.reservations?.cpus !== undefined
? {
cpu: toKubernetesCpu(
resources.reservations.cpus,
"deploy.resources.reservations.cpus",
),
}
: {}),
...(resources.reservations?.memory !== undefined
? {
memory: toKubernetesMemory(
resources.reservations.memory,
"deploy.resources.reservations.memory",
),
}
: {}),
};
if (Object.keys(limits).length === 0 && Object.keys(requests).length === 0)
return;
return {
...(Object.keys(limits).length > 0 ? { limits } : {}),
...(Object.keys(requests).length > 0 ? { requests } : {}),
};
}
function toBoolean(value: boolean | string | undefined): boolean | undefined {
if (typeof value === "boolean") return value;
if (!value) return;
@@ -1002,6 +1152,7 @@ export function serviceToDeployment(
? [{ secretRef: { name: `${name}-env` } }]
: undefined,
ports: toContainerPorts(ports),
resources: toContainerResources(service),
volumeMounts: toVolumeMounts(mounts),
} satisfies V1Container,
service["x-container"] ?? {},
+57 -7
View File
@@ -6,6 +6,12 @@ import { ResponseContext } from "@kubernetes/client-node/dist/gen/http/http.js";
import { from } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
import http from "node:http";
import https from "node:https";
import {
logValue,
processLogger,
safeLog,
type ProcessLogger,
} from "./request-log";
type TransportOptions = {
maxConcurrent: number;
@@ -14,6 +20,7 @@ type TransportOptions = {
baseRetryMs: number;
maxRetryMs: number;
random: () => number;
logger: ProcessLogger;
};
const DefaultTransportOptions: TransportOptions = {
@@ -23,6 +30,7 @@ const DefaultTransportOptions: TransportOptions = {
baseRetryMs: 250,
maxRetryMs: 5000,
random: Math.random,
logger: processLogger,
};
function abortError(): Error {
@@ -129,7 +137,10 @@ class RequestLimiter {
}
}
function sendOnce(request: RequestContext): Promise<ResponseContext> {
function sendOnce(
request: RequestContext,
onComplete: (response: ResponseContext, body: string) => void,
): Promise<ResponseContext> {
return new Promise((resolve, reject) => {
const url = new URL(request.getUrl());
const transport = url.protocol === "http:" ? http : https;
@@ -158,12 +169,16 @@ function sendOnce(request: RequestContext): Promise<ResponseContext> {
else if (value !== undefined) headers[key] = String(value);
}
resolve(
new ResponseContext(response.statusCode ?? 0, headers, {
const context = new ResponseContext(
response.statusCode ?? 0,
headers,
{
text: async () => buffer.toString("utf8"),
binary: async () => buffer,
}),
},
);
onComplete(context, buffer.toString("utf8"));
resolve(context);
});
},
);
@@ -198,12 +213,47 @@ export function createKubernetesHttpLibrary(
send(request) {
const result = (async () => {
for (let attempt = 0; ; attempt += 1) {
const release = await limiter.acquire(request.getSignal());
const startedAt = Date.now();
const method = request.getHttpMethod().toString();
const url = request.getUrl();
const base = {
type: "kubernetes.request",
method,
url,
pathname: new URL(url).pathname,
headers: logValue(request.getHeaders()),
body: logValue(request.getBody()),
attempt: attempt + 1,
maxRetries: options.maxRetries,
};
safeLog(options.logger, {
event: "kuber.k8s.request.start",
...base,
});
let release: (() => void) | undefined;
let response: ResponseContext;
try {
response = await sendOnce(request);
release = await limiter.acquire(request.getSignal());
response = await sendOnce(request, (completed, body) => {
safeLog(options.logger, {
event: "kuber.k8s.request.end",
...base,
status: completed.httpStatusCode,
responseHeaders: logValue(completed.headers),
responseBody: body,
durationMs: Date.now() - startedAt,
});
});
} catch (error) {
safeLog(options.logger, {
event: "kuber.k8s.request.failed",
...base,
durationMs: Date.now() - startedAt,
error: logValue(error),
});
throw error;
} finally {
release();
release?.();
}
if (
+26
View File
@@ -0,0 +1,26 @@
import type { KubernetesObject } from "@kubernetes/client-node";
type KubernetesObjectWithStatus = KubernetesObject & { status?: unknown };
/** Returns an apply-safe copy without fields owned by the API server. */
export function sanitizeKubernetesObject<T extends KubernetesObject>(
resource: T,
): T {
const { status: _status, ...object } = resource as KubernetesObjectWithStatus;
const {
managedFields: _managedFields,
resourceVersion: _resourceVersion,
uid: _uid,
creationTimestamp: _creationTimestamp,
generation: _generation,
selfLink: _selfLink,
deletionTimestamp: _deletionTimestamp,
deletionGracePeriodSeconds: _deletionGracePeriodSeconds,
...metadata
} = object.metadata ?? {};
return {
...object,
...(object.metadata && { metadata }),
} as T;
}
+226
View File
@@ -0,0 +1,226 @@
export type ProcessLogEntry = Record<string, unknown>;
export type ProcessLogger = {
log(entry: ProcessLogEntry): void;
};
const MAX_RESPONSE_LOG_BYTES = 64 * 1024;
export function logValue(
value: unknown,
seen = new WeakSet<object>(),
): unknown {
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
)
return value;
if (typeof value === "undefined") return undefined;
if (typeof value === "bigint") return value.toString();
if (typeof value === "symbol" || typeof value === "function")
return String(value);
if (value instanceof Uint8Array)
return { encoding: "base64", data: Buffer.from(value).toString("base64") };
if (value instanceof Error)
return {
name: value.name,
message: value.message,
...(value.stack && { stack: value.stack }),
};
if (typeof value !== "object") return String(value);
if (seen.has(value)) return "[Circular]";
seen.add(value);
if (Array.isArray(value)) return value.map((item) => logValue(item, seen));
try {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, logValue(item, seen)]),
);
} catch (error) {
return { unserializable: String(error) };
}
}
export const processLogger: ProcessLogger = {
log(entry) {
try {
console.log(`KUBER_REQUEST ${JSON.stringify(logValue(entry))}`);
} catch {
// Process logging must never change request behavior.
}
},
};
export function safeLog(logger: ProcessLogger, entry: ProcessLogEntry): void {
try {
logger.log(entry);
} catch {
// Logging is deliberately best-effort and process-only.
}
}
function headers(headers: Headers): Record<string, string> {
return Object.fromEntries(headers);
}
function isStreamingContentType(value: string | null): boolean {
const contentType = value?.split(";", 1)[0]?.trim().toLowerCase();
return (
contentType === "text/event-stream" ||
contentType === "application/x-ndjson" ||
contentType === "application/ndjson" ||
contentType === "application/json-seq"
);
}
function responseBodySize(value: Response): number | undefined {
const contentLength = value.headers.get("content-length");
if (!contentLength || !/^\d+$/.test(contentLength)) return;
const size = Number(contentLength);
return Number.isSafeInteger(size) ? size : undefined;
}
async function readBoundedResponseBody(
body: ReadableStream<Uint8Array>,
): Promise<Uint8Array> {
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > MAX_RESPONSE_LOG_BYTES) {
void reader.cancel();
throw new RangeError("Response log body exceeds limit");
}
chunks.push(value);
}
const result = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
function logResponseBody(
value: Response,
logger: ProcessLogger,
entry: ProcessLogEntry,
): void {
if (!value.body) return;
if (isStreamingContentType(value.headers.get("content-type"))) {
safeLog(logger, { ...entry, bodySkipped: true, streaming: true });
return;
}
const size = responseBodySize(value);
if (size === undefined) {
safeLog(logger, { ...entry, bodySkipped: true, bodySizeUnknown: true });
return;
}
if (size > MAX_RESPONSE_LOG_BYTES) {
safeLog(logger, {
...entry,
bodySkipped: true,
bodyTooLarge: true,
contentLength: size,
});
return;
}
try {
const body = value.clone().body;
if (!body) return;
void readBoundedResponseBody(body)
.then((body) => safeLog(logger, { ...entry, body: logValue(body) }))
.catch((error) =>
safeLog(
logger,
error instanceof RangeError
? { ...entry, bodySkipped: true, bodyTooLarge: true }
: { ...entry, error: logValue(error) },
),
);
} catch (error) {
safeLog(logger, { ...entry, error: logValue(error) });
}
}
export async function logServerRequest<T>(
request: Request,
requestId: string,
handler: () => Promise<T> | T,
logger: ProcessLogger = processLogger,
): Promise<T> {
const startedAt = Date.now();
const url = new URL(request.url);
const base = {
type: "server.request",
requestId,
method: request.method,
url: request.url,
pathname: url.pathname,
headers: headers(request.headers),
};
safeLog(logger, { event: "kuber.server.request.start", ...base });
try {
const response = await handler();
safeLog(logger, {
event: "kuber.server.request.end",
...base,
status: response instanceof Response ? response.status : 101,
durationMs: Date.now() - startedAt,
...(response instanceof Response && {
responseHeaders: headers(response.headers),
}),
});
if (response instanceof Response)
logResponseBody(response, logger, {
event: "kuber.server.response.body",
...base,
status: response.status,
});
return response;
} catch (error) {
safeLog(logger, {
event: "kuber.server.request.failed",
...base,
durationMs: Date.now() - startedAt,
error: logValue(error),
});
throw error;
}
}
export async function logKubernetesRequest<T>(
request: Omit<ProcessLogEntry, "event" | "type">,
handler: () => Promise<T>,
logger: ProcessLogger = processLogger,
): Promise<T> {
const startedAt = Date.now();
const base = { type: "kubernetes.request", ...request };
safeLog(logger, { event: "kuber.k8s.request.start", ...base });
try {
const result = await handler();
safeLog(logger, {
event: "kuber.k8s.request.end",
...base,
status: 200,
durationMs: Date.now() - startedAt,
});
return result;
} catch (error) {
safeLog(logger, {
event: "kuber.k8s.request.failed",
...base,
durationMs: Date.now() - startedAt,
error: logValue(error),
});
throw error;
}
}