Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c71e224b43 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperclip-adapter-opencode-k8s",
|
"name": "paperclip-adapter-opencode-k8s",
|
||||||
"version": "0.1.27",
|
"version": "0.1.28",
|
||||||
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
|
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for FAR-85: the @kubernetes/client-node v1.x ApiException
|
||||||
|
* exposes the HTTP status as `code`, not `statusCode`. The previous `isNotFound`
|
||||||
|
* predicate only checked `statusCode`/`response.statusCode`, so real 404s were
|
||||||
|
* never recognized — `getPvc` re-threw the 404 instead of returning null, and
|
||||||
|
* `ensureAgentDbPvc`'s existence check died before the create path ran.
|
||||||
|
*
|
||||||
|
* These tests mock the underlying k8s SDK and feed `getPvc`/`deletePvc` errors
|
||||||
|
* shaped exactly like the real ApiException so the predicate is exercised
|
||||||
|
* end-to-end, not in isolation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock("@kubernetes/client-node", () => {
|
||||||
|
// Reproduces the real @kubernetes/client-node v1.x ApiException shape:
|
||||||
|
// HTTP status under `code`, plus `body` and `headers`. Defined inside the
|
||||||
|
// factory because vi.mock() is hoisted above any module-level declarations.
|
||||||
|
class ApiException<T> extends Error {
|
||||||
|
code: number;
|
||||||
|
body: T;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
constructor(code: number, message: string, body: T, headers: Record<string, string> = {}) {
|
||||||
|
super(`HTTP-Code: ${code}\nMessage: ${message}\nBody: ${JSON.stringify(body)}`);
|
||||||
|
this.code = code;
|
||||||
|
this.body = body;
|
||||||
|
this.headers = headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class KubeConfig {
|
||||||
|
loadFromCluster() {}
|
||||||
|
loadFromFile() {}
|
||||||
|
makeApiClient() {
|
||||||
|
return {
|
||||||
|
readNamespacedPersistentVolumeClaim: mockReadNamespacedPVC,
|
||||||
|
deleteNamespacedPersistentVolumeClaim: mockDeleteNamespacedPVC,
|
||||||
|
createNamespacedPersistentVolumeClaim: mockCreateNamespacedPVC,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
KubeConfig,
|
||||||
|
CoreV1Api: class {},
|
||||||
|
BatchV1Api: class {},
|
||||||
|
AuthorizationV1Api: class {},
|
||||||
|
Log: class {},
|
||||||
|
ApiException,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockReadNamespacedPVC = vi.fn();
|
||||||
|
const mockDeleteNamespacedPVC = vi.fn();
|
||||||
|
const mockCreateNamespacedPVC = vi.fn();
|
||||||
|
|
||||||
|
import * as k8s from "@kubernetes/client-node";
|
||||||
|
import { getPvc, createPvc, deletePvc, resetCache } from "./k8s-client.js";
|
||||||
|
|
||||||
|
const ApiException = (k8s as unknown as { ApiException: new <T>(code: number, message: string, body: T, headers?: Record<string, string>) => Error & { code: number; body: T } }).ApiException;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetCache();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPvc — 404 detection (FAR-85 regression)", () => {
|
||||||
|
const NAMESPACE = "paperclip";
|
||||||
|
const NAME = "opencode-db-test";
|
||||||
|
|
||||||
|
it("returns the PVC on success", async () => {
|
||||||
|
const pvc = { metadata: { name: NAME, namespace: NAMESPACE } };
|
||||||
|
mockReadNamespacedPVC.mockResolvedValue(pvc);
|
||||||
|
const result = await getPvc(NAMESPACE, NAME);
|
||||||
|
expect(result).toEqual(pvc);
|
||||||
|
expect(mockReadNamespacedPVC).toHaveBeenCalledWith({ name: NAME, namespace: NAMESPACE });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the SDK throws ApiException with code=404 (v1.x shape)", async () => {
|
||||||
|
mockReadNamespacedPVC.mockRejectedValue(
|
||||||
|
new ApiException(404, "Unknown API Status Code!", {
|
||||||
|
kind: "Status",
|
||||||
|
status: "Failure",
|
||||||
|
message: `persistentvolumeclaims "${NAME}" not found`,
|
||||||
|
reason: "NotFound",
|
||||||
|
code: 404,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = await getPvc(NAMESPACE, NAME);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for legacy errors with statusCode=404", async () => {
|
||||||
|
mockReadNamespacedPVC.mockRejectedValue(Object.assign(new Error("not found"), { statusCode: 404 }));
|
||||||
|
expect(await getPvc(NAMESPACE, NAME)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for legacy errors with response.statusCode=404", async () => {
|
||||||
|
mockReadNamespacedPVC.mockRejectedValue(Object.assign(new Error("not found"), { response: { statusCode: 404 } }));
|
||||||
|
expect(await getPvc(NAMESPACE, NAME)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-throws non-404 ApiException (e.g. 500)", async () => {
|
||||||
|
const err = new ApiException(500, "Internal Error", { message: "boom" });
|
||||||
|
mockReadNamespacedPVC.mockRejectedValue(err);
|
||||||
|
await expect(getPvc(NAMESPACE, NAME)).rejects.toBe(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-throws 403 (Forbidden) — must not be silently masked as missing", async () => {
|
||||||
|
const err = new ApiException(403, "Forbidden", { message: "rbac denied" });
|
||||||
|
mockReadNamespacedPVC.mockRejectedValue(err);
|
||||||
|
await expect(getPvc(NAMESPACE, NAME)).rejects.toBe(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deletePvc — 404 detection", () => {
|
||||||
|
const NAMESPACE = "paperclip";
|
||||||
|
const NAME = "opencode-db-test";
|
||||||
|
|
||||||
|
it("swallows ApiException with code=404 (already gone)", async () => {
|
||||||
|
mockDeleteNamespacedPVC.mockRejectedValue(
|
||||||
|
new ApiException(404, "Unknown API Status Code!", { reason: "NotFound" }),
|
||||||
|
);
|
||||||
|
await expect(deletePvc(NAMESPACE, NAME)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-throws non-404 errors", async () => {
|
||||||
|
const err = new ApiException(409, "Conflict", { reason: "Conflict" });
|
||||||
|
mockDeleteNamespacedPVC.mockRejectedValue(err);
|
||||||
|
await expect(deletePvc(NAMESPACE, NAME)).rejects.toBe(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createPvc — passes through to SDK", () => {
|
||||||
|
it("forwards the spec to createNamespacedPersistentVolumeClaim", async () => {
|
||||||
|
const spec = {
|
||||||
|
apiVersion: "v1",
|
||||||
|
kind: "PersistentVolumeClaim",
|
||||||
|
metadata: { name: "opencode-db-x", namespace: "paperclip" },
|
||||||
|
spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "1Gi" } } },
|
||||||
|
};
|
||||||
|
mockCreateNamespacedPVC.mockResolvedValue(spec);
|
||||||
|
const result = await createPvc("paperclip", spec as never);
|
||||||
|
expect(result).toEqual(spec);
|
||||||
|
expect(mockCreateNamespacedPVC).toHaveBeenCalledWith({ namespace: "paperclip", body: spec });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user