@alchemy.run/kubernetes 0.0.0 → 2.0.0-beta.84
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 +23 -0
- package/lib/BuiltinAdapters.d.ts.map +1 -0
- package/lib/BuiltinAdapters.js +124 -0
- package/lib/BuiltinAdapters.js.map +1 -0
- package/lib/Client.d.ts +80 -0
- package/lib/Client.d.ts.map +1 -0
- package/lib/Client.js +296 -0
- package/lib/Client.js.map +1 -0
- package/lib/ClusterAdapter.d.ts +322 -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 +382 -0
- package/lib/Deployment.d.ts.map +1 -0
- package/lib/Deployment.js +533 -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 +314 -0
- package/lib/Job.d.ts.map +1 -0
- package/lib/Job.js +426 -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 +148 -0
- package/lib/Manifest.d.ts.map +1 -0
- package/lib/Manifest.js +200 -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 +194 -0
- package/src/Client.ts +465 -0
- package/src/ClusterAdapter.ts +370 -0
- package/src/Connection.ts +234 -0
- package/src/Deployment.ts +851 -0
- package/src/HelmChart.ts +388 -0
- package/src/Job.ts +689 -0
- package/src/LocalCluster.ts +631 -0
- package/src/Manifest.ts +295 -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 +503 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { Unowned } from "alchemy/AdoptPolicy";
|
|
2
|
+
import { deepEqual, isResolved } from "alchemy/Diff";
|
|
3
|
+
import { createPhysicalName } from "alchemy/PhysicalName";
|
|
4
|
+
import * as Provider from "alchemy/Provider";
|
|
5
|
+
import { Resource } from "alchemy/Resource";
|
|
6
|
+
import { createInternalTags, hasAlchemyTags } from "alchemy/Tags";
|
|
7
|
+
import * as Config from "effect/Config";
|
|
8
|
+
import * as Data from "effect/Data";
|
|
9
|
+
import * as Effect from "effect/Effect";
|
|
10
|
+
import * as FileSystem from "effect/FileSystem";
|
|
11
|
+
import * as Path from "effect/Path";
|
|
12
|
+
import * as ChildProcess from "effect/process/ChildProcess";
|
|
13
|
+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
|
|
14
|
+
import * as Stream from "effect/Stream";
|
|
15
|
+
import { applyObject, connectCluster } from "./Client.ts";
|
|
16
|
+
import type { Connection, ContainerRegistry } from "./Connection.ts";
|
|
17
|
+
import { kindClusterConfig, kindConfigOf } from "./internal/kind.ts";
|
|
18
|
+
import type { Providers } from "./Providers.ts";
|
|
19
|
+
|
|
20
|
+
export interface LocalClusterProps extends KindClusterConfig {
|
|
21
|
+
/**
|
|
22
|
+
* Name of the kind cluster. Its kubeconfig context is `kind-<name>`.
|
|
23
|
+
* Changing it replaces the cluster.
|
|
24
|
+
* @default a name generated from the stack, stage, and logical ID
|
|
25
|
+
*/
|
|
26
|
+
name?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Host port of the cluster's image registry, reachable from the deploying
|
|
29
|
+
* machine as `localhost:<registryPort>`. Give each local cluster its own
|
|
30
|
+
* port.
|
|
31
|
+
* @default 5001
|
|
32
|
+
*/
|
|
33
|
+
registryPort?: number;
|
|
34
|
+
/**
|
|
35
|
+
* The kind node image, which selects the Kubernetes version (e.g.
|
|
36
|
+
* `kindest/node:v1.33.1`). A node's own `image` overrides it. Changing it
|
|
37
|
+
* replaces the cluster.
|
|
38
|
+
* @default kind's default node image
|
|
39
|
+
*/
|
|
40
|
+
nodeImage?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The kubeconfig file kind writes the cluster's context into. Changing it
|
|
43
|
+
* replaces the cluster.
|
|
44
|
+
* @default `$KUBECONFIG` or `~/.kube/config`
|
|
45
|
+
*/
|
|
46
|
+
kubeconfig?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The fields of a [kind `Cluster` config](https://kind.sigs.k8s.io/docs/user/configuration/)
|
|
51
|
+
* (`kind.x-k8s.io/v1alpha4`), accepted directly on `LocalCluster`. kind
|
|
52
|
+
* can't reconfigure a running cluster, so changing any of them replaces the
|
|
53
|
+
* cluster.
|
|
54
|
+
*/
|
|
55
|
+
export interface KindClusterConfig {
|
|
56
|
+
/**
|
|
57
|
+
* The cluster's nodes.
|
|
58
|
+
* @default a single control-plane node
|
|
59
|
+
*/
|
|
60
|
+
nodes?: KindNode[];
|
|
61
|
+
/** Cluster networking. */
|
|
62
|
+
networking?: KindNetworking;
|
|
63
|
+
/** Kubernetes feature gates, e.g. `{ InPlacePodVerticalScaling: true }`. */
|
|
64
|
+
featureGates?: Record<string, boolean>;
|
|
65
|
+
/** API server `--runtime-config` entries, e.g. `{ "api/alpha": "false" }`. */
|
|
66
|
+
runtimeConfig?: Record<string, string>;
|
|
67
|
+
/** kubeadm config patches (YAML strategic-merge) applied to every node. */
|
|
68
|
+
kubeadmConfigPatches?: string[];
|
|
69
|
+
/** kubeadm config patches (RFC 6902 JSON patches) applied to every node. */
|
|
70
|
+
kubeadmConfigPatchesJSON6902?: KindJsonPatch[];
|
|
71
|
+
/**
|
|
72
|
+
* containerd config patches (TOML). Alchemy appends its own for the image
|
|
73
|
+
* registry.
|
|
74
|
+
*/
|
|
75
|
+
containerdConfigPatches?: string[];
|
|
76
|
+
/** containerd config patches (RFC 6902 JSON patches). */
|
|
77
|
+
containerdConfigPatchesJSON6902?: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A node in a kind cluster. */
|
|
81
|
+
export interface KindNode {
|
|
82
|
+
/** Whether the node runs the control plane or only workloads. */
|
|
83
|
+
role: "control-plane" | "worker";
|
|
84
|
+
/** Node image for this node, overriding `nodeImage`. */
|
|
85
|
+
image?: string;
|
|
86
|
+
/** Kubernetes labels for the node. */
|
|
87
|
+
labels?: Record<string, string>;
|
|
88
|
+
/** Host ports forwarded to the node container. */
|
|
89
|
+
extraPortMappings?: KindPortMapping[];
|
|
90
|
+
/** Host paths mounted into the node container. */
|
|
91
|
+
extraMounts?: KindMount[];
|
|
92
|
+
/** kubeadm config patches (YAML strategic-merge) for this node. */
|
|
93
|
+
kubeadmConfigPatches?: string[];
|
|
94
|
+
/** kubeadm config patches (RFC 6902 JSON patches) for this node. */
|
|
95
|
+
kubeadmConfigPatchesJSON6902?: KindJsonPatch[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A host port forwarded to a kind node container. */
|
|
99
|
+
export interface KindPortMapping {
|
|
100
|
+
/** Port on the node container, e.g. a NodePort or hostPort. */
|
|
101
|
+
containerPort: number;
|
|
102
|
+
/** Port on the host. */
|
|
103
|
+
hostPort: number;
|
|
104
|
+
/**
|
|
105
|
+
* Host address to listen on.
|
|
106
|
+
* @default "0.0.0.0"
|
|
107
|
+
*/
|
|
108
|
+
listenAddress?: string;
|
|
109
|
+
/** @default "TCP" */
|
|
110
|
+
protocol?: "TCP" | "UDP" | "SCTP";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A host path mounted into a kind node container. */
|
|
114
|
+
export interface KindMount {
|
|
115
|
+
/** Path on the host. */
|
|
116
|
+
hostPath: string;
|
|
117
|
+
/** Path in the node container. */
|
|
118
|
+
containerPath: string;
|
|
119
|
+
readOnly?: boolean;
|
|
120
|
+
/** Relabel the mount for SELinux hosts. */
|
|
121
|
+
selinuxRelabel?: boolean;
|
|
122
|
+
propagation?: "None" | "HostToContainer" | "Bidirectional";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Cluster networking settings for kind. */
|
|
126
|
+
export interface KindNetworking {
|
|
127
|
+
ipFamily?: "ipv4" | "ipv6" | "dual";
|
|
128
|
+
/**
|
|
129
|
+
* Host port of the API server.
|
|
130
|
+
* @default a random port
|
|
131
|
+
*/
|
|
132
|
+
apiServerPort?: number;
|
|
133
|
+
/**
|
|
134
|
+
* Host address the API server listens on.
|
|
135
|
+
* @default "127.0.0.1"
|
|
136
|
+
*/
|
|
137
|
+
apiServerAddress?: string;
|
|
138
|
+
/** Pod network CIDR. */
|
|
139
|
+
podSubnet?: string;
|
|
140
|
+
/** Service network CIDR. */
|
|
141
|
+
serviceSubnet?: string;
|
|
142
|
+
/** Skip kind's default CNI so you can install your own. */
|
|
143
|
+
disableDefaultCNI?: boolean;
|
|
144
|
+
kubeProxyMode?: "iptables" | "ipvs" | "nftables" | "none";
|
|
145
|
+
/** DNS search domains for nodes. */
|
|
146
|
+
dnsSearch?: string[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** An RFC 6902 JSON patch targeting a kubeadm config object. */
|
|
150
|
+
export interface KindJsonPatch {
|
|
151
|
+
group: string;
|
|
152
|
+
version: string;
|
|
153
|
+
kind: string;
|
|
154
|
+
/** The JSON patch document, as YAML or JSON. */
|
|
155
|
+
patch: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface LocalCluster extends Resource<
|
|
159
|
+
"Kubernetes.LocalCluster",
|
|
160
|
+
LocalClusterProps,
|
|
161
|
+
{
|
|
162
|
+
/** The kind cluster name. */
|
|
163
|
+
name: string;
|
|
164
|
+
/** The kubeconfig context of the cluster (`kind-<name>`). */
|
|
165
|
+
context: string;
|
|
166
|
+
/** The kubeconfig file holding the context, if not the default. */
|
|
167
|
+
kubeconfig: string | undefined;
|
|
168
|
+
/** The cluster's image registry (`localhost:<registryPort>`). */
|
|
169
|
+
registry: ContainerRegistry;
|
|
170
|
+
/** Name of the registry's Docker container. */
|
|
171
|
+
registryContainer: string;
|
|
172
|
+
/** CPU architecture of the cluster's nodes (the Docker host's). */
|
|
173
|
+
architecture: "amd64" | "arm64";
|
|
174
|
+
/**
|
|
175
|
+
* Connection for `Kubernetes.*` resources, including the registry and
|
|
176
|
+
* node architecture. Pass the whole resource as their `cluster`.
|
|
177
|
+
*/
|
|
178
|
+
connection: Connection;
|
|
179
|
+
},
|
|
180
|
+
{},
|
|
181
|
+
Providers
|
|
182
|
+
> {}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A Kubernetes cluster on your machine, for trying Alchemy's Kubernetes
|
|
186
|
+
* resources and developing against them without a hosted cluster.
|
|
187
|
+
*
|
|
188
|
+
* `LocalCluster` runs a [kind](https://kind.sigs.k8s.io) cluster in Docker
|
|
189
|
+
* together with a local image registry wired into the cluster's nodes, so
|
|
190
|
+
* workloads built from a `main` Effect program or a `context` Dockerfile
|
|
191
|
+
* work the same way they do on a hosted cluster with a registry. Pass the
|
|
192
|
+
* resource as the `cluster` of any `Kubernetes.*` resource.
|
|
193
|
+
*
|
|
194
|
+
* Requires Docker and the `kind` CLI on the deploying machine (set
|
|
195
|
+
* `KIND_BIN` to use a specific binary). The cluster's kubeconfig context,
|
|
196
|
+
* `kind-<name>`, works with `kubectl`.
|
|
197
|
+
*
|
|
198
|
+
* ### Creating a Local Cluster
|
|
199
|
+
* **Example:** Cluster and a Deployment
|
|
200
|
+
* ```typescript
|
|
201
|
+
* const cluster = yield* Kubernetes.LocalCluster("Cluster", {
|
|
202
|
+
* name: "alchemy",
|
|
203
|
+
* });
|
|
204
|
+
*
|
|
205
|
+
* const web = yield* Kubernetes.Deployment("Web", {
|
|
206
|
+
* cluster,
|
|
207
|
+
* image: "ghcr.io/stefanprodan/podinfo:6.15.0",
|
|
208
|
+
* port: 9898,
|
|
209
|
+
* serviceType: "ClusterIP",
|
|
210
|
+
* });
|
|
211
|
+
* ```
|
|
212
|
+
*
|
|
213
|
+
* ### Effect Programs
|
|
214
|
+
* **Example:** Run an Effect program as a Job
|
|
215
|
+
* ```typescript
|
|
216
|
+
* const cluster = yield* Kubernetes.LocalCluster("Cluster", {
|
|
217
|
+
* name: "alchemy",
|
|
218
|
+
* });
|
|
219
|
+
*
|
|
220
|
+
* const hello = yield* Kubernetes.Job(
|
|
221
|
+
* "Hello",
|
|
222
|
+
* { cluster, main: import.meta.url },
|
|
223
|
+
* Effect.gen(function* () {
|
|
224
|
+
* return {
|
|
225
|
+
* run: Effect.log("hello from a Job"),
|
|
226
|
+
* };
|
|
227
|
+
* }),
|
|
228
|
+
* );
|
|
229
|
+
* ```
|
|
230
|
+
*
|
|
231
|
+
* ### Kind Configuration
|
|
232
|
+
* **Example:** Forward a host port to a NodePort
|
|
233
|
+
* ```typescript
|
|
234
|
+
* const cluster = yield* Kubernetes.LocalCluster("Cluster", {
|
|
235
|
+
* name: "alchemy",
|
|
236
|
+
* nodes: [
|
|
237
|
+
* {
|
|
238
|
+
* role: "control-plane",
|
|
239
|
+
* extraPortMappings: [{ containerPort: 30080, hostPort: 8080 }],
|
|
240
|
+
* },
|
|
241
|
+
* ],
|
|
242
|
+
* });
|
|
243
|
+
* ```
|
|
244
|
+
*
|
|
245
|
+
* ### Registry Port
|
|
246
|
+
* **Example:** Two local clusters
|
|
247
|
+
* ```typescript
|
|
248
|
+
* const blue = yield* Kubernetes.LocalCluster("Blue", { name: "blue" });
|
|
249
|
+
* const green = yield* Kubernetes.LocalCluster("Green", {
|
|
250
|
+
* name: "green",
|
|
251
|
+
* registryPort: 5002,
|
|
252
|
+
* });
|
|
253
|
+
* ```
|
|
254
|
+
*
|
|
255
|
+
* @resource
|
|
256
|
+
* @product LocalCluster
|
|
257
|
+
*/
|
|
258
|
+
export const LocalCluster = Resource<LocalCluster>("Kubernetes.LocalCluster");
|
|
259
|
+
|
|
260
|
+
export class LocalClusterError extends Data.TaggedError("Kubernetes.LocalClusterError")<{
|
|
261
|
+
message: string;
|
|
262
|
+
}> {}
|
|
263
|
+
|
|
264
|
+
const KindBin = Config.String("KIND_BIN").pipe(Effect.orElseSucceed(() => "kind"));
|
|
265
|
+
const DockerBin = Config.String("DOCKER_BIN").pipe(Effect.orElseSucceed(() => "docker"));
|
|
266
|
+
|
|
267
|
+
const REGISTRY_IMAGE = "registry:3";
|
|
268
|
+
const DEFAULT_REGISTRY_PORT = 5001;
|
|
269
|
+
|
|
270
|
+
interface CommandResult {
|
|
271
|
+
exitCode: number;
|
|
272
|
+
stdout: string;
|
|
273
|
+
stderr: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const toArchitecture = (arch: string): "amd64" | "arm64" =>
|
|
277
|
+
/^(arm64|aarch64)$/i.test(arch.trim()) ? "arm64" : "amd64";
|
|
278
|
+
|
|
279
|
+
const sanitizeName = (name: string) =>
|
|
280
|
+
name
|
|
281
|
+
.toLowerCase()
|
|
282
|
+
.replaceAll(/[^a-z0-9-]/g, "-")
|
|
283
|
+
.replaceAll(/^-+|-+$/g, "");
|
|
284
|
+
|
|
285
|
+
interface RegistryInspect {
|
|
286
|
+
State?: { Running?: boolean };
|
|
287
|
+
Config?: { Labels?: Record<string, string> | null };
|
|
288
|
+
HostConfig?: {
|
|
289
|
+
PortBindings?: Record<string, Array<{ HostPort?: string }> | null> | null;
|
|
290
|
+
};
|
|
291
|
+
NetworkSettings?: { Networks?: Record<string, unknown> | null };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const LocalClusterProvider = () =>
|
|
295
|
+
Provider.effect(
|
|
296
|
+
LocalCluster,
|
|
297
|
+
Effect.gen(function* () {
|
|
298
|
+
const fs = yield* FileSystem.FileSystem;
|
|
299
|
+
const path = yield* Path.Path;
|
|
300
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
301
|
+
|
|
302
|
+
/** Run a CLI, returning its exit code and output. */
|
|
303
|
+
const run = (bin: string, args: string[], stdin?: string) =>
|
|
304
|
+
ChildProcess.make(bin, args, {
|
|
305
|
+
stdin: stdin === undefined ? "ignore" : Stream.succeed(new TextEncoder().encode(stdin)),
|
|
306
|
+
stdout: "pipe",
|
|
307
|
+
stderr: "pipe",
|
|
308
|
+
detached: false,
|
|
309
|
+
extendEnv: true,
|
|
310
|
+
}).pipe(
|
|
311
|
+
spawner.spawn,
|
|
312
|
+
Effect.flatMap((child) =>
|
|
313
|
+
Effect.all(
|
|
314
|
+
{
|
|
315
|
+
exitCode: child.exitCode,
|
|
316
|
+
stdout: child.stdout.pipe(Stream.decodeText, Stream.mkString),
|
|
317
|
+
stderr: child.stderr.pipe(Stream.decodeText, Stream.mkString),
|
|
318
|
+
},
|
|
319
|
+
{ concurrency: "unbounded" },
|
|
320
|
+
),
|
|
321
|
+
),
|
|
322
|
+
Effect.map((result): CommandResult => ({
|
|
323
|
+
exitCode: Number(result.exitCode),
|
|
324
|
+
stdout: result.stdout.trim(),
|
|
325
|
+
stderr: result.stderr.trim(),
|
|
326
|
+
})),
|
|
327
|
+
Effect.scoped,
|
|
328
|
+
// A spawn failure (almost always ENOENT) means the CLI itself is
|
|
329
|
+
// missing — a machine-setup problem, not a resource error.
|
|
330
|
+
Effect.catchCause((cause) =>
|
|
331
|
+
Effect.die(
|
|
332
|
+
new Error(
|
|
333
|
+
`Failed to run '${bin}': ${String(cause)}. ` +
|
|
334
|
+
"Kubernetes.LocalCluster needs Docker and the kind CLI " +
|
|
335
|
+
"(https://kind.sigs.k8s.io/docs/user/quick-start/#installation). " +
|
|
336
|
+
"Set KIND_BIN or DOCKER_BIN to use specific binaries.",
|
|
337
|
+
),
|
|
338
|
+
),
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
/** Run a CLI and fail with its stderr on a non-zero exit. */
|
|
343
|
+
const exec = Effect.fn(function* (bin: string, args: string[], stdin?: string) {
|
|
344
|
+
const result = yield* run(bin, args, stdin);
|
|
345
|
+
if (result.exitCode !== 0) {
|
|
346
|
+
return yield* new LocalClusterError({
|
|
347
|
+
message: `${bin} ${args.join(" ")} exited with code ${result.exitCode}: ${result.stderr}`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return result;
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const kind = Effect.fn(function* (args: string[]) {
|
|
354
|
+
return yield* exec(yield* KindBin, args);
|
|
355
|
+
});
|
|
356
|
+
const docker = Effect.fn(function* (args: string[], stdin?: string) {
|
|
357
|
+
return yield* exec(yield* DockerBin, args, stdin);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const kubeconfigArgs = (kubeconfig: string | undefined) =>
|
|
361
|
+
kubeconfig !== undefined ? ["--kubeconfig", kubeconfig] : [];
|
|
362
|
+
|
|
363
|
+
const clusterName = (
|
|
364
|
+
id: string,
|
|
365
|
+
props: LocalClusterProps | undefined,
|
|
366
|
+
output: LocalCluster["Attributes"] | undefined,
|
|
367
|
+
) =>
|
|
368
|
+
output?.name !== undefined
|
|
369
|
+
? Effect.succeed(output.name)
|
|
370
|
+
: props?.name !== undefined
|
|
371
|
+
? Effect.succeed(sanitizeName(props.name))
|
|
372
|
+
: createPhysicalName({ id, maxLength: 40, lowercase: true }).pipe(
|
|
373
|
+
Effect.map(sanitizeName),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const clusterExists = Effect.fn(function* (name: string) {
|
|
377
|
+
const { stdout } = yield* kind(["get", "clusters"]);
|
|
378
|
+
return stdout.split(/\r?\n/).some((line) => line.trim() === name);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const inspectRegistry = Effect.fn(function* (container: string) {
|
|
382
|
+
const result = yield* run(yield* DockerBin, ["container", "inspect", container]);
|
|
383
|
+
if (result.exitCode !== 0) return undefined;
|
|
384
|
+
const [info] = JSON.parse(result.stdout) as RegistryInspect[];
|
|
385
|
+
return info;
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const registryHostPort = (info: RegistryInspect | undefined) => {
|
|
389
|
+
const binding = info?.HostConfig?.PortBindings?.["5000/tcp"]?.[0];
|
|
390
|
+
return binding?.HostPort !== undefined ? Number(binding.HostPort) : undefined;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const hostArchitecture = Effect.gen(function* () {
|
|
394
|
+
const { stdout } = yield* docker(["version", "--format", "{{.Server.Arch}}"]);
|
|
395
|
+
return toArchitecture(stdout);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const toAttributes = (options: {
|
|
399
|
+
name: string;
|
|
400
|
+
kubeconfig: string | undefined;
|
|
401
|
+
registryPort: number;
|
|
402
|
+
architecture: "amd64" | "arm64";
|
|
403
|
+
}): LocalCluster["Attributes"] => {
|
|
404
|
+
const context = `kind-${options.name}`;
|
|
405
|
+
const registry = { server: `localhost:${options.registryPort}` };
|
|
406
|
+
return {
|
|
407
|
+
name: options.name,
|
|
408
|
+
context,
|
|
409
|
+
kubeconfig: options.kubeconfig,
|
|
410
|
+
registry,
|
|
411
|
+
registryContainer: `${options.name}-registry`,
|
|
412
|
+
architecture: options.architecture,
|
|
413
|
+
connection: {
|
|
414
|
+
auth: {
|
|
415
|
+
kind: "kubeconfig",
|
|
416
|
+
path: options.kubeconfig,
|
|
417
|
+
context,
|
|
418
|
+
},
|
|
419
|
+
registry,
|
|
420
|
+
architecture: options.architecture,
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/** Ensure the kind cluster exists and its context is in the kubeconfig. */
|
|
426
|
+
const ensureCluster = Effect.fn(function* (options: {
|
|
427
|
+
name: string;
|
|
428
|
+
nodeImage: string | undefined;
|
|
429
|
+
kubeconfig: string | undefined;
|
|
430
|
+
config: KindClusterConfig;
|
|
431
|
+
session: { note: (message: string) => Effect.Effect<void> };
|
|
432
|
+
}) {
|
|
433
|
+
if (!(yield* clusterExists(options.name))) {
|
|
434
|
+
yield* options.session.note(
|
|
435
|
+
`Creating kind cluster ${options.name} (about 30 seconds)...`,
|
|
436
|
+
);
|
|
437
|
+
const dir = yield* fs.makeTempDirectory({
|
|
438
|
+
prefix: "alchemy-kind-",
|
|
439
|
+
});
|
|
440
|
+
const configFile = path.join(dir, "kind.yaml");
|
|
441
|
+
yield* fs.writeFileString(
|
|
442
|
+
configFile,
|
|
443
|
+
JSON.stringify(kindClusterConfig(options.config), null, 2),
|
|
444
|
+
);
|
|
445
|
+
yield* kind([
|
|
446
|
+
"create",
|
|
447
|
+
"cluster",
|
|
448
|
+
"--name",
|
|
449
|
+
options.name,
|
|
450
|
+
"--config",
|
|
451
|
+
configFile,
|
|
452
|
+
"--wait",
|
|
453
|
+
"120s",
|
|
454
|
+
...(options.nodeImage !== undefined ? ["--image", options.nodeImage] : []),
|
|
455
|
+
...kubeconfigArgs(options.kubeconfig),
|
|
456
|
+
]).pipe(
|
|
457
|
+
// A concurrent create of the same cluster is a race, not a failure.
|
|
458
|
+
Effect.catchTag("Kubernetes.LocalClusterError", (error) =>
|
|
459
|
+
/already exist/i.test(error.message) ? Effect.void : Effect.fail(error),
|
|
460
|
+
),
|
|
461
|
+
);
|
|
462
|
+
yield* fs.remove(dir, { recursive: true });
|
|
463
|
+
}
|
|
464
|
+
// Restore the context if it was removed from the kubeconfig.
|
|
465
|
+
yield* kind([
|
|
466
|
+
"export",
|
|
467
|
+
"kubeconfig",
|
|
468
|
+
"--name",
|
|
469
|
+
options.name,
|
|
470
|
+
...kubeconfigArgs(options.kubeconfig),
|
|
471
|
+
]);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Ensure the registry container runs on the desired port, sits on the
|
|
476
|
+
* `kind` network, and every node's containerd resolves
|
|
477
|
+
* `localhost:<port>` to it.
|
|
478
|
+
*/
|
|
479
|
+
const ensureRegistry = Effect.fn(function* (options: {
|
|
480
|
+
id: string;
|
|
481
|
+
name: string;
|
|
482
|
+
registryPort: number;
|
|
483
|
+
session: { note: (message: string) => Effect.Effect<void> };
|
|
484
|
+
}) {
|
|
485
|
+
const container = `${options.name}-registry`;
|
|
486
|
+
let info = yield* inspectRegistry(container);
|
|
487
|
+
if (info !== undefined && registryHostPort(info) !== options.registryPort) {
|
|
488
|
+
yield* docker(["container", "rm", "--force", container]);
|
|
489
|
+
info = undefined;
|
|
490
|
+
}
|
|
491
|
+
if (info === undefined) {
|
|
492
|
+
yield* options.session.note(
|
|
493
|
+
`Starting image registry on localhost:${options.registryPort}...`,
|
|
494
|
+
);
|
|
495
|
+
const labels = yield* createInternalTags(options.id);
|
|
496
|
+
yield* docker([
|
|
497
|
+
"container",
|
|
498
|
+
"run",
|
|
499
|
+
"--detach",
|
|
500
|
+
"--restart=always",
|
|
501
|
+
"--publish",
|
|
502
|
+
`127.0.0.1:${options.registryPort}:5000`,
|
|
503
|
+
"--network",
|
|
504
|
+
"bridge",
|
|
505
|
+
"--name",
|
|
506
|
+
container,
|
|
507
|
+
...Object.entries(labels).flatMap(([key, value]) => ["--label", `${key}=${value}`]),
|
|
508
|
+
REGISTRY_IMAGE,
|
|
509
|
+
]);
|
|
510
|
+
info = yield* inspectRegistry(container);
|
|
511
|
+
} else if (info.State?.Running !== true) {
|
|
512
|
+
yield* docker(["container", "start", container]);
|
|
513
|
+
}
|
|
514
|
+
if (info?.NetworkSettings?.Networks?.kind === undefined) {
|
|
515
|
+
yield* docker(["network", "connect", "kind", container]).pipe(
|
|
516
|
+
Effect.catchTag("Kubernetes.LocalClusterError", (error) =>
|
|
517
|
+
/already exists/i.test(error.message) ? Effect.void : Effect.fail(error),
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// containerd on each node: pull `localhost:<port>/…` from the
|
|
523
|
+
// registry container over the kind network.
|
|
524
|
+
const registryDir = `/etc/containerd/certs.d/localhost:${options.registryPort}`;
|
|
525
|
+
const { stdout } = yield* kind(["get", "nodes", "--name", options.name]);
|
|
526
|
+
const nodes = stdout
|
|
527
|
+
.split(/\r?\n/)
|
|
528
|
+
.map((line) => line.trim())
|
|
529
|
+
.filter((line) => line.length > 0);
|
|
530
|
+
yield* Effect.forEach(
|
|
531
|
+
nodes,
|
|
532
|
+
(node) =>
|
|
533
|
+
Effect.gen(function* () {
|
|
534
|
+
yield* docker(["exec", node, "mkdir", "-p", registryDir]);
|
|
535
|
+
yield* docker(
|
|
536
|
+
["exec", "-i", node, "cp", "/dev/stdin", `${registryDir}/hosts.toml`],
|
|
537
|
+
`[host."http://${container}:5000"]\n`,
|
|
538
|
+
);
|
|
539
|
+
}),
|
|
540
|
+
{ concurrency: "unbounded" },
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
stables: ["name", "context", "kubeconfig", "registryContainer"],
|
|
546
|
+
list: () => Effect.succeed([] as LocalCluster["Attributes"][]),
|
|
547
|
+
diff: Effect.fn(function* ({ id, olds, news, output }) {
|
|
548
|
+
if (!isResolved(news)) return;
|
|
549
|
+
const oldName = yield* clusterName(id, olds, output);
|
|
550
|
+
const newName = yield* clusterName(id, news, undefined);
|
|
551
|
+
if (
|
|
552
|
+
(news.name !== undefined && oldName !== newName) ||
|
|
553
|
+
olds?.nodeImage !== news.nodeImage ||
|
|
554
|
+
olds?.kubeconfig !== news.kubeconfig ||
|
|
555
|
+
!deepEqual(kindConfigOf(olds ?? {}), kindConfigOf(news), {
|
|
556
|
+
stripNullish: true,
|
|
557
|
+
})
|
|
558
|
+
) {
|
|
559
|
+
return { action: "replace", deleteFirst: true } as const;
|
|
560
|
+
}
|
|
561
|
+
}),
|
|
562
|
+
read: Effect.fn(function* ({ id, olds, output }) {
|
|
563
|
+
const name = yield* clusterName(id, olds, output);
|
|
564
|
+
if (!(yield* clusterExists(name))) return undefined;
|
|
565
|
+
const registryInfo = yield* inspectRegistry(`${name}-registry`);
|
|
566
|
+
const attrs = toAttributes({
|
|
567
|
+
name,
|
|
568
|
+
kubeconfig: output?.kubeconfig ?? olds?.kubeconfig,
|
|
569
|
+
registryPort:
|
|
570
|
+
registryHostPort(registryInfo) ?? olds?.registryPort ?? DEFAULT_REGISTRY_PORT,
|
|
571
|
+
architecture: yield* hostArchitecture,
|
|
572
|
+
});
|
|
573
|
+
if (output) return attrs;
|
|
574
|
+
// kind clusters carry no ownership marker; the registry container
|
|
575
|
+
// we start next to them does. Anything else is foreign.
|
|
576
|
+
const owned = yield* hasAlchemyTags(id, registryInfo?.Config?.Labels ?? undefined);
|
|
577
|
+
return owned ? attrs : Unowned(attrs);
|
|
578
|
+
}),
|
|
579
|
+
reconcile: Effect.fn(function* ({ id, news, output, session }) {
|
|
580
|
+
const name = yield* clusterName(id, news, output);
|
|
581
|
+
const registryPort = news.registryPort ?? DEFAULT_REGISTRY_PORT;
|
|
582
|
+
yield* ensureCluster({
|
|
583
|
+
name,
|
|
584
|
+
nodeImage: news.nodeImage,
|
|
585
|
+
kubeconfig: news.kubeconfig,
|
|
586
|
+
config: kindConfigOf(news),
|
|
587
|
+
session,
|
|
588
|
+
});
|
|
589
|
+
yield* ensureRegistry({ id, name, registryPort, session });
|
|
590
|
+
const attrs = toAttributes({
|
|
591
|
+
name,
|
|
592
|
+
kubeconfig: news.kubeconfig,
|
|
593
|
+
registryPort,
|
|
594
|
+
architecture: yield* hostArchitecture,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// Advertise the registry to in-cluster tooling (KEP-1755).
|
|
598
|
+
const transport = yield* connectCluster(attrs.connection);
|
|
599
|
+
yield* applyObject({
|
|
600
|
+
transport,
|
|
601
|
+
object: {
|
|
602
|
+
apiVersion: "v1",
|
|
603
|
+
kind: "ConfigMap",
|
|
604
|
+
metadata: {
|
|
605
|
+
name: "local-registry-hosting",
|
|
606
|
+
namespace: "kube-public",
|
|
607
|
+
},
|
|
608
|
+
data: {
|
|
609
|
+
"localRegistryHosting.v1": `host: "${attrs.registry.server}"\nhelp: "https://kind.sigs.k8s.io/docs/user/local-registry/"\n`,
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
yield* session.note(
|
|
614
|
+
`kind cluster ${name} ready (context ${attrs.context}, registry ${attrs.registry.server})`,
|
|
615
|
+
);
|
|
616
|
+
return attrs;
|
|
617
|
+
}),
|
|
618
|
+
delete: Effect.fn(function* ({ output }) {
|
|
619
|
+
// Both commands are idempotent: kind ignores a missing cluster.
|
|
620
|
+
yield* kind([
|
|
621
|
+
"delete",
|
|
622
|
+
"cluster",
|
|
623
|
+
"--name",
|
|
624
|
+
output.name,
|
|
625
|
+
...kubeconfigArgs(output.kubeconfig),
|
|
626
|
+
]);
|
|
627
|
+
yield* run(yield* DockerBin, ["container", "rm", "--force", output.registryContainer]);
|
|
628
|
+
}),
|
|
629
|
+
};
|
|
630
|
+
}),
|
|
631
|
+
);
|