@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.
- package/LICENSE +21 -0
- package/README.md +412 -0
- package/example/config-map.yaml +6 -0
- package/example/example-report.md +744 -0
- package/example/example.test.ts +225 -0
- package/example/hello-world-crd.yaml +145 -0
- package/package.json +62 -0
- package/ts/actions/apply-namespace.ts +29 -0
- package/ts/actions/apply-status.ts +23 -0
- package/ts/actions/apply.ts +25 -0
- package/ts/actions/assert-list.ts +63 -0
- package/ts/actions/assert.ts +34 -0
- package/ts/actions/exec.ts +21 -0
- package/ts/actions/get.ts +40 -0
- package/ts/actions/types.ts +48 -0
- package/ts/apis/index.ts +788 -0
- package/ts/bdd/index.ts +30 -0
- package/ts/duration/index.ts +171 -0
- package/ts/index.ts +3 -0
- package/ts/k8s-resource/index.ts +120 -0
- package/ts/kubectl/index.ts +351 -0
- package/ts/recording/index.ts +134 -0
- package/ts/reporter/index.ts +0 -0
- package/ts/reporter/interface.ts +5 -0
- package/ts/reporter/markdown.ts +962 -0
- package/ts/retry.ts +112 -0
- package/ts/reverting/index.ts +36 -0
- package/ts/scenario/index.ts +220 -0
- package/ts/test.ts +127 -0
- package/ts/workspace/find-up.ts +20 -0
- package/ts/workspace/index.ts +25 -0
- package/ts/yaml/index.ts +14 -0
package/ts/bdd/index.ts
ADDED
|
@@ -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,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
|
+
}
|