Files
kuber/tests/server/log-service.test.ts
T
2026-09-03 11:28:30 +07:00

411 lines
13 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
createLogService,
encodeLogEvent,
KubernetesLogError,
MANAGED_BY_SELECTOR,
type ContainerLogRequest,
type KubernetesDeployment,
type KubernetesLogsBackend,
type KubernetesPod,
type KubernetesService,
type LogEvent,
} from "../../server/log-service";
class FakeBackend implements KubernetesLogsBackend {
deployments: KubernetesDeployment[] = [];
service: KubernetesService | undefined;
pods: KubernetesPod[] = [];
reads = new Map<string, string | Error>();
streams = new Map<
string,
(signal: AbortSignal) => AsyncIterable<string | Uint8Array>
>();
deploymentCalls: Array<Readonly<Record<string, string>>> = [];
podSelectors: Array<Readonly<Record<string, string>>> = [];
requests: ContainerLogRequest[] = [];
async listDeployments(
_namespace: string,
labels: Readonly<Record<string, string>>,
) {
this.deploymentCalls.push(labels);
return this.deployments;
}
async getService(_namespace: string, _name: string) {
return this.service;
}
async listPods(
_namespace: string,
selector: Readonly<Record<string, string>>,
) {
this.podSelectors.push(selector);
return this.pods;
}
async readContainerLogs(request: ContainerLogRequest) {
this.requests.push(request);
const value = this.reads.get(`${request.pod}/${request.container}`) ?? "";
if (value instanceof Error) throw value;
return value;
}
streamContainerLogs(request: ContainerLogRequest, signal: AbortSignal) {
this.requests.push(request);
const stream = this.streams.get(`${request.pod}/${request.container}`);
if (stream) return stream(signal);
return openStream(signal);
}
}
async function* openStream(signal: AbortSignal): AsyncGenerator<string> {
await untilAbort(signal);
yield* [] as string[];
}
function untilAbort(signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve();
return new Promise((resolve) =>
signal.addEventListener("abort", () => resolve(), { once: true }),
);
}
async function* chunks(...values: Array<string | Uint8Array>) {
for (const value of values) yield value;
}
async function nextOfType<T extends LogEvent["type"]>(
iterator: AsyncIterator<LogEvent>,
type: T,
limit = 30,
): Promise<Extract<LogEvent, { type: T }>> {
for (let index = 0; index < limit; index += 1) {
const result = await iterator.next();
if (result.done) throw new Error(`Stream ended before ${type}`);
if (result.value.type === type)
return result.value as Extract<LogEvent, { type: T }>;
}
throw new Error(`No ${type} event received`);
}
function setup() {
const backend = new FakeBackend();
backend.deployments = [{ name: "web", selector: { app: "web" } }];
backend.pods = [
{
name: "web-abc",
uid: "uid-1",
phase: "Running",
containers: ["web", "sidecar"],
},
];
const service = createLogService(
backend,
() => new Date("2026-09-02T12:00:00.000Z"),
);
return { backend, service };
}
describe("LogService", () => {
test("enumerates every container in managed deployment pods", async () => {
const { backend, service } = setup();
expect(
await service.listContainers({
namespace: "demo",
target: { kind: "managed-deployments" },
}),
).toEqual([
{
namespace: "demo",
targetKind: "deployment",
targetName: "web",
pod: "web-abc",
podUid: "uid-1",
container: "web",
},
{
namespace: "demo",
targetKind: "deployment",
targetName: "web",
pod: "web-abc",
podUid: "uid-1",
container: "sidecar",
},
]);
expect(backend.deploymentCalls).toEqual([MANAGED_BY_SELECTOR]);
expect(backend.podSelectors).toEqual([{ app: "web" }]);
});
test("uses a named service selector without listing deployments", async () => {
const { backend, service } = setup();
backend.service = { name: "frontend", selector: { role: "frontend" } };
const result = await service.listContainers({
namespace: "demo",
target: { kind: "service", name: "frontend" },
});
expect(result[0]).toMatchObject({
targetKind: "service",
targetName: "frontend",
});
expect(backend.deploymentCalls).toHaveLength(0);
expect(backend.podSelectors).toEqual([{ role: "frontend" }]);
});
test("rejects missing and selectorless services", async () => {
const { backend, service } = setup();
const options = {
namespace: "demo",
target: { kind: "service" as const, name: "missing" },
};
await expect(service.listContainers(options)).rejects.toMatchObject({
retryable: false,
});
backend.service = { name: "missing", selector: {} };
await expect(service.listContainers(options)).rejects.toThrow(
"no pod selector",
);
});
test("collects timestamped structured lines and passes log options", async () => {
const { backend, service } = setup();
backend.reads.set(
"web-abc/web",
"2026-09-02T11:59:00.123456Z first\nsecond\n",
);
backend.reads.set("web-abc/sidecar", "side\n");
const events = await service.collect({
namespace: "demo",
target: { kind: "managed-deployments" },
tailLines: 25,
sinceTime: new Date("2026-09-02T11:00:00Z"),
timestamps: true,
});
expect(events).toHaveLength(3);
expect(events[0]).toMatchObject({
type: "log",
timestamp: "2026-09-02T12:00:00.000Z",
logTimestamp: "2026-09-02T11:59:00.123456Z",
message: "first",
pod: "web-abc",
container: "web",
});
expect(events[1]).toMatchObject({ message: "second" });
expect(backend.requests[0]).toEqual({
namespace: "demo",
pod: "web-abc",
container: "web",
tailLines: 25,
sinceSeconds: undefined,
sinceTime: "2026-09-02T11:00:00.000Z",
timestamps: true,
});
expect(JSON.parse(encodeLogEvent(events[0]!))).toEqual(events[0]);
});
test("returns per-container safe errors and continues collection", async () => {
const { backend, service } = setup();
backend.reads.set(
"web-abc/web",
new KubernetesLogError("container unavailable", false),
);
backend.reads.set("web-abc/sidecar", "healthy");
const events = await service.collect({
namespace: "demo",
target: { kind: "managed-deployments" },
});
expect(events[0]).toEqual({
type: "error",
timestamp: "2026-09-02T12:00:00.000Z",
namespace: "demo",
pod: "web-abc",
container: "web",
message: "container unavailable",
retryable: false,
});
expect(events[1]).toMatchObject({ type: "log", message: "healthy" });
expect(JSON.stringify(events)).not.toContain("env");
expect(JSON.stringify(events)).not.toContain("Secret");
});
test("validates tail and since options before Kubernetes calls", async () => {
const { backend, service } = setup();
await expect(
service.collect({
namespace: "demo",
target: { kind: "managed-deployments" },
tailLines: -1,
}),
).rejects.toThrow("tailLines");
await expect(
service.collect({
namespace: "demo",
target: { kind: "managed-deployments" },
sinceSeconds: 5,
sinceTime: "2026-09-02T00:00:00Z",
}),
).rejects.toThrow("mutually exclusive");
expect(backend.deploymentCalls).toHaveLength(0);
});
test("follows chunked lines and stops active streams on abort", async () => {
const { backend, service } = setup();
let cancelled = false;
backend.streams.set("web-abc/web", (signal) =>
(async function* () {
try {
yield new TextEncoder().encode("hel");
yield "lo\npartial";
await untilAbort(signal);
} finally {
cancelled = true;
}
})(),
);
const controller = new AbortController();
const iterator = service.follow({
namespace: "demo",
target: { kind: "managed-deployments" },
signal: controller.signal,
discoveryIntervalMs: 5,
heartbeatIntervalMs: 1_000,
});
expect(await nextOfType(iterator, "log")).toMatchObject({
message: "hello",
});
controller.abort();
expect((await iterator.next()).done).toBe(true);
expect(cancelled).toBe(true);
});
test("discards queued lines immediately on external abort", async () => {
const { backend, service } = setup();
backend.pods[0] = { ...backend.pods[0]!, containers: ["web"] };
backend.streams.set("web-abc/web", () => chunks("one\ntwo\nthree\n"));
const controller = new AbortController();
const iterator = service.follow({
namespace: "demo",
target: { kind: "managed-deployments" },
signal: controller.signal,
queueCapacity: 5,
discoveryIntervalMs: 1_000,
heartbeatIntervalMs: 1_000,
});
expect(await nextOfType(iterator, "log")).toMatchObject({ message: "one" });
await Bun.sleep(1);
controller.abort();
expect((await iterator.next()).done).toBe(true);
});
test("discovers replacement pod UIDs while following", async () => {
const { backend, service } = setup();
backend.pods[0] = { ...backend.pods[0]!, containers: ["web"] };
backend.streams.set("web-abc/web", () => chunks("old\n"));
const controller = new AbortController();
const iterator = service.follow({
namespace: "demo",
target: { kind: "managed-deployments" },
signal: controller.signal,
discoveryIntervalMs: 5,
heartbeatIntervalMs: 1_000,
});
expect(await nextOfType(iterator, "log")).toMatchObject({
message: "old",
podUid: "uid-1",
});
backend.pods = [
{ name: "web-new", uid: "uid-2", containers: ["web"], phase: "Running" },
];
backend.streams.set("web-new/web", () => chunks("new\n"));
expect(await nextOfType(iterator, "log")).toMatchObject({
message: "new",
pod: "web-new",
podUid: "uid-2",
});
controller.abort();
await iterator.next();
});
test("emits heartbeats and retryable stream errors before retrying", async () => {
const { backend, service } = setup();
backend.pods[0] = { ...backend.pods[0]!, containers: ["web"] };
let attempts = 0;
backend.streams.set("web-abc/web", () =>
(async function* () {
attempts += 1;
if (attempts === 1) throw new Error("temporary disconnect");
yield "recovered\n";
})(),
);
const controller = new AbortController();
const iterator = service.follow({
namespace: "demo",
target: { kind: "managed-deployments" },
signal: controller.signal,
discoveryIntervalMs: 5,
heartbeatIntervalMs: 2,
retryIntervalMs: 7,
});
expect(await nextOfType(iterator, "error")).toMatchObject({
message: "temporary disconnect",
retryable: true,
retryAfterMs: 7,
pod: "web-abc",
});
expect(await nextOfType(iterator, "heartbeat")).toMatchObject({
timestamp: "2026-09-02T12:00:00.000Z",
});
expect(await nextOfType(iterator, "log")).toMatchObject({
message: "recovered",
});
controller.abort();
await iterator.next();
expect(attempts).toBe(2);
});
test("emits one terminal target error and closes follow mode", async () => {
const { service } = setup();
const iterator = service.follow({
namespace: "demo",
target: { kind: "service", name: "missing" },
signal: new AbortController().signal,
discoveryIntervalMs: 5,
heartbeatIntervalMs: 5,
});
expect(await nextOfType(iterator, "error")).toMatchObject({
message: "Service missing was not found",
retryable: false,
});
expect((await iterator.next()).done).toBe(true);
});
test("applies bounded-queue backpressure to fast producers", async () => {
const { backend, service } = setup();
backend.pods[0] = { ...backend.pods[0]!, containers: ["web"] };
let produced = 0;
backend.streams.set("web-abc/web", () =>
(async function* () {
for (let index = 0; index < 20; index += 1) {
produced += 1;
yield `${index}\n`;
}
})(),
);
const controller = new AbortController();
const iterator = service.follow({
namespace: "demo",
target: { kind: "managed-deployments" },
signal: controller.signal,
queueCapacity: 1,
discoveryIntervalMs: 1_000,
heartbeatIntervalMs: 1_000,
});
expect((await iterator.next()).value).toMatchObject({ message: "0" });
await Bun.sleep(10);
expect(produced).toBeLessThanOrEqual(3);
controller.abort();
await iterator.return?.(undefined);
});
});