@specific.dev/spectest 0.91.1 → 0.91.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/components/k3s.d.ts
CHANGED
|
@@ -328,6 +328,8 @@ export interface K3sHelpers {
|
|
|
328
328
|
limit?: number;
|
|
329
329
|
}): Promise<Wrapped<KubernetesListObject<T>>>;
|
|
330
330
|
}
|
|
331
|
+
/** @internal Exported for regression tests; not part of the components API. */
|
|
332
|
+
export declare function isTransientApplyError(err: unknown): boolean;
|
|
331
333
|
export declare function k3s(opts?: K3sOptions): {
|
|
332
334
|
image: {
|
|
333
335
|
type: "registry";
|
package/dist/components/k3s.js
CHANGED
|
@@ -910,8 +910,33 @@ const TRANSIENT_APPLY_PATTERNS = [
|
|
|
910
910
|
function errorText(err) {
|
|
911
911
|
return err?.message ?? String(err);
|
|
912
912
|
}
|
|
913
|
-
|
|
914
|
-
|
|
913
|
+
/** ApiException formats its body with JSON.stringify, escaping Status.message.
|
|
914
|
+
* Read the actual response instead of matching that presentation string. */
|
|
915
|
+
function applyErrorStatus(err) {
|
|
916
|
+
let body = err?.body;
|
|
917
|
+
if (typeof body === "string") {
|
|
918
|
+
try {
|
|
919
|
+
body = JSON.parse(body);
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
return undefined;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return body && typeof body === "object" && !Array.isArray(body) ? body : undefined;
|
|
926
|
+
}
|
|
927
|
+
/** @internal Exported for regression tests; not part of the components API. */
|
|
928
|
+
export function isTransientApplyError(err) {
|
|
929
|
+
const status = applyErrorStatus(err);
|
|
930
|
+
const code = status?.code ?? err?.code;
|
|
931
|
+
if ([429, 500, 503, 504].includes(code))
|
|
932
|
+
return true;
|
|
933
|
+
// A missing namespace can converge while another apply creates it. An
|
|
934
|
+
// arbitrary resource 404 cannot: don't classify by HTTP status alone.
|
|
935
|
+
if (code === 404 && status?.reason === "NotFound" &&
|
|
936
|
+
status.details?.kind === "namespaces" && !status.details.group &&
|
|
937
|
+
typeof status.details.name === "string" && status.details.name.length > 0)
|
|
938
|
+
return true;
|
|
939
|
+
const msg = typeof status?.message === "string" ? status.message : errorText(err);
|
|
915
940
|
return TRANSIENT_APPLY_PATTERNS.some((re) => re.test(msg));
|
|
916
941
|
}
|
|
917
942
|
function describeDoc(doc) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { KubernetesObjectApi } from "@kubernetes/client-node";
|
|
3
|
+
import { ApiException } from "@kubernetes/client-node/dist/gen/apis/exception.js";
|
|
4
|
+
import { isTransientApplyError, k3s } from "./k3s";
|
|
5
|
+
|
|
6
|
+
const missingNamespace = {
|
|
7
|
+
apiVersion: "v1", kind: "Status", status: "Failure", code: 404, reason: "NotFound",
|
|
8
|
+
message: 'namespaces "agent-sandbox-system" not found',
|
|
9
|
+
details: { name: "agent-sandbox-system", kind: "namespaces" },
|
|
10
|
+
};
|
|
11
|
+
const exception = (code: number, body: unknown) => new ApiException(code, "Unknown API Status Code!", body, {});
|
|
12
|
+
|
|
13
|
+
for (const format of ["object", "JSON string"] as const) {
|
|
14
|
+
const encode = (body: unknown) => format === "object" ? body : JSON.stringify(body);
|
|
15
|
+
test(`missing-namespace ApiException with ${format} body is transient`, () => {
|
|
16
|
+
const error = exception(404, encode(missingNamespace));
|
|
17
|
+
// This is the production client's escaped representation, not a hand-made Error.
|
|
18
|
+
expect(/namespaces? "[^"]+" not found/i.test(error.message)).toBe(false);
|
|
19
|
+
expect(isTransientApplyError(error)).toBe(true);
|
|
20
|
+
expect(isTransientApplyError(exception(404, encode({ ...missingNamespace, message: "Not Found" })))).toBe(true);
|
|
21
|
+
// The Status message also works when structured details are absent.
|
|
22
|
+
expect(isTransientApplyError(exception(404, encode({ ...missingNamespace, details: undefined })))).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test(`apply converges after a missing namespace with ${format} response body`, async () => {
|
|
26
|
+
const error = exception(404, encode(missingNamespace));
|
|
27
|
+
let attempts = 0;
|
|
28
|
+
const patch = spyOn(KubernetesObjectApi.prototype, "patch").mockImplementation(async doc => {
|
|
29
|
+
if (++attempts === 1) throw error;
|
|
30
|
+
return doc as never;
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
const helpers = await makeHelpers();
|
|
34
|
+
const applied = await helpers.apply(JSON.stringify(policy), { timeoutMs: 2000 });
|
|
35
|
+
expect(attempts).toBe(2);
|
|
36
|
+
expect(applied[0].unwrap()).toEqual(policy);
|
|
37
|
+
} finally { patch.mockRestore(); }
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test(`unrelated 404, forbidden and invalid Status bodies remain terminal (${format})`, () => {
|
|
41
|
+
for (const body of [
|
|
42
|
+
{ ...missingNamespace, message: 'deployments.apps "controller" not found', details: { name: "controller", kind: "deployments", group: "apps" } },
|
|
43
|
+
{ ...missingNamespace, message: 'namespaces.example.dev "widget" not found', details: { name: "widget", kind: "namespaces", group: "example.dev" } },
|
|
44
|
+
{ code: 404, reason: "NotFound", message: "Not Found" },
|
|
45
|
+
{ code: 403, reason: "Forbidden", message: 'namespaces "agent-sandbox-system" is forbidden', details: missingNamespace.details },
|
|
46
|
+
{ code: 422, reason: "Invalid", message: 'NetworkPolicy "policy" is invalid', details: { causes: [{ message: missingNamespace.message }] } },
|
|
47
|
+
]) expect(isTransientApplyError(exception(body.code, encode(body)))).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("plain client errors and transient HTTP statuses retain their retry behavior", () => {
|
|
52
|
+
for (const code of [429, 500, 503, 504]) {
|
|
53
|
+
expect(isTransientApplyError(exception(code, { message: "Try again" }))).toBe(true);
|
|
54
|
+
}
|
|
55
|
+
for (const message of [missingNamespace.message, 'no matches for kind "Widget"', "Failed to fetch resource metadata", "failed calling webhook", "HTTP-Code: 503\nMessage: unavailable"]) {
|
|
56
|
+
expect(isTransientApplyError(new Error(message))).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
for (const body of ["not JSON", "{", null, [], 42]) {
|
|
59
|
+
expect(isTransientApplyError(exception(404, body))).toBe(false);
|
|
60
|
+
}
|
|
61
|
+
expect(isTransientApplyError(null)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const policy = {
|
|
65
|
+
apiVersion: "networking.k8s.io/v1", kind: "NetworkPolicy",
|
|
66
|
+
metadata: { name: "policy", namespace: "agent-sandbox-system" },
|
|
67
|
+
spec: { podSelector: {}, policyTypes: ["Ingress"] },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
async function makeHelpers() {
|
|
71
|
+
return k3s().helpers({ name: "cluster", exec: async () => ({ exitCode: 0, stderr: "", stdout: JSON.stringify({
|
|
72
|
+
apiVersion: "v1", kind: "Config", clusters: [{ name: "test", cluster: { server: "https://cluster.internal:6443" } }],
|
|
73
|
+
users: [{ name: "test", user: {} }], contexts: [{ name: "test", context: { cluster: "test", user: "test" } }],
|
|
74
|
+
"current-context": "test",
|
|
75
|
+
}) }) } as never);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test("apply fails immediately on an unrelated 404 and still times out on a missing namespace", async () => {
|
|
79
|
+
const helpers = await makeHelpers();
|
|
80
|
+
const patch = spyOn(KubernetesObjectApi.prototype, "patch");
|
|
81
|
+
try {
|
|
82
|
+
patch.mockRejectedValue(exception(404, { reason: "NotFound", message: 'deployments.apps "controller" not found' }));
|
|
83
|
+
await expect(helpers.apply(JSON.stringify(policy))).rejects.toThrow("applying NetworkPolicy/policy");
|
|
84
|
+
expect(patch).toHaveBeenCalledTimes(1);
|
|
85
|
+
patch.mockClear();
|
|
86
|
+
patch.mockRejectedValue(exception(404, missingNamespace));
|
|
87
|
+
await expect(helpers.apply(JSON.stringify(policy), { timeoutMs: 0 })).rejects.toThrow("apply did not converge");
|
|
88
|
+
expect(patch).toHaveBeenCalledTimes(1);
|
|
89
|
+
} finally { patch.mockRestore(); }
|
|
90
|
+
});
|
package/src/components/k3s.ts
CHANGED
|
@@ -1421,8 +1421,27 @@ function errorText(err: unknown): string {
|
|
|
1421
1421
|
return (err as Error)?.message ?? String(err);
|
|
1422
1422
|
}
|
|
1423
1423
|
|
|
1424
|
-
|
|
1425
|
-
|
|
1424
|
+
/** ApiException formats its body with JSON.stringify, escaping Status.message.
|
|
1425
|
+
* Read the actual response instead of matching that presentation string. */
|
|
1426
|
+
function applyErrorStatus(err: unknown): V1Status | undefined {
|
|
1427
|
+
let body = (err as { body?: unknown } | null)?.body;
|
|
1428
|
+
if (typeof body === "string") {
|
|
1429
|
+
try { body = JSON.parse(body); } catch { return undefined; }
|
|
1430
|
+
}
|
|
1431
|
+
return body && typeof body === "object" && !Array.isArray(body) ? body as V1Status : undefined;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** @internal Exported for regression tests; not part of the components API. */
|
|
1435
|
+
export function isTransientApplyError(err: unknown): boolean {
|
|
1436
|
+
const status = applyErrorStatus(err);
|
|
1437
|
+
const code = status?.code ?? (err as { code?: unknown } | null)?.code;
|
|
1438
|
+
if ([429, 500, 503, 504].includes(code as number)) return true;
|
|
1439
|
+
// A missing namespace can converge while another apply creates it. An
|
|
1440
|
+
// arbitrary resource 404 cannot: don't classify by HTTP status alone.
|
|
1441
|
+
if (code === 404 && status?.reason === "NotFound" &&
|
|
1442
|
+
status.details?.kind === "namespaces" && !status.details.group &&
|
|
1443
|
+
typeof status.details.name === "string" && status.details.name.length > 0) return true;
|
|
1444
|
+
const msg = typeof status?.message === "string" ? status.message : errorText(err);
|
|
1426
1445
|
return TRANSIENT_APPLY_PATTERNS.some((re) => re.test(msg));
|
|
1427
1446
|
}
|
|
1428
1447
|
|