239 lines
7.2 KiB
TypeScript
239 lines
7.2 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { Readable, Writable } from "node:stream";
|
|
import type { PassThrough } from "node:stream";
|
|
|
|
// Mock the k8s client so `execInPod` runs against a scripted WebSocket exec: the
|
|
// fake `Exec.exec` streams stdout chunks into the provided PassThrough and, for
|
|
// the success path, reports an exit status. This exercises the host-side stdout
|
|
// accumulation cap without a real cluster.
|
|
type StatusCb = (status: {
|
|
status: string;
|
|
details?: { causes?: { reason?: string; message?: string }[] };
|
|
}) => void;
|
|
|
|
let scriptedExec: (
|
|
stdout: PassThrough,
|
|
stderr: PassThrough,
|
|
statusCb: StatusCb,
|
|
stdin: PassThrough | null,
|
|
) => void = () => undefined;
|
|
|
|
vi.mock("@kubernetes/client-node", () => {
|
|
class Exec {
|
|
constructor(_kc: unknown) {}
|
|
async exec(
|
|
_namespace: string,
|
|
_podName: string,
|
|
_containerName: string,
|
|
_command: string[],
|
|
stdout: PassThrough,
|
|
stderr: PassThrough,
|
|
stdin: PassThrough | null,
|
|
_tty: boolean,
|
|
statusCb: StatusCb,
|
|
) {
|
|
// Defer so the caller has wired its stream listeners first.
|
|
setImmediate(() => scriptedExec(stdout, stderr, statusCb, stdin));
|
|
return { close() {} };
|
|
}
|
|
}
|
|
return { Exec };
|
|
});
|
|
|
|
const { execInPod, execInPodStreaming } = await import("../../src/pod-exec.js");
|
|
|
|
const KC = {} as never;
|
|
|
|
describe("execInPod stdout cap", () => {
|
|
it("fails closed when pod stdout exceeds the cap", async () => {
|
|
scriptedExec = (stdout) => {
|
|
// Emit more than the cap in a single chunk; the host must reject.
|
|
stdout.write(Buffer.alloc(64, 0x41));
|
|
};
|
|
await expect(
|
|
execInPod(KC, "ns", "pod", "agent", ["/bin/sh", "-c", ":"], undefined, 5_000, 16),
|
|
).rejects.toThrow(/cap|exceeded/i);
|
|
});
|
|
|
|
it("accepts stdout within the cap and returns the accumulated output", async () => {
|
|
scriptedExec = (stdout, stderr, statusCb) => {
|
|
stdout.write(Buffer.from("hello", "utf-8"));
|
|
stdout.end();
|
|
stderr.end();
|
|
statusCb({ status: "Success" });
|
|
};
|
|
const result = await execInPod(
|
|
KC,
|
|
"ns",
|
|
"pod",
|
|
"agent",
|
|
["/bin/sh", "-c", ":"],
|
|
undefined,
|
|
5_000,
|
|
1024,
|
|
);
|
|
expect(result).toEqual({ exitCode: 0, stdout: "hello", stderr: "" });
|
|
});
|
|
|
|
it("leaves stdout unbounded when no cap is provided", async () => {
|
|
scriptedExec = (stdout, stderr, statusCb) => {
|
|
stdout.write(Buffer.alloc(4096, 0x42));
|
|
stdout.end();
|
|
stderr.end();
|
|
statusCb({ status: "Success" });
|
|
};
|
|
const result = await execInPod(
|
|
KC,
|
|
"ns",
|
|
"pod",
|
|
"agent",
|
|
["/bin/sh", "-c", ":"],
|
|
undefined,
|
|
5_000,
|
|
);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toHaveLength(4096);
|
|
});
|
|
});
|
|
|
|
describe("execInPod stderr cap", () => {
|
|
it("fails closed when pod stderr exceeds the cap", async () => {
|
|
// stderr is equally pod-controlled: a malicious pod that floods stderr must
|
|
// not grow the host accumulator without bound. Same DoS class as stdout.
|
|
scriptedExec = (_stdout, stderr) => {
|
|
stderr.write(Buffer.alloc(64, 0x45));
|
|
};
|
|
await expect(
|
|
// maxStdoutBytes generous, maxStderrBytes = 16 -> stderr must trip.
|
|
execInPod(KC, "ns", "pod", "agent", ["/bin/sh", "-c", ":"], undefined, 5_000, 1024, 16),
|
|
).rejects.toThrow(/stderr.*cap|cap.*stderr|exceeded/i);
|
|
});
|
|
|
|
it("accepts stderr within the cap and returns the accumulated output", async () => {
|
|
scriptedExec = (stdout, stderr, statusCb) => {
|
|
stderr.write(Buffer.from("warn", "utf-8"));
|
|
stdout.end();
|
|
stderr.end();
|
|
statusCb({ status: "Success" });
|
|
};
|
|
const result = await execInPod(
|
|
KC,
|
|
"ns",
|
|
"pod",
|
|
"agent",
|
|
["/bin/sh", "-c", ":"],
|
|
undefined,
|
|
5_000,
|
|
1024,
|
|
1024,
|
|
);
|
|
expect(result).toEqual({ exitCode: 0, stdout: "", stderr: "warn" });
|
|
});
|
|
|
|
it("leaves stderr unbounded when no cap is provided", async () => {
|
|
scriptedExec = (stdout, stderr, statusCb) => {
|
|
stderr.write(Buffer.alloc(4096, 0x46));
|
|
stdout.end();
|
|
stderr.end();
|
|
statusCb({ status: "Success" });
|
|
};
|
|
const result = await execInPod(
|
|
KC,
|
|
"ns",
|
|
"pod",
|
|
"agent",
|
|
["/bin/sh", "-c", ":"],
|
|
undefined,
|
|
5_000,
|
|
1024,
|
|
);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stderr).toHaveLength(4096);
|
|
});
|
|
});
|
|
|
|
describe("execInPodStreaming", () => {
|
|
it("streams the command's stdout into the caller sink and resolves with exit code + stderr", async () => {
|
|
scriptedExec = (stdout, stderr, statusCb) => {
|
|
stdout.write(Buffer.from("chunk-one;"));
|
|
stdout.write(Buffer.from("chunk-two"));
|
|
stdout.end();
|
|
stderr.write(Buffer.from("warn"));
|
|
stderr.end();
|
|
statusCb({ status: "Success" });
|
|
};
|
|
const chunks: Buffer[] = [];
|
|
const sink = new Writable({
|
|
write(chunk: Buffer, _enc, cb) {
|
|
chunks.push(Buffer.from(chunk));
|
|
cb();
|
|
},
|
|
});
|
|
const result = await execInPodStreaming(KC, "ns", "pod", "agent", ["/bin/sh", "-c", ":"], {
|
|
stdout: sink,
|
|
timeoutMs: 5_000,
|
|
});
|
|
expect(result).toEqual({ exitCode: 0, stderr: "warn" });
|
|
expect(Buffer.concat(chunks).toString("utf-8")).toBe("chunk-one;chunk-two");
|
|
});
|
|
|
|
it("streams the caller stdin Readable into the pod command", async () => {
|
|
let received = Buffer.alloc(0);
|
|
scriptedExec = (stdout, stderr, statusCb, stdin) => {
|
|
if (stdin) {
|
|
stdin.on("data", (chunk: Buffer) => {
|
|
received = Buffer.concat([received, chunk]);
|
|
});
|
|
}
|
|
// Resolve once the source has been fully piped through.
|
|
setImmediate(() => {
|
|
stdout.end();
|
|
stderr.end();
|
|
statusCb({ status: "Success" });
|
|
});
|
|
};
|
|
const source = Readable.from([Buffer.from("payload-bytes")]);
|
|
const result = await execInPodStreaming(KC, "ns", "pod", "agent", ["/bin/sh", "-c", ":"], {
|
|
stdin: source,
|
|
timeoutMs: 5_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
expect(received.toString("utf-8")).toBe("payload-bytes");
|
|
});
|
|
|
|
it("fails closed when the pod floods stderr past the cap", async () => {
|
|
scriptedExec = (_stdout, stderr) => {
|
|
stderr.write(Buffer.alloc(64, 0x45));
|
|
};
|
|
const sink = new Writable({ write(_c, _e, cb) { cb(); } });
|
|
await expect(
|
|
execInPodStreaming(KC, "ns", "pod", "agent", ["/bin/sh", "-c", ":"], {
|
|
stdout: sink,
|
|
timeoutMs: 5_000,
|
|
maxStderrBytes: 16,
|
|
}),
|
|
).rejects.toThrow(/stderr.*cap|exceeded/i);
|
|
});
|
|
|
|
it("fails closed when the caller sink errors (e.g. a streamed-bytes disk guard trips)", async () => {
|
|
scriptedExec = (stdout, stderr, statusCb) => {
|
|
stdout.write(Buffer.alloc(4096, 0x42));
|
|
stdout.end();
|
|
stderr.end();
|
|
statusCb({ status: "Success" });
|
|
};
|
|
// A sink that rejects any write, standing in for the file-sync disk guard.
|
|
const sink = new Writable({
|
|
write(_chunk, _enc, cb) {
|
|
cb(new Error("streamed-output disk guard tripped"));
|
|
},
|
|
});
|
|
await expect(
|
|
execInPodStreaming(KC, "ns", "pod", "agent", ["/bin/sh", "-c", ":"], {
|
|
stdout: sink,
|
|
timeoutMs: 5_000,
|
|
}),
|
|
).rejects.toThrow(/disk guard/i);
|
|
});
|
|
});
|