feat: improve resource reconciliation and observability
This commit is contained in:
+2
-1
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user