411 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|