alchemy-docker-k3s 0.1.0-alpha.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 ADDED
@@ -0,0 +1,14 @@
1
+ Copyright 2026 toolbar23
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ https://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
14
+
package/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # alchemy-docker-k3s
2
+
3
+ Persistent single-node local K3s clusters, backed by k3d, that implement
4
+ Alchemy's `Kubernetes.ClusterLike` contract.
5
+
6
+ See the
7
+ [repository documentation](https://github.com/toolbar23/alchemy-k3s#local-cluster)
8
+ for configuration, port mapping, and update behavior.
@@ -0,0 +1,104 @@
1
+ import * as Docker from "alchemy/Docker";
2
+ import * as Effect from "effect/Effect";
3
+ import { Resource } from "alchemy";
4
+ import * as Provider from "alchemy/Provider";
5
+ import * as Redacted from "effect/Redacted";
6
+ import * as Layer from "effect/Layer";
7
+ import * as Kubernetes from "alchemy/Kubernetes";
8
+ //#region src/providers.d.ts
9
+ declare const Providers_base: Provider.ProviderCollection<Providers, "DockerK3s">;
10
+ declare class Providers extends Providers_base {}
11
+ declare const providers: () => Layer.Layer<Providers, never, never>;
12
+ //#endregion
13
+ //#region ../shared/src/types.d.ts
14
+ type DayOfWeek = "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
15
+ interface UpdateWindow {
16
+ /** Days on which an update may start. */
17
+ days: DayOfWeek[];
18
+ /** Inclusive start in 24-hour HH:mm form. */
19
+ startTime: `${number}:${number}`;
20
+ /** Exclusive end in 24-hour HH:mm form. */
21
+ endTime: `${number}:${number}`;
22
+ /** IANA time-zone name, for example Europe/Berlin. */
23
+ timeZone: string;
24
+ }
25
+ interface K3sDefinition {
26
+ /** A pinned Kubernetes minor channel such as v1.35. */
27
+ channel: `v1.${number}`;
28
+ /** Required maintenance window for automatic patch updates. */
29
+ updateWindow: UpdateWindow;
30
+ clusterCidr?: string;
31
+ serviceCidr?: string;
32
+ clusterDns?: string;
33
+ addons?: {
34
+ traefik?: boolean;
35
+ metricsServer?: boolean;
36
+ };
37
+ /** K3s' built-in Flannel data plane. @default "vxlan" */
38
+ flannelBackend?: "vxlan" | "wireguard-native";
39
+ }
40
+ interface NormalizedK3sDefinition {
41
+ channel: `v1.${number}`;
42
+ updateWindow: UpdateWindow;
43
+ clusterCidr: string;
44
+ serviceCidr: string;
45
+ clusterDns: string;
46
+ addons: {
47
+ traefik: boolean;
48
+ metricsServer: boolean;
49
+ };
50
+ flannelBackend: "vxlan" | "wireguard-native";
51
+ }
52
+ interface ClusterVersion {
53
+ node: string;
54
+ version: string;
55
+ }
56
+ //#endregion
57
+ //#region src/types.d.ts
58
+ interface PortMapping {
59
+ hostPort: number;
60
+ containerPort: number;
61
+ protocol?: "tcp" | "udp";
62
+ }
63
+ interface ClusterProps {
64
+ k3s: K3sDefinition;
65
+ context?: Docker.Docker.ContextRef;
66
+ /** Fixed host port for the Kubernetes API. Omit for a random free port. */
67
+ apiPort?: number;
68
+ /** Host mappings through k3d's server load balancer. */
69
+ ports?: PortMapping[];
70
+ }
71
+ interface ClusterStateProps {
72
+ name: string;
73
+ k3s: NormalizedK3sDefinition;
74
+ context?: Docker.Docker.ContextRef;
75
+ apiPort?: number;
76
+ ports: PortMapping[];
77
+ volume: {
78
+ name: string;
79
+ };
80
+ configFingerprint: string;
81
+ }
82
+ interface ClusterAttributes {
83
+ connection: Kubernetes.Connection;
84
+ endpoint: string;
85
+ kubeconfigPath: string;
86
+ currentVersions: ClusterVersion[];
87
+ currentVersion: string;
88
+ channel: `v1.${number}`;
89
+ name: string;
90
+ volumeName: string;
91
+ /** K3s bootstrap token retained so a replacement container can decrypt its datastore. */
92
+ token: Redacted.Redacted<string>;
93
+ configFingerprint: string;
94
+ }
95
+ type ClusterResource = Resource<"Docker.K3s.Cluster", ClusterStateProps, ClusterAttributes, never, Providers>;
96
+ type Cluster$1 = ClusterResource;
97
+ //#endregion
98
+ //#region src/cluster.d.ts
99
+ declare const Cluster: (id: string, props: ClusterProps) => Effect.Effect<ClusterResource, never, Providers | Docker.Providers>;
100
+ //#endregion
101
+ //#region src/cluster-state.d.ts
102
+ declare const ClusterState: import("alchemy").ResourceClass<ClusterResource>;
103
+ //#endregion
104
+ export { Cluster, type ClusterAttributes, type Cluster$1 as ClusterInstance, type ClusterProps, ClusterState, type K3sDefinition, type PortMapping, Providers, providers };
package/dist/index.mjs ADDED
@@ -0,0 +1,415 @@
1
+ import * as Docker from "alchemy/Docker";
2
+ import * as Effect from "effect/Effect";
3
+ import { Resource, isResolved } from "alchemy";
4
+ import * as Provider from "alchemy/Provider";
5
+ import * as Redacted from "effect/Redacted";
6
+ import { parse } from "yaml";
7
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { execFile } from "node:child_process";
10
+ import { promisify } from "node:util";
11
+ import * as Layer from "effect/Layer";
12
+ //#region ../shared/src/definition.ts
13
+ const DAYS = [
14
+ "Monday",
15
+ "Tuesday",
16
+ "Wednesday",
17
+ "Thursday",
18
+ "Friday",
19
+ "Saturday",
20
+ "Sunday"
21
+ ];
22
+ const HH_MM = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
23
+ const CHANNEL = /^v1\.(\d+)$/;
24
+ const ipv4Number = (address) => {
25
+ const parts = address.split(".").map(Number);
26
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return;
27
+ return parts.reduce((value, part) => (value << 8 | part) >>> 0, 0);
28
+ };
29
+ const ipv4CidrRange = (cidr) => {
30
+ const [address, prefixText, extra] = cidr.split("/");
31
+ const value = address === void 0 ? void 0 : ipv4Number(address);
32
+ const prefix = Number(prefixText);
33
+ if (extra !== void 0 || value === void 0 || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) return;
34
+ const mask = prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0;
35
+ const start = (value & mask) >>> 0;
36
+ if (value !== start) return void 0;
37
+ return [start, (start | ~mask) >>> 0];
38
+ };
39
+ const validateChannel = (channel) => {
40
+ if (!CHANNEL.test(channel)) throw new Error(`K3s channel must pin one minor as v1.<minor>; received ${JSON.stringify(channel)}`);
41
+ return channel;
42
+ };
43
+ const validateUpdateWindow = (window) => {
44
+ if (window.days.length === 0 || window.days.some((day) => !DAYS.includes(day))) throw new Error("updateWindow.days must contain valid weekday names");
45
+ if (!HH_MM.test(window.startTime) || !HH_MM.test(window.endTime)) throw new Error("updateWindow times must use 24-hour HH:mm form");
46
+ if (window.startTime === window.endTime) throw new Error("updateWindow startTime and endTime must differ");
47
+ try {
48
+ new Intl.DateTimeFormat("en", { timeZone: window.timeZone }).format();
49
+ } catch {
50
+ throw new Error(`updateWindow.timeZone must be an IANA time zone; received ${JSON.stringify(window.timeZone)}`);
51
+ }
52
+ return window;
53
+ };
54
+ const normalizeK3sDefinition = (definition) => {
55
+ const normalized = {
56
+ channel: validateChannel(definition.channel),
57
+ updateWindow: validateUpdateWindow(definition.updateWindow),
58
+ clusterCidr: definition.clusterCidr ?? "10.244.0.0/16",
59
+ serviceCidr: definition.serviceCidr ?? "10.43.0.0/16",
60
+ clusterDns: definition.clusterDns ?? "10.43.0.10",
61
+ addons: {
62
+ traefik: definition.addons?.traefik ?? true,
63
+ metricsServer: definition.addons?.metricsServer ?? true
64
+ },
65
+ flannelBackend: definition.flannelBackend ?? "vxlan"
66
+ };
67
+ if (normalized.flannelBackend !== "vxlan" && normalized.flannelBackend !== "wireguard-native") throw new Error(`flannelBackend must be "vxlan" or "wireguard-native"; received ${JSON.stringify(normalized.flannelBackend)}`);
68
+ const ranges = [["clusterCidr", normalized.clusterCidr], ["serviceCidr", normalized.serviceCidr]];
69
+ for (const [name, cidr] of ranges) if (ipv4CidrRange(cidr) === void 0) throw new Error(`${name} must be an IPv4 CIDR; received ${JSON.stringify(cidr)}`);
70
+ const clusterRange = ipv4CidrRange(normalized.clusterCidr);
71
+ const serviceRange = ipv4CidrRange(normalized.serviceCidr);
72
+ if (clusterRange[0] <= serviceRange[1] && serviceRange[0] <= clusterRange[1]) throw new Error("clusterCidr and serviceCidr must not overlap");
73
+ const dns = ipv4Number(normalized.clusterDns);
74
+ if (dns === void 0 || dns < serviceRange[0] || dns > serviceRange[1]) throw new Error("clusterDns must be an IPv4 address inside serviceCidr");
75
+ return normalized;
76
+ };
77
+ const partsFor = (date, timeZone) => Object.fromEntries(new Intl.DateTimeFormat("en-US", {
78
+ timeZone,
79
+ weekday: "long",
80
+ hour: "2-digit",
81
+ minute: "2-digit",
82
+ hourCycle: "h23"
83
+ }).formatToParts(date).map(({ type, value }) => [type, value]));
84
+ const isInsideUpdateWindow = (window, date = /* @__PURE__ */ new Date()) => {
85
+ validateUpdateWindow(window);
86
+ const parts = partsFor(date, window.timeZone);
87
+ const weekday = parts.weekday;
88
+ const current = `${parts.hour}:${parts.minute}`;
89
+ if (window.startTime < window.endTime) return window.days.includes(weekday) && current >= window.startTime && current < window.endTime;
90
+ if (current >= window.startTime) return window.days.includes(weekday);
91
+ if (current >= window.endTime) return false;
92
+ const previousParts = partsFor(/* @__PURE__ */ new Date(date.getTime() - 432e5), window.timeZone);
93
+ return window.days.includes(previousParts.weekday);
94
+ };
95
+ const assertSameMinor = (current, desired) => {
96
+ const currentMinor = /^v?(\d+\.\d+)\./.exec(current)?.[1];
97
+ const desiredMinor = /^v?(\d+\.\d+)\./.exec(desired)?.[1];
98
+ if (currentMinor !== void 0 && desiredMinor !== void 0 && currentMinor !== desiredMinor) throw new Error(`Automatic updates cannot change Kubernetes minor (${current} -> ${desired}); change k3s.channel explicitly`);
99
+ };
100
+ //#endregion
101
+ //#region ../shared/src/channel.ts
102
+ const resolveChannelVersion = async (channel, fetcher = fetch) => {
103
+ validateChannel(channel);
104
+ const response = await fetcher(`https://update.k3s.io/v1-release/channels/${channel}`, {
105
+ redirect: "follow",
106
+ signal: AbortSignal.timeout(15e3)
107
+ });
108
+ if (!response.ok) throw new Error(`Unable to resolve K3s channel ${channel}: HTTP ${response.status}`);
109
+ const resolvedUrl = decodeURIComponent(response.url);
110
+ const match = /(?:^|\/)(v\d+\.\d+\.\d+(?:\+k3s\d+)?)(?:$|[/?#])/.exec(resolvedUrl);
111
+ if (match?.[1] !== void 0) return match[1];
112
+ const body = (await response.text()).trim();
113
+ const bodyMatch = /v\d+\.\d+\.\d+(?:\+k3s\d+)?/.exec(body);
114
+ if (bodyMatch?.[0] !== void 0) return bodyMatch[0];
115
+ throw new Error(`K3s channel ${channel} returned no recognizable release version`);
116
+ };
117
+ //#endregion
118
+ //#region ../shared/src/kubeconfig.ts
119
+ const kubeconfigPath = (provider, fqn) => {
120
+ const safe = fqn.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-|-$/g, "");
121
+ return resolve(join(".alchemy", "kubeconfigs", provider, `${safe}.yaml`));
122
+ };
123
+ const writeKubeconfig = async (path, contents) => {
124
+ await mkdir(dirname(path), {
125
+ recursive: true,
126
+ mode: 448
127
+ });
128
+ await writeFile(path, contents, {
129
+ encoding: "utf8",
130
+ mode: 384
131
+ });
132
+ await chmod(path, 384);
133
+ };
134
+ //#endregion
135
+ //#region ../shared/src/process.ts
136
+ const execute = promisify(execFile);
137
+ const run = async (file, args, options = {}) => {
138
+ if (options.input !== void 0) return await new Promise((resolve, reject) => {
139
+ execFile(file, args, {
140
+ encoding: "utf8",
141
+ timeout: options.timeout ?? 12e4,
142
+ maxBuffer: 16777216,
143
+ env: options.env
144
+ }, (error, stdout, stderr) => {
145
+ if (error !== null) {
146
+ reject(new Error(`${file} ${args.join(" ")} failed: ${stderr.trim() || error.message}`, { cause: error }));
147
+ return;
148
+ }
149
+ resolve({
150
+ stdout,
151
+ stderr
152
+ });
153
+ }).stdin?.end(options.input);
154
+ });
155
+ const result = await execute(file, args, {
156
+ encoding: "utf8",
157
+ timeout: options.timeout ?? 12e4,
158
+ maxBuffer: 16777216,
159
+ env: options.env
160
+ });
161
+ return {
162
+ stdout: result.stdout,
163
+ stderr: result.stderr
164
+ };
165
+ };
166
+ //#endregion
167
+ //#region src/k3d.ts
168
+ const contextName = (context) => {
169
+ if (context === void 0) return void 0;
170
+ return typeof context === "string" ? context : context.name;
171
+ };
172
+ const dockerEnvironment = async (context) => {
173
+ const name = contextName(context);
174
+ if (name === void 0 || name === "default") return process.env;
175
+ const host = (await run("docker", [
176
+ "context",
177
+ "inspect",
178
+ name,
179
+ "--format",
180
+ "{{.Endpoints.docker.Host}}"
181
+ ])).stdout.trim();
182
+ if (host.length === 0) throw new Error(`Docker context ${name} has no endpoint`);
183
+ return {
184
+ ...process.env,
185
+ DOCKER_HOST: host
186
+ };
187
+ };
188
+ const requireK3d = async () => {
189
+ let output;
190
+ try {
191
+ output = (await run("k3d", ["version"])).stdout;
192
+ } catch (error) {
193
+ throw new Error("k3d >=5.9.0 <6 is required; install it from https://k3d.io", { cause: error });
194
+ }
195
+ const match = /k3d version v?(\d+)\.(\d+)\.(\d+)/.exec(output);
196
+ if (match === null || Number(match[1]) !== 5 || Number(match[2]) < 9) throw new Error(`k3d >=5.9.0 <6 is required; received ${output.trim()}`);
197
+ };
198
+ const buildCreateArgs = (props, version, token) => {
199
+ const args = [
200
+ "cluster",
201
+ "create",
202
+ props.name,
203
+ "--servers",
204
+ "1",
205
+ "--agents",
206
+ "0",
207
+ "--image",
208
+ `rancher/k3s:${version.replace("+", "-")}`,
209
+ "--volume",
210
+ `${props.volume.name}:/var/lib/rancher/k3s@server:0`,
211
+ "--kubeconfig-update-default=false",
212
+ "--kubeconfig-switch-context=false",
213
+ "--wait"
214
+ ];
215
+ if (props.apiPort !== void 0) args.push("--api-port", `127.0.0.1:${props.apiPort}`);
216
+ if (token !== void 0) args.push("--token", token);
217
+ for (const port of props.ports) args.push("--port", `${port.hostPort}:${port.containerPort}/${port.protocol ?? "tcp"}@loadbalancer`);
218
+ for (const value of [
219
+ `--cluster-cidr=${props.k3s.clusterCidr}`,
220
+ `--service-cidr=${props.k3s.serviceCidr}`,
221
+ `--cluster-dns=${props.k3s.clusterDns}`,
222
+ `--flannel-backend=${props.k3s.flannelBackend}`
223
+ ]) args.push("--k3s-arg", `${value}@server:0`);
224
+ if (!props.k3s.addons.traefik) args.push("--k3s-arg", "--disable=traefik@server:0");
225
+ if (!props.k3s.addons.metricsServer) args.push("--k3s-arg", "--disable=metrics-server@server:0");
226
+ return args;
227
+ };
228
+ const inspectK3dCluster = async (props) => {
229
+ const env = await dockerEnvironment(props.context);
230
+ const listed = await run("k3d", [
231
+ "cluster",
232
+ "list",
233
+ "-o",
234
+ "json"
235
+ ], { env });
236
+ if (!JSON.parse(listed.stdout).some((cluster) => cluster.name === props.name)) return void 0;
237
+ const detailed = await run("k3d", [
238
+ "cluster",
239
+ "list",
240
+ props.name,
241
+ "--token",
242
+ "-o",
243
+ "json"
244
+ ], { env });
245
+ return JSON.parse(detailed.stdout)[0];
246
+ };
247
+ const parseK3sVersion = (output) => {
248
+ const match = /k3s version (v?\d+\.\d+\.\d+(?:[+-]k3s\d+)?)/.exec(output);
249
+ if (match?.[1] === void 0) throw new Error(`Cannot determine K3s version from ${output.trim()}`);
250
+ const version = match[1].replace("-k3s", "+k3s");
251
+ return version.startsWith("v") ? version : `v${version}`;
252
+ };
253
+ const runningVersion = async (props, cluster) => {
254
+ const server = cluster.nodes?.find((node) => node.role === "server");
255
+ if (server === void 0) throw new Error(`k3d cluster ${props.name} has no server node`);
256
+ const result = await run("docker", [
257
+ "exec",
258
+ server.name,
259
+ "k3s",
260
+ "--version"
261
+ ], { env: await dockerEnvironment(props.context) });
262
+ return parseK3sVersion(result.stdout);
263
+ };
264
+ const createK3dCluster = async (props, version, token) => {
265
+ await run("k3d", buildCreateArgs(props, version, token), {
266
+ env: await dockerEnvironment(props.context),
267
+ timeout: 9e5
268
+ });
269
+ };
270
+ const deleteK3dCluster = async (props) => {
271
+ await run("k3d", [
272
+ "cluster",
273
+ "delete",
274
+ props.name
275
+ ], {
276
+ env: await dockerEnvironment(props.context),
277
+ timeout: 6e5
278
+ });
279
+ };
280
+ const getKubeconfig = async (props) => (await run("k3d", [
281
+ "kubeconfig",
282
+ "get",
283
+ props.name
284
+ ], { env: await dockerEnvironment(props.context) })).stdout;
285
+ //#endregion
286
+ //#region src/cluster-state.ts
287
+ const ClusterState = Resource("Docker.K3s.Cluster");
288
+ const observe = async (fqn, props) => {
289
+ await requireK3d();
290
+ const cluster = await inspectK3dCluster(props);
291
+ if (cluster === void 0) return void 0;
292
+ const kubeconfig = await getKubeconfig(props);
293
+ const parsed = parse(kubeconfig);
294
+ const target = parsed.clusters?.[0]?.cluster?.server;
295
+ if (target === void 0) throw new Error("k3d returned a kubeconfig without an API endpoint");
296
+ const path = kubeconfigPath("docker", fqn);
297
+ await writeKubeconfig(path, kubeconfig);
298
+ const version = await runningVersion(props, cluster);
299
+ if (cluster.clusterToken === void 0 || cluster.clusterToken.length === 0) throw new Error("k3d returned no cluster token");
300
+ return {
301
+ connection: {
302
+ endpoint: target,
303
+ auth: {
304
+ kind: "kubeconfig",
305
+ path,
306
+ ...parsed["current-context"] === void 0 ? {} : { context: parsed["current-context"] }
307
+ }
308
+ },
309
+ endpoint: target,
310
+ kubeconfigPath: path,
311
+ currentVersions: [{
312
+ node: `${props.name}-server-0`,
313
+ version
314
+ }],
315
+ currentVersion: version,
316
+ channel: props.k3s.channel,
317
+ name: props.name,
318
+ volumeName: props.volume.name,
319
+ token: Redacted.make(cluster.clusterToken),
320
+ configFingerprint: props.configFingerprint
321
+ };
322
+ };
323
+ const ClusterProvider = () => Provider.succeed(ClusterState, {
324
+ stables: [
325
+ "name",
326
+ "volumeName",
327
+ "kubeconfigPath"
328
+ ],
329
+ read: ({ fqn, olds }) => Effect.tryPromise({
330
+ try: () => observe(fqn, olds),
331
+ catch: (cause) => new Error(`Unable to inspect local K3s cluster ${olds.name}`, { cause })
332
+ }),
333
+ diff: ({ news, olds, output }) => Effect.tryPromise({
334
+ try: async () => {
335
+ if (output === void 0) return void 0;
336
+ if (!isResolved(news)) return void 0;
337
+ if (typeof news.name === "string" && news.name !== output.name) return {
338
+ action: "replace",
339
+ deleteFirst: true
340
+ };
341
+ if (typeof news.configFingerprint === "string" && news.configFingerprint !== output.configFingerprint) return { action: "update" };
342
+ if (typeof news.k3s !== "object" || !("channel" in news.k3s)) return void 0;
343
+ const desired = await resolveChannelVersion(news.k3s.channel);
344
+ if (news.k3s.channel === olds.k3s.channel) assertSameMinor(output.currentVersion, desired);
345
+ if (output.currentVersion === desired) return { action: "noop" };
346
+ if (!isInsideUpdateWindow(news.k3s.updateWindow)) {
347
+ console.warn(`K3s ${desired} is available for ${output.name}; update deferred until the configured maintenance window`);
348
+ return { action: "noop" };
349
+ }
350
+ return { action: "update" };
351
+ },
352
+ catch: (cause) => new Error("Unable to plan local K3s update", { cause })
353
+ }),
354
+ reconcile: ({ fqn, news, olds, output }) => Effect.tryPromise({
355
+ try: async () => {
356
+ await requireK3d();
357
+ const desired = await resolveChannelVersion(news.k3s.channel);
358
+ if (output !== void 0 && olds?.k3s.channel === news.k3s.channel) assertSameMinor(output.currentVersion, desired);
359
+ const existing = await inspectK3dCluster(news);
360
+ const recreate = existing !== void 0 && (await runningVersion(news, existing) !== desired || output?.configFingerprint !== news.configFingerprint);
361
+ if (recreate) await deleteK3dCluster(news);
362
+ if (existing === void 0 || recreate) await createK3dCluster(news, desired, output === void 0 ? void 0 : Redacted.value(output.token));
363
+ const observed = await observe(fqn, news);
364
+ if (observed === void 0) throw new Error("k3d cluster was not visible after creation");
365
+ return observed;
366
+ },
367
+ catch: (cause) => new Error(`Failed to reconcile local K3s cluster ${news.name}`, { cause })
368
+ }),
369
+ delete: ({ olds }) => Effect.tryPromise({
370
+ try: async () => {
371
+ if (await inspectK3dCluster(olds) !== void 0) await deleteK3dCluster(olds);
372
+ },
373
+ catch: (cause) => new Error(`Failed to delete local K3s cluster ${olds.name}`, { cause })
374
+ })
375
+ });
376
+ //#endregion
377
+ //#region src/cluster.ts
378
+ const clusterName = (id) => {
379
+ const name = id.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
380
+ if (name.length === 0) throw new Error("Cluster id must contain a letter or number");
381
+ return name;
382
+ };
383
+ const validPort = (port) => Number.isInteger(port) && port >= 1 && port <= 65535;
384
+ const Cluster = (id, props) => Effect.gen(function* () {
385
+ const k3s = normalizeK3sDefinition(props.k3s);
386
+ if (props.apiPort !== void 0 && !validPort(props.apiPort)) throw new Error("apiPort must be an integer between 1 and 65535");
387
+ for (const mapping of props.ports ?? []) if (!validPort(mapping.hostPort) || !validPort(mapping.containerPort)) throw new Error("Every local port mapping must use ports between 1 and 65535");
388
+ const volume = yield* Docker.Volume(`${id}-data`, {
389
+ ...props.context === void 0 ? {} : { context: props.context },
390
+ labels: { "k3s.cluster": clusterName(id) }
391
+ });
392
+ const ports = props.ports ?? [];
393
+ return yield* ClusterState(id, {
394
+ name: clusterName(id),
395
+ k3s,
396
+ ...props.context === void 0 ? {} : { context: props.context },
397
+ ...props.apiPort === void 0 ? {} : { apiPort: props.apiPort },
398
+ ports,
399
+ volume,
400
+ configFingerprint: JSON.stringify({
401
+ apiPort: props.apiPort,
402
+ ports,
403
+ clusterCidr: k3s.clusterCidr,
404
+ serviceCidr: k3s.serviceCidr,
405
+ clusterDns: k3s.clusterDns,
406
+ addons: k3s.addons
407
+ })
408
+ });
409
+ });
410
+ //#endregion
411
+ //#region src/providers.ts
412
+ var Providers = class extends Provider.ProviderCollection()("DockerK3s") {};
413
+ const providers = () => Layer.effect(Providers, Provider.collection([ClusterState])).pipe(Layer.provide(ClusterProvider()));
414
+ //#endregion
415
+ export { Cluster, ClusterState, Providers, providers };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "alchemy-docker-k3s",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Persistent local k3d clusters for Alchemy",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.mts",
16
+ "import": "./dist/index.mjs"
17
+ }
18
+ },
19
+ "main": "./dist/index.mjs",
20
+ "types": "./dist/index.d.mts",
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/toolbar23/alchemy-k3s.git",
27
+ "directory": "packages/docker"
28
+ },
29
+ "homepage": "https://github.com/toolbar23/alchemy-k3s#readme",
30
+ "bugs": "https://github.com/toolbar23/alchemy-k3s/issues",
31
+ "keywords": [
32
+ "alchemy",
33
+ "docker",
34
+ "k3d",
35
+ "k3s",
36
+ "kubernetes",
37
+ "iac"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "provenance": true
42
+ },
43
+ "scripts": {
44
+ "build": "tsdown",
45
+ "prepack": "npm run build"
46
+ },
47
+ "peerDependencies": {
48
+ "alchemy": ">=2.0.0-beta.74 <3",
49
+ "effect": ">=4.0.0-rc.110 <5"
50
+ },
51
+ "dependencies": {
52
+ "yaml": "^2.8.1"
53
+ }
54
+ }