@appthrust/kest 0.1.0

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.
@@ -0,0 +1,30 @@
1
+ const given: Fn =
2
+ ({ recorder }) =>
3
+ (description) =>
4
+ recorder.record("BDDGiven", { description });
5
+
6
+ const when: Fn =
7
+ ({ recorder }) =>
8
+ (description) =>
9
+ recorder.record("BDDWhen", { description });
10
+
11
+ const then: Fn =
12
+ ({ recorder }) =>
13
+ (description) =>
14
+ recorder.record("BDDThen", { description });
15
+
16
+ const and: Fn =
17
+ ({ recorder }) =>
18
+ (description) =>
19
+ recorder.record("BDDAnd", { description });
20
+
21
+ const but: Fn =
22
+ ({ recorder }) =>
23
+ (description) =>
24
+ recorder.record("BDBut", { description });
25
+
26
+ type Fn = (env: {
27
+ readonly recorder: import("../recording").Recorder;
28
+ }) => (description: string) => void;
29
+
30
+ export default { given, when, then, and, but };
@@ -0,0 +1,171 @@
1
+ const MS_PER_S = 1_000n;
2
+ const MS_PER_M = 60n * MS_PER_S;
3
+ const MS_PER_H = 60n * MS_PER_M;
4
+
5
+ function pow10(exp: number): bigint {
6
+ let x = 1n;
7
+ for (let i = 0; i < exp; i++) {
8
+ x *= 10n;
9
+ }
10
+ return x;
11
+ }
12
+
13
+ function parseDecimalToMilliseconds(value: string, unitMs: bigint): bigint {
14
+ // value is like "12" or "12.34"
15
+ const dot = value.indexOf(".");
16
+ if (dot === -1) {
17
+ return BigInt(value) * unitMs;
18
+ }
19
+
20
+ const intPartStr = value.slice(0, dot);
21
+ const fracPartStr = value.slice(dot + 1);
22
+ if (fracPartStr.length === 0) {
23
+ throw new Error("invalid duration");
24
+ }
25
+
26
+ const intPart = BigInt(intPartStr);
27
+ const fracScale = pow10(fracPartStr.length);
28
+ const frac = BigInt(fracPartStr);
29
+
30
+ // Truncate toward zero at millisecond precision.
31
+ return intPart * unitMs + (frac * unitMs) / fracScale;
32
+ }
33
+
34
+ function unitToMilliseconds(unit: string): bigint {
35
+ switch (unit) {
36
+ case "ms":
37
+ return 1n;
38
+ case "s":
39
+ return MS_PER_S;
40
+ case "m":
41
+ return MS_PER_M;
42
+ case "h":
43
+ return MS_PER_H;
44
+ default:
45
+ throw new Error("invalid duration");
46
+ }
47
+ }
48
+
49
+ export class Duration {
50
+ static readonly zero = new Duration(0);
51
+
52
+ readonly milliseconds: number;
53
+
54
+ constructor(milliseconds: number) {
55
+ if (!Number.isFinite(milliseconds)) {
56
+ throw new Error("invalid duration");
57
+ }
58
+ if (!Number.isInteger(milliseconds)) {
59
+ throw new Error("invalid duration");
60
+ }
61
+ if (milliseconds < 0) {
62
+ throw new Error("invalid duration");
63
+ }
64
+ this.milliseconds = milliseconds;
65
+ }
66
+
67
+ toMilliseconds(): number {
68
+ return this.milliseconds;
69
+ }
70
+
71
+ toString(): string {
72
+ const ms = this.milliseconds;
73
+ if (ms === 0) {
74
+ return "0";
75
+ }
76
+
77
+ let absMs = ms;
78
+ if (absMs < 1000) {
79
+ return `${absMs}ms`;
80
+ }
81
+
82
+ const MS_PER_S_NUM = 1000;
83
+ const MS_PER_M_NUM = 60_000;
84
+ const MS_PER_H_NUM = 3_600_000;
85
+
86
+ const hours = Math.floor(absMs / MS_PER_H_NUM);
87
+ absMs -= hours * MS_PER_H_NUM;
88
+ const minutes = Math.floor(absMs / MS_PER_M_NUM);
89
+ absMs -= minutes * MS_PER_M_NUM;
90
+ const seconds = Math.floor(absMs / MS_PER_S_NUM);
91
+ const remMs = absMs - seconds * MS_PER_S_NUM;
92
+
93
+ let out = "";
94
+ if (hours > 0) {
95
+ out += `${hours}h`;
96
+ }
97
+ if (minutes > 0) {
98
+ out += `${minutes}m`;
99
+ }
100
+
101
+ if (seconds > 0 || remMs > 0 || out === "") {
102
+ if (remMs === 0) {
103
+ out += `${seconds}s`;
104
+ } else {
105
+ const frac = String(remMs).padStart(3, "0").replace(/0+$/, "");
106
+ out += `${seconds}.${frac}s`;
107
+ }
108
+ }
109
+
110
+ return out;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Parses a duration string like Go's time.ParseDuration.
116
+ *
117
+ * Examples:
118
+ * - "300ms"
119
+ * - "1.5h"
120
+ * - "-2h45m"
121
+ * - "1h30m"
122
+ */
123
+ export function parseDuration(input: string): Duration {
124
+ if (input.length === 0) {
125
+ throw new Error("invalid duration");
126
+ }
127
+ // Keep it strict (Go-like): no leading/trailing whitespace.
128
+ if (input.trim() !== input) {
129
+ throw new Error("invalid duration");
130
+ }
131
+
132
+ if (input[0] === "-" || input[0] === "+") {
133
+ throw new Error("invalid duration");
134
+ }
135
+
136
+ const s = input;
137
+
138
+ if (s === "0") {
139
+ return Duration.zero;
140
+ }
141
+
142
+ // token: <number><unit>
143
+ // number: \d+(\.\d+)?
144
+ // NOTE: millisecond precision for Date interop: no ns/us.
145
+ const re = /(\d+(?:\.\d+)?)(ms|s|m|h)/gy;
146
+ let totalMs = 0n;
147
+ let matchedAny = false;
148
+ let consumed = 0;
149
+ while (true) {
150
+ const m = re.exec(s);
151
+ if (!m) {
152
+ break;
153
+ }
154
+ matchedAny = true;
155
+ consumed = re.lastIndex;
156
+ const value = m[1];
157
+ const unit = m[2];
158
+ const unitMs = unitToMilliseconds(unit as string);
159
+ totalMs += parseDecimalToMilliseconds(value as string, unitMs);
160
+ }
161
+
162
+ if (!matchedAny || consumed !== s.length) {
163
+ throw new Error("invalid duration");
164
+ }
165
+
166
+ const msNumber = Number(totalMs);
167
+ if (!Number.isSafeInteger(msNumber)) {
168
+ throw new Error("invalid duration");
169
+ }
170
+ return new Duration(msNumber);
171
+ }
package/ts/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** biome-ignore-all lint/performance/noBarrelFile: that's ok */
2
+ export type * from "./apis";
3
+ export { test } from "./test";
@@ -0,0 +1,120 @@
1
+ import type { K8sResource } from "../apis";
2
+ import { parseYaml } from "../yaml";
3
+
4
+ export function parseK8sResource(value: unknown): ParseResult<K8sResource> {
5
+ const violations: Array<string> = [];
6
+
7
+ // Check if value is an object
8
+ if (value === null || typeof value !== "object") {
9
+ return { ok: false, violations: ["value must be an object"] };
10
+ }
11
+
12
+ const obj = value as Record<string, unknown>;
13
+
14
+ // Check apiVersion
15
+ if (typeof obj["apiVersion"] !== "string" || obj["apiVersion"] === "") {
16
+ violations.push("apiVersion is required");
17
+ }
18
+
19
+ // Check kind
20
+ if (typeof obj["kind"] !== "string" || obj["kind"] === "") {
21
+ violations.push("kind is required");
22
+ }
23
+
24
+ // Check metadata
25
+ if (obj["metadata"] === null || typeof obj["metadata"] !== "object") {
26
+ violations.push("metadata is required");
27
+ } else {
28
+ const metadata = obj["metadata"] as Record<string, unknown>;
29
+ if (typeof metadata["name"] !== "string" || metadata["name"] === "") {
30
+ violations.push("metadata.name is required");
31
+ }
32
+ }
33
+
34
+ if (violations.length > 0) {
35
+ return { ok: false, violations };
36
+ }
37
+
38
+ return {
39
+ ok: true,
40
+ value: obj as K8sResource,
41
+ };
42
+ }
43
+
44
+ export type ParseResult<T> =
45
+ | { ok: true; value: T }
46
+ | { ok: false; violations: Array<string> };
47
+
48
+ export function parseK8sResourceYaml(yaml: string): ParseResult<K8sResource> {
49
+ const parsed = parseYaml(yaml);
50
+ return parseK8sResource(parsed);
51
+ }
52
+
53
+ export function parseK8sResourceList(
54
+ value: unknown
55
+ ): ParseResult<Array<K8sResource>> {
56
+ if (value === null || typeof value !== "object") {
57
+ return { ok: false, violations: ["value must be an object"] };
58
+ }
59
+
60
+ const obj = value as Record<string, unknown>;
61
+ const items = obj["items"];
62
+ if (!Array.isArray(items)) {
63
+ return { ok: false, violations: ["items must be an array"] };
64
+ }
65
+
66
+ const violations: Array<string> = [];
67
+ const parsedItems: Array<K8sResource> = [];
68
+ for (const [i, item] of items.entries()) {
69
+ const result = parseK8sResource(item);
70
+ if (!result.ok) {
71
+ violations.push(
72
+ ...result.violations.map((v) => `items[${i}]: ${v}` satisfies string)
73
+ );
74
+ continue;
75
+ }
76
+ parsedItems.push(result.value);
77
+ }
78
+
79
+ if (violations.length > 0) {
80
+ return { ok: false, violations };
81
+ }
82
+
83
+ return { ok: true, value: parsedItems };
84
+ }
85
+
86
+ export function parseK8sResourceListYaml(
87
+ yaml: string
88
+ ): ParseResult<Array<K8sResource>> {
89
+ const parsed = parseYaml(yaml);
90
+ return parseK8sResourceList(parsed);
91
+ }
92
+
93
+ function parseK8sResourceFromESM(esm: ESM): ParseResult<K8sResource> {
94
+ const result = parseK8sResource(esm.default);
95
+ if (!result.ok) {
96
+ return { ok: false, violations: result.violations };
97
+ }
98
+ return { ok: true, value: result.value };
99
+ }
100
+
101
+ export async function parseK8sResourceAny(
102
+ value: unknown
103
+ ): Promise<ParseResult<K8sResource>> {
104
+ if (typeof value === "string") {
105
+ return parseK8sResourceYaml(value);
106
+ }
107
+ const awatedValue = await value;
108
+ if (isESM(awatedValue)) {
109
+ return parseK8sResourceFromESM(awatedValue);
110
+ }
111
+ return parseK8sResource(awatedValue);
112
+ }
113
+
114
+ export interface ESM {
115
+ readonly default: unknown;
116
+ }
117
+
118
+ function isESM(value: unknown): value is ESM {
119
+ return typeof value === "object" && value !== null && "default" in value;
120
+ }
@@ -0,0 +1,351 @@
1
+ import type { K8sResource } from "../apis";
2
+ import type { Recorder } from "../recording";
3
+ import { stringifyYaml } from "../yaml";
4
+
5
+ export interface KubectlContext {
6
+ readonly namespace?: undefined | string;
7
+ readonly context?: undefined | string;
8
+ readonly kubeconfig?: undefined | string;
9
+ /**
10
+ * Field manager name used for server-side apply operations.
11
+ *
12
+ * Ref: `kubectl apply --server-side --field-manager=<name>`
13
+ */
14
+ readonly fieldManagerName?: undefined | string;
15
+ }
16
+
17
+ export type KubectlPatch =
18
+ | Record<string, unknown>
19
+ | ReadonlyArray<unknown>
20
+ | string;
21
+
22
+ export type KubectlPatchType = "json" | "merge" | "strategic";
23
+
24
+ export interface KubectlPatchOptions {
25
+ readonly type?: undefined | KubectlPatchType;
26
+ readonly context?: undefined | KubectlContext;
27
+ }
28
+
29
+ export interface KubectlDeps {
30
+ readonly recorder: Recorder;
31
+ /**
32
+ * Working directory for kubectl command execution.
33
+ * Typically scenario/workspace root.
34
+ */
35
+ readonly cwd?: undefined | string;
36
+ }
37
+
38
+ /**
39
+ * Kubernetes CLI (kubectl) wrapper for executing kubectl commands.
40
+ */
41
+ export interface Kubectl {
42
+ /**
43
+ * Returns a new Kubectl instance with merged context settings.
44
+ * The returned instance inherits the current default context,
45
+ * with the provided overrides applied on top.
46
+ *
47
+ * @param overrideContext - Context settings to merge (namespace, context, kubeconfig)
48
+ * @returns A new Kubectl instance with the merged context
49
+ */
50
+ extends(overrideContext: KubectlContext): Kubectl;
51
+
52
+ /**
53
+ * Applies a Kubernetes resource using `kubectl apply -f -`.
54
+ * The resource is serialized to YAML and passed via stdin.
55
+ *
56
+ * @param resource - The K8s resource object to apply
57
+ * @param context - Optional context overrides for this call
58
+ * @returns The trimmed stdout from kubectl (e.g., "configmap/my-config created")
59
+ * @throws Error if kubectl exits with non-zero code
60
+ */
61
+ apply(resource: K8sResource, context?: KubectlContext): Promise<string>;
62
+
63
+ /**
64
+ * Applies the `status` subresource using server-side apply:
65
+ *
66
+ * `kubectl apply --server-side --field-manager=<name> --subresource=status -f -`
67
+ *
68
+ * @param resource - The K8s resource object (must include `status`)
69
+ * @param context - Optional context overrides for this call
70
+ * @returns The trimmed stdout from kubectl
71
+ * @throws Error if fieldManagerName is missing or kubectl exits with non-zero code
72
+ */
73
+ applyStatus(resource: K8sResource, context?: KubectlContext): Promise<string>;
74
+
75
+ /**
76
+ * Creates a Kubernetes resource using `kubectl create -f -`.
77
+ * The resource is serialized to YAML and passed via stdin.
78
+ *
79
+ * @param resource - The K8s resource object to create
80
+ * @param context - Optional context overrides for this call
81
+ * @returns The trimmed stdout from kubectl (e.g., "configmap/my-config created")
82
+ * @throws Error if kubectl exits with non-zero code
83
+ */
84
+ create(resource: K8sResource, context?: KubectlContext): Promise<string>;
85
+
86
+ /**
87
+ * Retrieves a Kubernetes resource using `kubectl get <resource>/<name> -o yaml`.
88
+ *
89
+ * @param type - The resource type (e.g., "Pod", "Pod.v1", "Some.v1.custom-resource-group.example.com")
90
+ * @param name - The name of the resource to get
91
+ * @param context - Optional context overrides for this call
92
+ * @returns The resource as YAML string
93
+ * @throws Error if kubectl exits with non-zero code
94
+ */
95
+ get(type: string, name: string, context?: KubectlContext): Promise<string>;
96
+
97
+ /**
98
+ * Lists Kubernetes resources using `kubectl get <resource> -o yaml`.
99
+ *
100
+ * @param type - The resource type (e.g., "pods", "deployments.v1.apps")
101
+ * @param context - Optional context overrides for this call
102
+ * @returns The resource list as YAML string
103
+ * @throws Error if kubectl exits with non-zero code
104
+ */
105
+ list(type: string, context?: KubectlContext): Promise<string>;
106
+
107
+ /**
108
+ * Patches a Kubernetes resource using `kubectl patch <resource>/<name>`.
109
+ *
110
+ * @param resource - The resource type (e.g., "configmap", "deployment.v1.apps")
111
+ * @param name - The name of the resource to patch
112
+ * @param patch - Patch body (object/array will be JSON-encoded)
113
+ * @param options - Optional patch options (type, context)
114
+ * @returns The trimmed stdout from kubectl (e.g., "configmap/my-config patched")
115
+ * @throws Error if kubectl exits with non-zero code
116
+ */
117
+ patch(
118
+ resource: string,
119
+ name: string,
120
+ patch: KubectlPatch,
121
+ options?: KubectlPatchOptions
122
+ ): Promise<string>;
123
+
124
+ /**
125
+ * Deletes a Kubernetes resource using `kubectl delete <resource>/<name>`.
126
+ *
127
+ * @param resource - The resource type (e.g., "configmap", "namespace")
128
+ * @param name - The name of the resource to delete
129
+ * @param context - Optional context overrides for this call
130
+ * @returns The trimmed stdout from kubectl (e.g., "configmap \"my-config\" deleted")
131
+ * @throws Error if kubectl exits with non-zero code
132
+ */
133
+ delete(
134
+ resource: string,
135
+ name: string,
136
+ context?: KubectlContext
137
+ ): Promise<string>;
138
+ }
139
+
140
+ export class RealKubectl implements Kubectl {
141
+ private readonly recorder: Recorder;
142
+ private readonly cwd: undefined | string;
143
+ private readonly defaultContext: KubectlContext;
144
+
145
+ constructor(deps: KubectlDeps, defaultContext: KubectlContext = {}) {
146
+ this.recorder = deps.recorder;
147
+ this.cwd = deps.cwd;
148
+ this.defaultContext = { fieldManagerName: "kest", ...defaultContext };
149
+ }
150
+
151
+ extends(overrideContext: KubectlContext): Kubectl {
152
+ return new RealKubectl(
153
+ { recorder: this.recorder, cwd: this.cwd },
154
+ {
155
+ ...this.defaultContext,
156
+ ...overrideContext,
157
+ }
158
+ );
159
+ }
160
+
161
+ async apply(
162
+ resource: K8sResource,
163
+ context?: KubectlContext
164
+ ): Promise<string> {
165
+ const yaml = stringifyYaml(resource);
166
+ return await this.runKubectl({
167
+ args: ["apply", "-f", "-"],
168
+ stdin: { content: yaml, language: "yaml" },
169
+ stdoutLanguage: "text",
170
+ stderrLanguage: "text",
171
+ overrideContext: context,
172
+ });
173
+ }
174
+
175
+ async applyStatus(
176
+ resource: K8sResource,
177
+ context?: KubectlContext
178
+ ): Promise<string> {
179
+ const yaml = stringifyYaml(resource);
180
+ const ctx = { ...this.defaultContext, ...context };
181
+ const fieldManagerName = ctx.fieldManagerName;
182
+ if (!fieldManagerName) {
183
+ throw new Error(
184
+ "kubectl applyStatus requires `fieldManagerName` to be set"
185
+ );
186
+ }
187
+ return await this.runKubectl({
188
+ args: [
189
+ "apply",
190
+ "--server-side",
191
+ "--field-manager",
192
+ fieldManagerName,
193
+ "--subresource=status",
194
+ "-f",
195
+ "-",
196
+ ],
197
+ stdin: { content: yaml, language: "yaml" },
198
+ stdoutLanguage: "text",
199
+ stderrLanguage: "text",
200
+ overrideContext: context,
201
+ });
202
+ }
203
+
204
+ async create(
205
+ resource: K8sResource,
206
+ context?: KubectlContext
207
+ ): Promise<string> {
208
+ const yaml = stringifyYaml(resource);
209
+ return await this.runKubectl({
210
+ args: ["create", "-f", "-"],
211
+ stdin: { content: yaml, language: "yaml" },
212
+ stdoutLanguage: "text",
213
+ stderrLanguage: "text",
214
+ overrideContext: context,
215
+ });
216
+ }
217
+
218
+ async get(
219
+ type: string,
220
+ name: string,
221
+ context?: KubectlContext
222
+ ): Promise<string> {
223
+ return await this.runKubectl({
224
+ args: ["get", type, name, "-o", "yaml"],
225
+ stdoutLanguage: "yaml",
226
+ stderrLanguage: "text",
227
+ overrideContext: context,
228
+ });
229
+ }
230
+
231
+ async list(type: string, context?: KubectlContext): Promise<string> {
232
+ return await this.runKubectl({
233
+ args: ["get", type, "-o", "yaml"],
234
+ stdoutLanguage: "yaml",
235
+ stderrLanguage: "text",
236
+ overrideContext: context,
237
+ });
238
+ }
239
+
240
+ async patch(
241
+ resource: string,
242
+ name: string,
243
+ patch: KubectlPatch,
244
+ options?: KubectlPatchOptions
245
+ ): Promise<string> {
246
+ const patchContent = stringifyPatch(patch);
247
+ const args = ["patch", `${resource}/${name}`] as [string, ...Array<string>];
248
+ if (options?.type) {
249
+ args.push("--type", options.type);
250
+ }
251
+ args.push("-p", patchContent);
252
+ return await this.runKubectl({
253
+ args,
254
+ stdoutLanguage: "text",
255
+ stderrLanguage: "text",
256
+ overrideContext: options?.context,
257
+ });
258
+ }
259
+
260
+ async delete(
261
+ resource: string,
262
+ name: string,
263
+ context?: KubectlContext
264
+ ): Promise<string> {
265
+ return await this.runKubectl({
266
+ args: ["delete", `${resource}/${name}`],
267
+ stdoutLanguage: "text",
268
+ stderrLanguage: "text",
269
+ overrideContext: context,
270
+ });
271
+ }
272
+
273
+ private async runKubectl(params: ExecParams): Promise<string> {
274
+ const cmd = "kubectl";
275
+ const ctx = { ...this.defaultContext, ...params.overrideContext };
276
+ const ctxArgs: Array<string> = [];
277
+ if (ctx.namespace) {
278
+ ctxArgs.push("-n", ctx.namespace);
279
+ }
280
+ if (ctx.context) {
281
+ ctxArgs.push("--context", ctx.context);
282
+ }
283
+ if (ctx.kubeconfig) {
284
+ ctxArgs.push("--kubeconfig", ctx.kubeconfig);
285
+ }
286
+ const args = [...params.args, ...ctxArgs];
287
+ const stdin = params.stdin?.content;
288
+ this.recorder.record("CommandRun", {
289
+ cmd,
290
+ args,
291
+ stdin,
292
+ stdinLanguage: params.stdin?.language,
293
+ });
294
+ const proc = Bun.spawn([cmd, ...args], {
295
+ ...(this.cwd && { cwd: this.cwd }),
296
+ stdin: stdin ? new TextEncoder().encode(stdin) : undefined,
297
+ stdout: "pipe",
298
+ stderr: "pipe",
299
+ });
300
+ await proc.exited;
301
+ const { exitCode, stdout, stderr } = {
302
+ exitCode: proc.exitCode ?? 0,
303
+ stdout: (await proc.stdout?.text())?.trim() ?? "",
304
+ stderr: (await proc.stderr?.text())?.trim() ?? "",
305
+ };
306
+ this.recorder.record("CommandResult", {
307
+ exitCode,
308
+ stdout,
309
+ stderr,
310
+ stdoutLanguage: params.stdoutLanguage,
311
+ stderrLanguage: params.stderrLanguage,
312
+ });
313
+ if (exitCode !== 0) {
314
+ const details = stderr || stdout;
315
+ const [subcommand] = params.args;
316
+ throw new Error(
317
+ `kubectl ${subcommand} failed (exit code ${exitCode})${
318
+ details ? `: ${details}` : ""
319
+ }`
320
+ );
321
+ }
322
+ return stdout;
323
+ }
324
+ }
325
+
326
+ interface ExecParams {
327
+ readonly args: readonly [subcommand: string, ...args: ReadonlyArray<string>];
328
+ readonly stdin?:
329
+ | undefined
330
+ | {
331
+ readonly language?: undefined | string;
332
+ readonly content: string;
333
+ };
334
+ readonly stdoutLanguage?: undefined | string;
335
+ readonly stderrLanguage?: undefined | string;
336
+ readonly overrideContext?: undefined | KubectlContext;
337
+ }
338
+
339
+ function stringifyPatch(patch: KubectlPatch): string {
340
+ if (typeof patch === "string") {
341
+ return patch;
342
+ }
343
+ return JSON.stringify(patch);
344
+ }
345
+
346
+ export function createKubectl(
347
+ deps: KubectlDeps,
348
+ context: KubectlContext = {}
349
+ ): Kubectl {
350
+ return new RealKubectl(deps, context);
351
+ }