@alchemy.run/kubernetes 0.0.0 → 2.0.0-beta.85
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 +201 -0
- package/README.md +41 -4
- package/lib/BuiltinAdapters.d.ts +43 -0
- package/lib/BuiltinAdapters.d.ts.map +1 -0
- package/lib/BuiltinAdapters.js +129 -0
- package/lib/BuiltinAdapters.js.map +1 -0
- package/lib/Client.d.ts +97 -0
- package/lib/Client.d.ts.map +1 -0
- package/lib/Client.js +312 -0
- package/lib/Client.js.map +1 -0
- package/lib/ClusterAdapter.d.ts +324 -0
- package/lib/ClusterAdapter.d.ts.map +1 -0
- package/lib/ClusterAdapter.js +65 -0
- package/lib/ClusterAdapter.js.map +1 -0
- package/lib/Connection.d.ts +210 -0
- package/lib/Connection.d.ts.map +1 -0
- package/lib/Connection.js +58 -0
- package/lib/Connection.js.map +1 -0
- package/lib/Deployment.d.ts +401 -0
- package/lib/Deployment.d.ts.map +1 -0
- package/lib/Deployment.js +550 -0
- package/lib/Deployment.js.map +1 -0
- package/lib/HelmChart.d.ts +147 -0
- package/lib/HelmChart.d.ts.map +1 -0
- package/lib/HelmChart.js +248 -0
- package/lib/HelmChart.js.map +1 -0
- package/lib/Job.d.ts +333 -0
- package/lib/Job.d.ts.map +1 -0
- package/lib/Job.js +443 -0
- package/lib/Job.js.map +1 -0
- package/lib/LocalCluster.d.ts +243 -0
- package/lib/LocalCluster.d.ts.map +1 -0
- package/lib/LocalCluster.js +359 -0
- package/lib/LocalCluster.js.map +1 -0
- package/lib/Manifest.d.ts +157 -0
- package/lib/Manifest.d.ts.map +1 -0
- package/lib/Manifest.js +205 -0
- package/lib/Manifest.js.map +1 -0
- package/lib/Objects.d.ts +60 -0
- package/lib/Objects.d.ts.map +1 -0
- package/lib/Objects.js +132 -0
- package/lib/Objects.js.map +1 -0
- package/lib/Providers.d.ts +24 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +36 -0
- package/lib/Providers.js.map +1 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +10 -0
- package/lib/index.js.map +1 -0
- package/lib/internal/declared.d.ts +19 -0
- package/lib/internal/declared.d.ts.map +1 -0
- package/lib/internal/declared.js +435 -0
- package/lib/internal/declared.js.map +1 -0
- package/lib/internal/helm.d.ts +65 -0
- package/lib/internal/helm.d.ts.map +1 -0
- package/lib/internal/helm.js +173 -0
- package/lib/internal/helm.js.map +1 -0
- package/lib/internal/kind.d.ts +17 -0
- package/lib/internal/kind.d.ts.map +1 -0
- package/lib/internal/kind.js +31 -0
- package/lib/internal/kind.js.map +1 -0
- package/lib/internal/kubeconfig.d.ts +79 -0
- package/lib/internal/kubeconfig.d.ts.map +1 -0
- package/lib/internal/kubeconfig.js +222 -0
- package/lib/internal/kubeconfig.js.map +1 -0
- package/lib/internal/registry.d.ts +44 -0
- package/lib/internal/registry.d.ts.map +1 -0
- package/lib/internal/registry.js +72 -0
- package/lib/internal/registry.js.map +1 -0
- package/lib/internal/workload.d.ts +127 -0
- package/lib/internal/workload.d.ts.map +1 -0
- package/lib/internal/workload.js +411 -0
- package/lib/internal/workload.js.map +1 -0
- package/package.json +55 -3
- package/src/BuiltinAdapters.ts +202 -0
- package/src/Client.ts +496 -0
- package/src/ClusterAdapter.ts +373 -0
- package/src/Connection.ts +234 -0
- package/src/Deployment.ts +872 -0
- package/src/HelmChart.ts +388 -0
- package/src/Job.ts +709 -0
- package/src/LocalCluster.ts +631 -0
- package/src/Manifest.ts +301 -0
- package/src/Objects.ts +213 -0
- package/src/Providers.ts +52 -0
- package/src/globals.d.ts +2 -0
- package/src/index.ts +9 -0
- package/src/internal/declared.ts +471 -0
- package/src/internal/helm.ts +245 -0
- package/src/internal/kind.ts +43 -0
- package/src/internal/kubeconfig.ts +364 -0
- package/src/internal/registry.ts +108 -0
- package/src/internal/workload.ts +504 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { unwrapRedacted } from "alchemy/Util/data";
|
|
2
|
+
import { collectRedactedSecrets, redactJson } from "alchemy/Util/Redaction";
|
|
3
|
+
/**
|
|
4
|
+
* Internal Helm rendering for `Kubernetes.HelmChart`.
|
|
5
|
+
*
|
|
6
|
+
* Renders a chart to plain Kubernetes objects with the local `helm` CLI
|
|
7
|
+
* (`helm template` — pure local templating, no cluster connection, no
|
|
8
|
+
* in-cluster release records), so the rendered objects flow through the same
|
|
9
|
+
* server-side-apply machinery as `Kubernetes.Manifest` and the workload platforms.
|
|
10
|
+
* Mirrors the `Docker` service's local-CLI dependency: the `helm` binary
|
|
11
|
+
* must be installed on the deploying machine (`HELM_BIN` overrides the
|
|
12
|
+
* binary path).
|
|
13
|
+
*/
|
|
14
|
+
import * as Config from "effect/Config";
|
|
15
|
+
import * as Data from "effect/Data";
|
|
16
|
+
import * as Effect from "effect/Effect";
|
|
17
|
+
import * as FileSystem from "effect/FileSystem";
|
|
18
|
+
import * as Path from "effect/Path";
|
|
19
|
+
import * as ChildProcess from "effect/process/ChildProcess";
|
|
20
|
+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
|
|
21
|
+
import * as Stream from "effect/Stream";
|
|
22
|
+
import * as YAML from "yaml";
|
|
23
|
+
import type { KubernetesObjectDefinition } from "../Objects.ts";
|
|
24
|
+
|
|
25
|
+
/** A Helm invocation or render failure (bad chart ref, template error, …). */
|
|
26
|
+
export class HelmError extends Data.TaggedError("HelmError")<{
|
|
27
|
+
readonly message: string;
|
|
28
|
+
}> {}
|
|
29
|
+
|
|
30
|
+
const HelmBin = Config.String("HELM_BIN").pipe(Effect.orElseSucceed(() => "helm"));
|
|
31
|
+
|
|
32
|
+
export interface RenderHelmChartOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Chart reference: a repository chart name (with `repo`), an
|
|
35
|
+
* `oci://` reference, or a local chart directory path.
|
|
36
|
+
*/
|
|
37
|
+
chart: string;
|
|
38
|
+
/** Classic chart repository URL (`--repo`). */
|
|
39
|
+
repo?: string | undefined;
|
|
40
|
+
/** Chart version (`--version`). */
|
|
41
|
+
version?: string | undefined;
|
|
42
|
+
/** Release name the chart's templates render with (`.Release.Name`). */
|
|
43
|
+
releaseName: string;
|
|
44
|
+
/** Namespace the chart renders into (`.Release.Namespace`). */
|
|
45
|
+
namespace: string;
|
|
46
|
+
/** Values passed to the chart (written to a temp values file). */
|
|
47
|
+
values?: Record<string, unknown> | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Render objects from the chart's `crds/` directory too
|
|
50
|
+
* (`--include-crds`).
|
|
51
|
+
* @default true
|
|
52
|
+
*/
|
|
53
|
+
includeCrds?: boolean | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Render a chart with `helm template --no-hooks` and parse the
|
|
58
|
+
* multi-document YAML output into object definitions. Every rendered object
|
|
59
|
+
* must carry `apiVersion`, `kind`, and `metadata.name` (server-side apply
|
|
60
|
+
* needs a name; `generateName`-only objects are rejected with a clear
|
|
61
|
+
* error). Helm lifecycle hooks are excluded from the result (see
|
|
62
|
+
* {@link isHelmHook}).
|
|
63
|
+
*/
|
|
64
|
+
export const renderHelmChart = Effect.fn(function* (options: RenderHelmChartOptions) {
|
|
65
|
+
const fs = yield* FileSystem.FileSystem;
|
|
66
|
+
const path = yield* Path.Path;
|
|
67
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
68
|
+
const bin = yield* HelmBin;
|
|
69
|
+
const secrets = yield* Effect.sync(() =>
|
|
70
|
+
options.values === undefined ? [] : collectRedactedSecrets(options.values),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// The values file holds unwrapped credentials. Close this scope when the
|
|
74
|
+
// render finishes, including on helm or parse failure, so the file is removed.
|
|
75
|
+
return yield* Effect.scoped(
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const args = [
|
|
78
|
+
"template",
|
|
79
|
+
options.releaseName,
|
|
80
|
+
options.chart,
|
|
81
|
+
"--namespace",
|
|
82
|
+
options.namespace,
|
|
83
|
+
// Helm lifecycle hooks (`helm.sh/hook`) only make sense under Helm's own
|
|
84
|
+
// release events; HelmChart server-side applies the render and does not
|
|
85
|
+
// implement hook timing/weights/delete policies, so a hook (e.g. a
|
|
86
|
+
// pre-delete uninstall Job) must never enter the managed-object graph.
|
|
87
|
+
"--no-hooks",
|
|
88
|
+
];
|
|
89
|
+
if (options.repo !== undefined) {
|
|
90
|
+
args.push("--repo", options.repo);
|
|
91
|
+
}
|
|
92
|
+
if (options.version !== undefined) {
|
|
93
|
+
args.push("--version", options.version);
|
|
94
|
+
}
|
|
95
|
+
if (options.includeCrds ?? true) {
|
|
96
|
+
args.push("--include-crds");
|
|
97
|
+
}
|
|
98
|
+
if (options.values !== undefined && Object.keys(options.values).length > 0) {
|
|
99
|
+
// JSON is valid YAML, so the literal values object round-trips through
|
|
100
|
+
// a temp values file without a YAML serializer.
|
|
101
|
+
const dir = yield* fs.makeTempDirectoryScoped({
|
|
102
|
+
prefix: "alchemy-helm-",
|
|
103
|
+
});
|
|
104
|
+
const valuesFile = path.join(dir, "values.json");
|
|
105
|
+
// JSON.stringify(Redacted) emits the display placeholder. Unwrap at the
|
|
106
|
+
// values file; the chart props stay wrapped for plan and state.
|
|
107
|
+
const valuesJson = yield* Effect.sync(() => JSON.stringify(unwrapRedacted(options.values)));
|
|
108
|
+
yield* fs.writeFileString(valuesFile, valuesJson);
|
|
109
|
+
args.push("--values", valuesFile);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result = yield* ChildProcess.make(bin, args, {
|
|
113
|
+
stdin: "ignore",
|
|
114
|
+
stdout: "pipe",
|
|
115
|
+
stderr: "pipe",
|
|
116
|
+
detached: false,
|
|
117
|
+
extendEnv: true,
|
|
118
|
+
}).pipe(
|
|
119
|
+
(command) => spawner.spawn(command),
|
|
120
|
+
Effect.flatMap((child) =>
|
|
121
|
+
Effect.all(
|
|
122
|
+
{
|
|
123
|
+
exitCode: child.exitCode,
|
|
124
|
+
stdout: child.stdout.pipe(Stream.decodeText, Stream.mkString),
|
|
125
|
+
stderr: child.stderr.pipe(Stream.decodeText, Stream.mkString),
|
|
126
|
+
},
|
|
127
|
+
{ concurrency: "unbounded" },
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
// Scope the child to this render so it isn't tied to (and killed by)
|
|
131
|
+
// an enclosing scope that closes before helm exits.
|
|
132
|
+
Effect.scoped,
|
|
133
|
+
// A spawn failure (almost always ENOENT) means the helm CLI itself is
|
|
134
|
+
// missing — a machine-setup problem, not a resource error.
|
|
135
|
+
Effect.catchCause((cause) =>
|
|
136
|
+
Effect.die(
|
|
137
|
+
new Error(
|
|
138
|
+
`Failed to run '${bin}': ${String(cause)}. Kubernetes.HelmChart renders charts with the local helm CLI; if it isn't installed, install it (https://helm.sh/docs/intro/install/) or point HELM_BIN at the binary.`,
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (result.exitCode !== 0) {
|
|
145
|
+
const detail =
|
|
146
|
+
`helm ${args.join(" ")} exited with code ${String(result.exitCode)}: ` +
|
|
147
|
+
result.stderr.trim();
|
|
148
|
+
return yield* Effect.failSync(
|
|
149
|
+
() => new HelmError({ message: redactJson(detail, secrets) }),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return yield* parseRenderedManifests(options.chart, result.stdout, secrets);
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether a rendered object is a Helm lifecycle hook (`helm.sh/hook`
|
|
160
|
+
* annotation). Hooks are executed by Helm at release events (pre-install,
|
|
161
|
+
* pre-delete, test, …); HelmChart has no release and no hook lifecycle, so
|
|
162
|
+
* they are filtered out rather than applied as ordinary objects.
|
|
163
|
+
*/
|
|
164
|
+
export const isHelmHook = (object: KubernetesObjectDefinition): boolean =>
|
|
165
|
+
typeof object.metadata.annotations?.["helm.sh/hook"] === "string";
|
|
166
|
+
|
|
167
|
+
const causeText = (cause: unknown): string =>
|
|
168
|
+
cause instanceof Error ? cause.message : String(cause);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Parse `helm template` output (multi-document YAML) into definitions.
|
|
172
|
+
* Hook-annotated objects are dropped — `renderHelmChart` already passes
|
|
173
|
+
* `--no-hooks`, but the parser enforces the invariant independently of the
|
|
174
|
+
* helm CLI's behavior.
|
|
175
|
+
*
|
|
176
|
+
* `secrets` scrubs credentials out of parser and validation diagnostics.
|
|
177
|
+
* Parsed objects stay plain; callers pass the same list to the request
|
|
178
|
+
* scrubber.
|
|
179
|
+
*/
|
|
180
|
+
export const parseRenderedManifests = (
|
|
181
|
+
chart: string,
|
|
182
|
+
rendered: string,
|
|
183
|
+
secrets: readonly string[] = [],
|
|
184
|
+
): Effect.Effect<Array<KubernetesObjectDefinition>, HelmError> =>
|
|
185
|
+
Effect.try({
|
|
186
|
+
try: () => parseRenderedManifestsSync(chart, rendered, secrets),
|
|
187
|
+
catch: (cause) =>
|
|
188
|
+
cause instanceof HelmError
|
|
189
|
+
? cause
|
|
190
|
+
: new HelmError({
|
|
191
|
+
message: redactJson(
|
|
192
|
+
`Failed to parse rendered manifests from chart '${chart}': ${causeText(cause)}`,
|
|
193
|
+
secrets,
|
|
194
|
+
),
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const parseRenderedManifestsSync = (
|
|
199
|
+
chart: string,
|
|
200
|
+
rendered: string,
|
|
201
|
+
secrets: readonly string[],
|
|
202
|
+
): Array<KubernetesObjectDefinition> => {
|
|
203
|
+
const fail = (message: string): never => {
|
|
204
|
+
throw new HelmError({ message: redactJson(message, secrets) });
|
|
205
|
+
};
|
|
206
|
+
// Helm 4 writes OCI pull metadata to stdout before the rendered YAML.
|
|
207
|
+
// Strip only that exact leading preamble; chart output remains subject to
|
|
208
|
+
// the same strict Kubernetes object validation below.
|
|
209
|
+
const manifests = chart.startsWith("oci://")
|
|
210
|
+
? rendered.replace(/^Pulled: [^\r\n]+\r?\nDigest: [^\r\n]+\r?\n(?=---(?:\r?\n|$))/, "")
|
|
211
|
+
: rendered;
|
|
212
|
+
const documents = YAML.parseAllDocuments(manifests);
|
|
213
|
+
const objects: Array<KubernetesObjectDefinition> = [];
|
|
214
|
+
for (const document of documents) {
|
|
215
|
+
// parseAllDocuments records syntax errors on the document instead of
|
|
216
|
+
// throwing. toJS() can still throw (unresolved aliases, alias cycles).
|
|
217
|
+
if (document.errors.length > 0) {
|
|
218
|
+
const detail = document.errors.map((error) => error.message).join("\n");
|
|
219
|
+
fail(`Failed to parse rendered manifests from chart '${chart}': ${detail}`);
|
|
220
|
+
}
|
|
221
|
+
const value: unknown = document.toJS();
|
|
222
|
+
// helm renders empty documents for templates that produce no output
|
|
223
|
+
// (conditionals, whitespace) — skip them.
|
|
224
|
+
if (value === null || value === undefined) continue;
|
|
225
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
226
|
+
fail(`Chart '${chart}' rendered a non-object YAML document: ${JSON.stringify(value)}`);
|
|
227
|
+
}
|
|
228
|
+
const object = value as Partial<KubernetesObjectDefinition>;
|
|
229
|
+
if (typeof object.apiVersion !== "string" || typeof object.kind !== "string") {
|
|
230
|
+
// Scrub the full JSON before truncating so a secret that crosses the
|
|
231
|
+
// 200-character boundary is removed entirely.
|
|
232
|
+
const snippet = redactJson(JSON.stringify(value), secrets).slice(0, 200);
|
|
233
|
+
fail(`Chart '${chart}' rendered an object without apiVersion/kind: ${snippet}`);
|
|
234
|
+
}
|
|
235
|
+
if (typeof object.metadata?.name !== "string") {
|
|
236
|
+
fail(
|
|
237
|
+
`Chart '${chart}' rendered a ${object.apiVersion}/${object.kind} without metadata.name — server-side apply requires a concrete name (generateName is not supported)`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
const definition = object as KubernetesObjectDefinition;
|
|
241
|
+
if (isHelmHook(definition)) continue;
|
|
242
|
+
objects.push(definition);
|
|
243
|
+
}
|
|
244
|
+
return objects;
|
|
245
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { KindClusterConfig, LocalClusterProps } from "../LocalCluster.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* containerd patch enabling the per-registry `hosts.toml` directory that
|
|
5
|
+
* points `localhost:<registryPort>` at the local registry container. The
|
|
6
|
+
* default on kind v0.27+ node images; kept for older ones
|
|
7
|
+
* (https://kind.sigs.k8s.io/docs/user/local-registry/).
|
|
8
|
+
*/
|
|
9
|
+
export const REGISTRY_CONTAINERD_PATCH = `[plugins."io.containerd.grpc.v1.cri".registry]
|
|
10
|
+
config_path = "/etc/containerd/certs.d"`;
|
|
11
|
+
|
|
12
|
+
/** The kind config fields of a `LocalCluster`'s props. */
|
|
13
|
+
export const kindConfigOf = ({
|
|
14
|
+
nodes,
|
|
15
|
+
networking,
|
|
16
|
+
featureGates,
|
|
17
|
+
runtimeConfig,
|
|
18
|
+
kubeadmConfigPatches,
|
|
19
|
+
kubeadmConfigPatchesJSON6902,
|
|
20
|
+
containerdConfigPatches,
|
|
21
|
+
containerdConfigPatchesJSON6902,
|
|
22
|
+
}: Partial<LocalClusterProps>): KindClusterConfig => ({
|
|
23
|
+
nodes,
|
|
24
|
+
networking,
|
|
25
|
+
featureGates,
|
|
26
|
+
runtimeConfig,
|
|
27
|
+
kubeadmConfigPatches,
|
|
28
|
+
kubeadmConfigPatchesJSON6902,
|
|
29
|
+
containerdConfigPatches,
|
|
30
|
+
containerdConfigPatchesJSON6902,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The kind cluster config `LocalCluster` passes to `kind create cluster`:
|
|
35
|
+
* the kind fields with the kind/apiVersion header and the registry
|
|
36
|
+
* containerd patch added. kind reads YAML, and JSON is valid YAML.
|
|
37
|
+
*/
|
|
38
|
+
export const kindClusterConfig = (config: KindClusterConfig) => ({
|
|
39
|
+
kind: "Cluster",
|
|
40
|
+
apiVersion: "kind.x-k8s.io/v1alpha4",
|
|
41
|
+
...JSON.parse(JSON.stringify(config)),
|
|
42
|
+
containerdConfigPatches: [...(config.containerdConfigPatches ?? []), REGISTRY_CONTAINERD_PATCH],
|
|
43
|
+
});
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal kubeconfig resolution for the built-in cluster adapters: parse a
|
|
3
|
+
* kubeconfig file, resolve a context to (cluster, user), and turn its auth
|
|
4
|
+
* stanza — static token, client certificate, or `exec` credential plugin —
|
|
5
|
+
* into a {@link ClusterTransport}. This is the "anything kubectl can reach"
|
|
6
|
+
* path: AKS/GKE/EKS CLI-managed contexts work through their exec plugins
|
|
7
|
+
* (`kubelogin`, `gke-gcloud-auth-plugin`, `aws eks get-token`).
|
|
8
|
+
*/
|
|
9
|
+
import * as Config from "effect/Config";
|
|
10
|
+
import * as Data from "effect/Data";
|
|
11
|
+
import * as Effect from "effect/Effect";
|
|
12
|
+
import * as FileSystem from "effect/FileSystem";
|
|
13
|
+
import * as Path from "effect/Path";
|
|
14
|
+
import * as ChildProcess from "effect/process/ChildProcess";
|
|
15
|
+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
|
|
16
|
+
import * as Stream from "effect/Stream";
|
|
17
|
+
import * as YAML from "yaml";
|
|
18
|
+
|
|
19
|
+
/** A kubeconfig file could not be read, parsed, or resolved. */
|
|
20
|
+
export class KubeConfigError extends Data.TaggedError("Kubernetes.KubeConfigError")<{
|
|
21
|
+
readonly message: string;
|
|
22
|
+
readonly cause?: unknown;
|
|
23
|
+
}> {}
|
|
24
|
+
|
|
25
|
+
/** An exec credential plugin invocation failed or answered malformed. */
|
|
26
|
+
export class ExecCredentialError extends Data.TaggedError("Kubernetes.ExecCredentialError")<{
|
|
27
|
+
readonly message: string;
|
|
28
|
+
readonly cause?: unknown;
|
|
29
|
+
}> {}
|
|
30
|
+
|
|
31
|
+
interface KubeConfigFile {
|
|
32
|
+
"current-context"?: string;
|
|
33
|
+
clusters?: {
|
|
34
|
+
name?: string;
|
|
35
|
+
cluster?: {
|
|
36
|
+
server?: string;
|
|
37
|
+
"certificate-authority"?: string;
|
|
38
|
+
"certificate-authority-data"?: string;
|
|
39
|
+
"insecure-skip-tls-verify"?: boolean;
|
|
40
|
+
};
|
|
41
|
+
}[];
|
|
42
|
+
users?: {
|
|
43
|
+
name?: string;
|
|
44
|
+
user?: KubeConfigUser;
|
|
45
|
+
}[];
|
|
46
|
+
contexts?: {
|
|
47
|
+
name?: string;
|
|
48
|
+
context?: { cluster?: string; user?: string };
|
|
49
|
+
}[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface KubeConfigUser {
|
|
53
|
+
token?: string;
|
|
54
|
+
tokenFile?: string;
|
|
55
|
+
"client-certificate"?: string;
|
|
56
|
+
"client-certificate-data"?: string;
|
|
57
|
+
"client-key"?: string;
|
|
58
|
+
"client-key-data"?: string;
|
|
59
|
+
exec?: {
|
|
60
|
+
command?: string;
|
|
61
|
+
args?: string[];
|
|
62
|
+
env?: { name?: string; value?: string }[];
|
|
63
|
+
apiVersion?: string;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The resolved connection material for one kubeconfig context. */
|
|
68
|
+
export interface ResolvedKubeContext {
|
|
69
|
+
endpoint: string;
|
|
70
|
+
certificateAuthorityData?: string;
|
|
71
|
+
insecureSkipTlsVerify?: boolean;
|
|
72
|
+
user: KubeConfigUser;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const decodeBase64 = (value: string) => Buffer.from(value, "base64").toString("utf8");
|
|
76
|
+
|
|
77
|
+
const expandHome = Effect.fn(function* (input: string) {
|
|
78
|
+
if (!input.startsWith("~")) return input;
|
|
79
|
+
const home = yield* Effect.sync(() => process.env.HOME ?? process.env.USERPROFILE);
|
|
80
|
+
if (!home) {
|
|
81
|
+
return yield* new KubeConfigError({
|
|
82
|
+
message: `Cannot expand '${input}': no HOME environment variable`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const path = yield* Path.Path;
|
|
86
|
+
return path.join(home, input.slice(1));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** Resolve the kubeconfig file path: explicit → `$KUBECONFIG` → default. */
|
|
90
|
+
export const resolveKubeConfigPath = Effect.fn(function* (explicit: string | undefined) {
|
|
91
|
+
if (explicit) return yield* expandHome(explicit);
|
|
92
|
+
const fromEnv = yield* Config.String("KUBECONFIG").pipe(Effect.orElseSucceed(() => undefined));
|
|
93
|
+
// $KUBECONFIG may be a path list; use the first entry like kubectl's
|
|
94
|
+
// effective-config merge order.
|
|
95
|
+
const candidate = fromEnv?.split(":")[0];
|
|
96
|
+
if (candidate) return yield* expandHome(candidate);
|
|
97
|
+
return yield* expandHome("~/.kube/config");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** Load a kubeconfig file and resolve one context to its cluster + user. */
|
|
101
|
+
export const resolveKubeContext = Effect.fn(function* (options: {
|
|
102
|
+
path?: string;
|
|
103
|
+
context?: string;
|
|
104
|
+
}) {
|
|
105
|
+
const fs = yield* FileSystem.FileSystem;
|
|
106
|
+
const configPath = yield* resolveKubeConfigPath(options.path);
|
|
107
|
+
|
|
108
|
+
const raw = yield* fs.readFileString(configPath).pipe(
|
|
109
|
+
Effect.mapError(
|
|
110
|
+
(cause) =>
|
|
111
|
+
new KubeConfigError({
|
|
112
|
+
message: `Failed to read kubeconfig at '${configPath}'`,
|
|
113
|
+
cause,
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const parsed = yield* Effect.try({
|
|
119
|
+
try: () => YAML.parse(raw) as KubeConfigFile,
|
|
120
|
+
catch: (cause) =>
|
|
121
|
+
new KubeConfigError({
|
|
122
|
+
message: `Failed to parse kubeconfig at '${configPath}'`,
|
|
123
|
+
cause,
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const contextName = options.context ?? parsed["current-context"];
|
|
128
|
+
if (!contextName) {
|
|
129
|
+
return yield* new KubeConfigError({
|
|
130
|
+
message: `Kubeconfig '${configPath}' has no current-context — pass an explicit context`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const context = parsed.contexts?.find((entry) => entry.name === contextName)?.context;
|
|
135
|
+
if (!context?.cluster) {
|
|
136
|
+
return yield* new KubeConfigError({
|
|
137
|
+
message: `Context '${contextName}' not found in kubeconfig '${configPath}'`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const cluster = parsed.clusters?.find((entry) => entry.name === context.cluster)?.cluster;
|
|
142
|
+
if (!cluster?.server) {
|
|
143
|
+
return yield* new KubeConfigError({
|
|
144
|
+
message: `Cluster '${context.cluster}' (context '${contextName}') has no server in kubeconfig '${configPath}'`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const user = parsed.users?.find((entry) => entry.name === context.user)?.user ?? {};
|
|
149
|
+
|
|
150
|
+
let certificateAuthorityData = cluster["certificate-authority-data"];
|
|
151
|
+
if (!certificateAuthorityData && cluster["certificate-authority"]) {
|
|
152
|
+
const caPath = yield* expandHome(cluster["certificate-authority"]);
|
|
153
|
+
const pem = yield* fs.readFileString(caPath).pipe(
|
|
154
|
+
Effect.mapError(
|
|
155
|
+
(cause) =>
|
|
156
|
+
new KubeConfigError({
|
|
157
|
+
message: `Failed to read certificate authority file '${caPath}'`,
|
|
158
|
+
cause,
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
certificateAuthorityData = Buffer.from(pem, "utf8").toString("base64");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
endpoint: cluster.server,
|
|
167
|
+
certificateAuthorityData,
|
|
168
|
+
insecureSkipTlsVerify: cluster["insecure-skip-tls-verify"],
|
|
169
|
+
user,
|
|
170
|
+
} satisfies ResolvedKubeContext;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
interface ExecCredentialStatus {
|
|
174
|
+
token?: string;
|
|
175
|
+
clientCertificateData?: string;
|
|
176
|
+
clientKeyData?: string;
|
|
177
|
+
expirationTimestamp?: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Credentials minted by a user's auth stanza for one request. */
|
|
181
|
+
export interface MintedCredentials {
|
|
182
|
+
token?: string;
|
|
183
|
+
clientCert?: { certificate: string; key: string };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Exec plugin invocations are cached per (command, args) until their
|
|
187
|
+
// expirationTimestamp (or briefly, when the plugin doesn't declare one).
|
|
188
|
+
// Plain cached values, no finalizers — module scope is safe.
|
|
189
|
+
const execCache = new Map<string, { credentials: MintedCredentials; expiresAt: number }>();
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Run a kubeconfig-style exec credential plugin and parse its
|
|
193
|
+
* ExecCredential response.
|
|
194
|
+
*/
|
|
195
|
+
export const runExecCredential = Effect.fn(function* (options: {
|
|
196
|
+
command: string;
|
|
197
|
+
args?: string[] | undefined;
|
|
198
|
+
env?: Record<string, string> | undefined;
|
|
199
|
+
}) {
|
|
200
|
+
const cacheKey = JSON.stringify([options.command, options.args ?? [], options.env ?? {}]);
|
|
201
|
+
const cached = execCache.get(cacheKey);
|
|
202
|
+
const now = yield* Effect.sync(() => Date.now());
|
|
203
|
+
if (cached && cached.expiresAt > now) {
|
|
204
|
+
return cached.credentials;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
208
|
+
// The child process is scoped to this invocation: spawn, drain stdio,
|
|
209
|
+
// and close the scope when the plugin exits.
|
|
210
|
+
const result = yield* ChildProcess.make(options.command, options.args ?? [], {
|
|
211
|
+
stdin: "ignore",
|
|
212
|
+
stdout: "pipe",
|
|
213
|
+
stderr: "pipe",
|
|
214
|
+
detached: false,
|
|
215
|
+
extendEnv: true,
|
|
216
|
+
env: options.env,
|
|
217
|
+
}).pipe(
|
|
218
|
+
(command) => spawner.spawn(command),
|
|
219
|
+
Effect.flatMap((child) =>
|
|
220
|
+
Effect.all(
|
|
221
|
+
{
|
|
222
|
+
exitCode: child.exitCode,
|
|
223
|
+
stdout: child.stdout.pipe(Stream.decodeText, Stream.mkString),
|
|
224
|
+
stderr: child.stderr.pipe(Stream.decodeText, Stream.mkString),
|
|
225
|
+
},
|
|
226
|
+
{ concurrency: "unbounded" },
|
|
227
|
+
),
|
|
228
|
+
),
|
|
229
|
+
Effect.scoped,
|
|
230
|
+
Effect.catchCause((cause) =>
|
|
231
|
+
Effect.fail(
|
|
232
|
+
new ExecCredentialError({
|
|
233
|
+
message: `Failed to run exec credential plugin '${options.command}': ${String(cause)}`,
|
|
234
|
+
}),
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
if (result.exitCode !== 0) {
|
|
240
|
+
return yield* new ExecCredentialError({
|
|
241
|
+
message:
|
|
242
|
+
`Exec credential plugin '${options.command} ${(options.args ?? []).join(" ")}' ` +
|
|
243
|
+
`exited with code ${String(result.exitCode)}: ${result.stderr.trim()}`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const status = yield* Effect.try({
|
|
248
|
+
try: () => (JSON.parse(result.stdout) as { status?: ExecCredentialStatus }).status,
|
|
249
|
+
catch: (cause) =>
|
|
250
|
+
new ExecCredentialError({
|
|
251
|
+
message: `Exec credential plugin '${options.command}' answered malformed JSON`,
|
|
252
|
+
cause,
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (!status || (!status.token && !(status.clientCertificateData && status.clientKeyData))) {
|
|
257
|
+
return yield* new ExecCredentialError({
|
|
258
|
+
message: `Exec credential plugin '${options.command}' returned no token or client certificate`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const credentials: MintedCredentials = {
|
|
263
|
+
token: status.token,
|
|
264
|
+
clientCert:
|
|
265
|
+
status.clientCertificateData && status.clientKeyData
|
|
266
|
+
? {
|
|
267
|
+
certificate: status.clientCertificateData,
|
|
268
|
+
key: status.clientKeyData,
|
|
269
|
+
}
|
|
270
|
+
: undefined,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const expiresAt = status.expirationTimestamp
|
|
274
|
+
? // Re-mint 30s before the declared expiry.
|
|
275
|
+
new Date(status.expirationTimestamp).getTime() - 30_000
|
|
276
|
+
: // No declared expiry: cache briefly so bursts of requests share one
|
|
277
|
+
// plugin invocation without holding a stale credential for long.
|
|
278
|
+
now + 60_000;
|
|
279
|
+
execCache.set(cacheKey, { credentials, expiresAt });
|
|
280
|
+
|
|
281
|
+
return credentials;
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/** Mint request credentials from a kubeconfig user's auth stanza. */
|
|
285
|
+
export const mintUserCredentials = Effect.fn(function* (user: KubeConfigUser) {
|
|
286
|
+
if (user.exec?.command) {
|
|
287
|
+
return yield* runExecCredential({
|
|
288
|
+
command: user.exec.command,
|
|
289
|
+
args: user.exec.args,
|
|
290
|
+
env: user.exec.env
|
|
291
|
+
? Object.fromEntries(
|
|
292
|
+
user.exec.env
|
|
293
|
+
.filter(
|
|
294
|
+
(entry): entry is { name: string; value: string } =>
|
|
295
|
+
typeof entry.name === "string" && typeof entry.value === "string",
|
|
296
|
+
)
|
|
297
|
+
.map((entry) => [entry.name, entry.value]),
|
|
298
|
+
)
|
|
299
|
+
: undefined,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (user.token) {
|
|
304
|
+
return { token: user.token } satisfies MintedCredentials;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (user.tokenFile) {
|
|
308
|
+
const fs = yield* FileSystem.FileSystem;
|
|
309
|
+
const tokenPath = yield* expandHome(user.tokenFile);
|
|
310
|
+
const token = yield* fs.readFileString(tokenPath).pipe(
|
|
311
|
+
Effect.mapError(
|
|
312
|
+
(cause) =>
|
|
313
|
+
new KubeConfigError({
|
|
314
|
+
message: `Failed to read token file '${tokenPath}'`,
|
|
315
|
+
cause,
|
|
316
|
+
}),
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
return { token: token.trim() } satisfies MintedCredentials;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const certData = user["client-certificate-data"];
|
|
323
|
+
const keyData = user["client-key-data"];
|
|
324
|
+
if (certData && keyData) {
|
|
325
|
+
return {
|
|
326
|
+
clientCert: {
|
|
327
|
+
certificate: decodeBase64(certData),
|
|
328
|
+
key: decodeBase64(keyData),
|
|
329
|
+
},
|
|
330
|
+
} satisfies MintedCredentials;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const certPath = user["client-certificate"];
|
|
334
|
+
const keyPath = user["client-key"];
|
|
335
|
+
if (certPath && keyPath) {
|
|
336
|
+
const fs = yield* FileSystem.FileSystem;
|
|
337
|
+
const readPem = (file: string) =>
|
|
338
|
+
expandHome(file).pipe(
|
|
339
|
+
Effect.flatMap((resolved) =>
|
|
340
|
+
fs.readFileString(resolved).pipe(
|
|
341
|
+
Effect.mapError(
|
|
342
|
+
(cause) =>
|
|
343
|
+
new KubeConfigError({
|
|
344
|
+
message: `Failed to read credential file '${resolved}'`,
|
|
345
|
+
cause,
|
|
346
|
+
}),
|
|
347
|
+
),
|
|
348
|
+
),
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
return {
|
|
352
|
+
clientCert: {
|
|
353
|
+
certificate: yield* readPem(certPath),
|
|
354
|
+
key: yield* readPem(keyPath),
|
|
355
|
+
},
|
|
356
|
+
} satisfies MintedCredentials;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return yield* new KubeConfigError({
|
|
360
|
+
message:
|
|
361
|
+
"Kubeconfig user has no supported auth stanza (token, tokenFile, " +
|
|
362
|
+
"client certificate, or exec credential plugin)",
|
|
363
|
+
});
|
|
364
|
+
});
|