alasio 4.1.3 → 4.2.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.
Files changed (50) hide show
  1. package/dist/cli/src/cluster/docker-engine.js +216 -0
  2. package/dist/cli/src/cluster/docker-engine.js.map +1 -0
  3. package/dist/cli/src/cluster/docker.js +294 -199
  4. package/dist/cli/src/cluster/docker.js.map +1 -1
  5. package/dist/cli/src/cluster/host.js +235 -57
  6. package/dist/cli/src/cluster/host.js.map +1 -1
  7. package/dist/cli/src/cluster/k3s.js +108 -0
  8. package/dist/cli/src/cluster/k3s.js.map +1 -0
  9. package/dist/cli/src/cluster/machine.js +173 -0
  10. package/dist/cli/src/cluster/machine.js.map +1 -0
  11. package/dist/cli/src/cluster/node.js +392 -0
  12. package/dist/cli/src/cluster/node.js.map +1 -0
  13. package/dist/cli/src/cluster/root.js +109 -0
  14. package/dist/cli/src/cluster/root.js.map +1 -0
  15. package/dist/cli/src/cluster/tar.js +32 -0
  16. package/dist/cli/src/cluster/tar.js.map +1 -0
  17. package/dist/cli/src/commands/as-root.js +15 -0
  18. package/dist/cli/src/commands/as-root.js.map +1 -0
  19. package/dist/cli/src/commands/common.js +26 -15
  20. package/dist/cli/src/commands/common.js.map +1 -1
  21. package/dist/cli/src/commands/down.js +9 -4
  22. package/dist/cli/src/commands/down.js.map +1 -1
  23. package/dist/cli/src/commands/init.js +35 -28
  24. package/dist/cli/src/commands/init.js.map +1 -1
  25. package/dist/cli/src/commands/status.js +29 -14
  26. package/dist/cli/src/commands/status.js.map +1 -1
  27. package/dist/cli/src/commands/uninstall.js +23 -9
  28. package/dist/cli/src/commands/uninstall.js.map +1 -1
  29. package/dist/cli/src/commands/up.js +26 -9
  30. package/dist/cli/src/commands/up.js.map +1 -1
  31. package/dist/cli/src/commands/upgrade.js +2 -1
  32. package/dist/cli/src/commands/upgrade.js.map +1 -1
  33. package/dist/cli/src/commands.js +2 -1
  34. package/dist/cli/src/commands.js.map +1 -1
  35. package/dist/cli/src/config.js +55 -27
  36. package/dist/cli/src/config.js.map +1 -1
  37. package/dist/cli/src/kube/api.js +1 -0
  38. package/dist/cli/src/kube/api.js.map +1 -1
  39. package/dist/cli/src/main.js +4 -2
  40. package/dist/cli/src/main.js.map +1 -1
  41. package/dist/cli/src/manifests/config.js +2 -2
  42. package/dist/cli/src/release.js +6 -6
  43. package/dist/cli/src/release.js.map +1 -1
  44. package/dist/cli/src/target.js +43 -27
  45. package/dist/cli/src/target.js.map +1 -1
  46. package/dist/cluster/node/config-v3.toml.tmpl +12 -0
  47. package/dist/cluster/node/pins.json +15 -0
  48. package/package.json +2 -2
  49. package/dist/cli/src/cluster/local.js +0 -392
  50. package/dist/cli/src/cluster/local.js.map +0 -1
@@ -1,228 +1,323 @@
1
1
  /**
2
- * Docker's Engine API, over the Unix socket Docker listens on: as much of it as the local
3
- * cluster (./local.ts) needs, its images, network, volumes and containers and commands
4
- * run in them, called with node:http, so neither the Docker CLI nor a client library is
5
- * needed. Paths are those of API version 1.44 (Docker 25), the oldest it is written for.
2
+ * The cluster in Docker: k3s in Docker's containers, made through its Engine API
3
+ * (./docker-engine.ts) from alasio's node image (cluster/node), which is k3s with gVisor
4
+ * registered as containerd's `runsc` runtime and an entrypoint that readies its container
5
+ * for k3s.
6
6
  *
7
- * The socket is the one `DOCKER_HOST` names (`unix://PATH`), /var/run/docker.sock when
8
- * it names none.
7
+ * A cluster NAME is a bridge network NAME, a server container NAME-server-0 and agent
8
+ * containers NAME-agent-I, each with named volumes for what k3s, kubelet and the CNI
9
+ * keep (NAME-server-0-k3s and so on), so a node made anew, as when its image or settings
10
+ * change, keeps its state. All of it is labelled as the cluster's, and nothing that is
11
+ * not is changed. The nodes have fixed addresses in the network's subnet: the server the
12
+ * second, after the gateway, and the agents those after it. Docker gives fixed addresses
13
+ * only in a subnet chosen for the network, so one is chosen when none is given: the first
14
+ * 172.30.N.0/24 no other network overlaps.
15
+ *
16
+ * The registries the nodes pull from are k3s's registries.yaml, which the node image's
17
+ * entrypoint writes from the variable each node is made with, so a node is made anew when
18
+ * they change, as when its other settings do.
19
+ *
20
+ * The API server is published on the loopback only. What is done in the cluster itself,
21
+ * waiting for it and configuring it, is done with the kubectl k3s carries in the server
22
+ * node, so this machine needs none of Kubernetes' tools.
9
23
  */
10
- import { request as httpRequest, STATUS_CODES } from "node:http";
11
- import { buffer } from "node:stream/consumers";
12
- import { Context, Effect, Layer, Result, Schedule, Schema } from "effect";
13
- const API_VERSION = "v1.44";
14
- const DEFAULT_SOCKET = "/var/run/docker.sock";
15
- /** Docker refused a call, or could not be reached: the status it answered with, when it answered. */
16
- export class DockerError extends Schema.TaggedError()("DockerError", {
17
- /** The call, as its method and path. */
18
- call: Schema.String,
19
- /** Docker's HTTP status (404 for absent, 409 for a conflict), when it answered. */
20
- status: Schema.optional(Schema.Number),
21
- /** What Docker answered, or why it could not be reached. */
24
+ import { createHash } from "node:crypto";
25
+ import { Context, Effect, FileSystem, Layer, Schedule, Schema } from "effect";
26
+ import { imageReference } from "../images.js";
27
+ import { NODE_IMAGE } from "../release.js";
28
+ import { DockerEngine } from "./docker-engine.js";
29
+ import { awaitReady as waitFor, clusterKubeconfig, GVISOR_RUNTIME_CLASS, KUBELET_ARGS, nodeHostsWith, NotYet, readySince, registriesYaml, WAITS, writeKubeconfig, } from "./k3s.js";
30
+ /** The cluster cannot be made as asked: something of its name is not its own, or its settings cannot hold. */
31
+ export class ClusterUnusable extends Schema.TaggedError()("ClusterUnusable", {
32
+ cluster: Schema.String,
22
33
  reason: Schema.String,
23
34
  }) {
24
35
  get message() {
25
- return `Docker ${this.status === undefined ? "failed" : `answered ${this.status} to`} ${this.call}: ${this.reason}`;
36
+ return `cluster ${this.cluster} cannot be made: ${this.reason}`;
26
37
  }
27
38
  }
28
- /** `DOCKER_HOST` names a Docker that is not reached over a Unix socket. */
29
- export class DockerHostUnsupported extends Schema.TaggedError()("DockerHostUnsupported", {
30
- host: Schema.String,
39
+ /** A command run in a node exited with another code than 0. */
40
+ export class NodeCommandFailed extends Schema.TaggedError()("NodeCommandFailed", {
41
+ node: Schema.String,
42
+ command: Schema.String,
43
+ exitCode: Schema.Number,
44
+ stderr: Schema.String,
31
45
  }) {
32
46
  get message() {
33
- return `DOCKER_HOST is ${this.host}, but alasio reaches Docker only over a Unix socket (unix://PATH)`;
47
+ return `${this.command} in ${this.node} exited with ${this.exitCode}${this.stderr.trim() ? `: ${this.stderr.trim()}` : ""}`;
34
48
  }
35
49
  }
36
- /** Whether `error` is Docker's answer `status`. */
37
- export const hasStatus = (status) => (error) => error.status === status;
38
- /**
39
- * Whether `error` is Docker's answer to removing a container it killed but did not see
40
- * exit within the time it waits: the kill stands, and the container exits once its
41
- * processes do, which those waiting on a FUSE mount whose client dies with them, as
42
- * JuiceFS's on a node are, can take longer to.
43
- */
44
- const killedNotYetExited = (error) => error.status === 500 && error.reason.includes("did not receive an exit event");
45
- /** The socket of the Docker `env` names, by `DOCKER_HOST`. */
46
- export function dockerSocket(env) {
47
- const host = env["DOCKER_HOST"];
48
- if (!host)
49
- return Result.succeed(DEFAULT_SOCKET);
50
- if (host.startsWith("unix://"))
51
- return Result.succeed(host.slice("unix://".length));
52
- return Result.fail(new DockerHostUnsupported({ host }));
50
+ /** The cluster in Docker of the options its layer was given. */
51
+ export class DockerCluster extends Context.Service()("alasio/cluster/DockerCluster") {
52
+ static layer = (options) => Layer.effect(DockerCluster, makeDockerCluster(options));
53
+ }
54
+ const CLUSTER_LABEL = "alasio.cluster";
55
+ const ROLE_LABEL = "alasio.role";
56
+ /** The label of a node's container that says what it was made from: specHash's. */
57
+ const SPEC_LABEL = "alasio.spec";
58
+ /** What a node keeps, each in a volume of its own: the volume's suffix, and where it is mounted. */
59
+ const NODE_VOLUMES = [
60
+ ["k3s", "/var/lib/rancher/k3s"],
61
+ ["kubelet", "/var/lib/kubelet"],
62
+ ["cni", "/var/lib/cni"],
63
+ ["log", "/var/log"],
64
+ ];
65
+ /** The API server's port in the server node. */
66
+ const K3S_PORT = 6443;
67
+ const API_PORT = `${K3S_PORT}/tcp`;
68
+ const K3S_KUBECONFIG = "/etc/rancher/k3s/k3s.yaml";
69
+ /** The token the server made on its first start, which agents join with. */
70
+ const K3S_TOKEN = "/var/lib/rancher/k3s/server/token";
71
+ /** The variable the node image's entrypoint writes k3s's registries.yaml from, before k3s starts. */
72
+ const REGISTRIES_ENV = "ALASIO_REGISTRIES";
73
+ /** The `index`th address of the IPv4 subnet `cidr` (a.b.c.d/n), its network address the 0th; null when it has no such host address. */
74
+ export function subnetAddress(cidr, index) {
75
+ const match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/u.exec(cidr);
76
+ if (!match)
77
+ return null;
78
+ const octets = match.slice(1, 5).map(Number);
79
+ const prefix = Number(match[5]);
80
+ if (octets.some((octet) => octet > 255) || prefix > 30)
81
+ return null;
82
+ // The last address is the subnet's broadcast address.
83
+ if (index < 1 || index >= 2 ** (32 - prefix) - 1)
84
+ return null;
85
+ const network = octets.reduce((value, octet) => value * 256 + octet, 0);
86
+ const address = network - (network % 2 ** (32 - prefix)) + index;
87
+ return [24, 16, 8, 0].map((shift) => Math.floor(address / 2 ** shift) % 256).join(".");
53
88
  }
54
- /** Docker's Engine API, as the local cluster calls it. Objects are addressed by name. */
55
- export class DockerEngine extends Context.Service()("alasio/cluster/DockerEngine") {
56
- /** The Docker `env` names (dockerSocket). */
57
- static layer = (env = process.env) => Layer.effect(DockerEngine, Effect.map(Effect.fromResult(dockerSocket(env)), makeDockerEngine));
89
+ /** The first and last addresses of the IPv4 subnet `cidr`, as numbers; null when it is none. */
90
+ function subnetRange(cidr) {
91
+ const match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/u.exec(cidr);
92
+ if (!match)
93
+ return null;
94
+ const octets = match.slice(1, 5).map(Number);
95
+ const prefix = Number(match[5]);
96
+ if (octets.some((octet) => octet > 255) || prefix > 32)
97
+ return null;
98
+ const size = 2 ** (32 - prefix);
99
+ const first = octets.reduce((value, octet) => value * 256 + octet, 0);
100
+ return [first - (first % size), first - (first % size) + size - 1];
58
101
  }
59
- const describe = (call) => `${call.method} ${call.path}`;
60
- const unreachable = (call, cause) => new DockerError({ call: describe(call), reason: cause instanceof Error ? cause.message : String(cause) });
61
- /** A label filter of a list call, in the JSON Docker's `filters` parameter takes. */
62
- const labelFilter = (labels) => JSON.stringify({ label: Object.entries(labels).map(([key, value]) => `${key}=${value}`) });
63
- /** The content of the first regular file in `tar`, a tar archive as Docker's archive endpoint sends one; null when it holds none. */
64
- export function firstFile(tar) {
65
- const BLOCK = 512;
66
- for (let offset = 0; offset + BLOCK <= tar.length;) {
67
- const header = tar.subarray(offset, offset + BLOCK);
68
- // Two zero blocks end an archive.
69
- if (header.every((byte) => byte === 0))
70
- return null;
71
- const size = Number.parseInt(header.toString("latin1", 124, 136).replace(/\0.*$/su, "").trim() || "0", 8);
72
- const type = header[156];
73
- const start = offset + BLOCK;
74
- // "0", or NUL in archives older than POSIX's.
75
- if (type === 0x30 || type === 0)
76
- return tar.subarray(start, start + size);
77
- offset = start + Math.ceil(size / BLOCK) * BLOCK;
102
+ /** The first 172.30.N.0/24 that overlaps none of the `taken` subnets (others than IPv4 are not in the way), or null when all do. Pure, for tests. */
103
+ export function freeSubnet(taken) {
104
+ const ranges = taken.flatMap((cidr) => {
105
+ const range = subnetRange(cidr);
106
+ return range ? [range] : [];
107
+ });
108
+ for (let third = 0; third < 256; third++) {
109
+ const candidate = `172.30.${third}.0/24`;
110
+ // A /24 written as such always has a range.
111
+ const [first, last] = subnetRange(candidate);
112
+ if (!ranges.some(([start, end]) => start <= last && first <= end))
113
+ return candidate;
78
114
  }
79
115
  return null;
80
116
  }
81
117
  /**
82
- * The output of a command whose exec ran without a terminal: Docker sends it in frames of
83
- * an 8-byte header (the stream, 1 for stdout or 2 for stderr, and the size, big-endian, at
84
- * byte 4) and what was written.
118
+ * Aborts the FUSE connections of what is mounted in a node, as JuiceFS's workspaces are: a
119
+ * process waiting on one whose client is gone, as a node's are once they are killed, can
120
+ * itself be killed, and its node removed, only once it is. The machine's connections are
121
+ * all in /sys/fs/fuse/connections, each named after the minor of its mount's device, and
122
+ * only the node's own are aborted.
85
123
  */
86
- export function demultiplex(raw) {
87
- const stdout = [];
88
- const stderr = [];
89
- for (let offset = 0; offset + 8 <= raw.length;) {
90
- const size = raw.readUInt32BE(offset + 4);
91
- const payload = raw.subarray(offset + 8, offset + 8 + size);
92
- (raw[offset] === 2 ? stderr : stdout).push(payload);
93
- offset += 8 + size;
94
- }
95
- return { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) };
96
- }
97
- /** The Engine API on the Unix socket `socketPath`. */
98
- function makeDockerEngine(socketPath) {
99
- const send = (call, headers = {}) => {
100
- const query = new URLSearchParams();
101
- for (const [key, value] of Object.entries(call.query ?? {})) {
102
- for (const each of typeof value === "string" ? [value] : value)
103
- query.append(key, each);
124
+ const ABORT_FUSE = [
125
+ "grep -q ' /sys/fs/fuse/connections ' /proc/self/mounts || mount -t fusectl fusectl /sys/fs/fuse/connections",
126
+ "for device in $(awk '/ - fuse/ { print $3 }' /proc/self/mountinfo | sort -u); do",
127
+ ' abort="/sys/fs/fuse/connections/${device#*:}/abort"',
128
+ ' if [ -e "$abort" ]; then echo 1 >"$abort"; fi',
129
+ "done",
130
+ ].join("\n");
131
+ /** kubelet's settings, as k3s's flags give them. */
132
+ const kubeletArgs = KUBELET_ARGS.map((arg) => `--kubelet-arg=${arg}`);
133
+ /** A digest of `spec` that two specs share only when they are the same. */
134
+ const specHash = (spec) => createHash("sha256").update(JSON.stringify(spec)).digest("hex");
135
+ const makeDockerCluster = Effect.fnUntraced(function* ({ name: cluster, apiPort, storagePath, image = imageReference(NODE_IMAGE), subnet, hostAliases = [], mounts = [], agents = 0, registries, readyTimeout = WAITS.readyTimeout, poll = WAITS.poll, }) {
136
+ const docker = yield* DockerEngine;
137
+ const fs = yield* FileSystem.FileSystem;
138
+ const labels = { [CLUSTER_LABEL]: cluster };
139
+ const server = `${cluster}-server-0`;
140
+ const nodes = [server, ...Array.from({ length: agents }, (_, index) => `${cluster}-agent-${index}`)];
141
+ const unusable = (reason) => new ClusterUnusable({ cluster, reason });
142
+ /**
143
+ * The container of `node`, with what every node has: privileged, as k3s in Docker needs,
144
+ * its volumes, the storage directory and the host mounts, and the registries.
145
+ */
146
+ const nodeContainer = (node, role, address, command, env) => ({
147
+ Image: image,
148
+ Hostname: node,
149
+ Cmd: command,
150
+ Env: [...env, ...(registries ? [`${REGISTRIES_ENV}=${registriesYaml(registries)}`] : [])],
151
+ Labels: { ...labels, [ROLE_LABEL]: role },
152
+ ...(role === "server" ? { ExposedPorts: { [API_PORT]: {} } } : {}),
153
+ HostConfig: {
154
+ Privileged: true,
155
+ Init: true,
156
+ CgroupnsMode: "private",
157
+ RestartPolicy: { Name: "unless-stopped" },
158
+ SecurityOpt: ["label=disable"],
159
+ Tmpfs: { "/run": "", "/var/run": "" },
160
+ Mounts: [
161
+ ...NODE_VOLUMES.map(([suffix, target]) => ({ Type: "volume", Source: `${node}-${suffix}`, Target: target })),
162
+ { Type: "bind", Source: storagePath, Target: storagePath },
163
+ ...mounts.map(({ source, target, readOnly = false }) => ({ Type: "bind", Source: source, Target: target, ReadOnly: readOnly })),
164
+ ],
165
+ ExtraHosts: hostAliases.flatMap(({ ip, hostnames }) => hostnames.map((hostname) => `${hostname}:${ip}`)),
166
+ ...(role === "server" ? { PortBindings: { [API_PORT]: [{ HostIp: "127.0.0.1", HostPort: String(apiPort) }] } } : {}),
167
+ },
168
+ NetworkingConfig: { EndpointsConfig: { [cluster]: { IPAMConfig: { IPv4Address: address } } } },
169
+ });
170
+ /** The cluster's network, made unless it exists; its subnet, which must be `subnet` when that is given. */
171
+ const ensureNetwork = Effect.fnUntraced(function* () {
172
+ const existing = yield* docker.inspectNetwork(cluster);
173
+ if (existing && existing.Labels?.[CLUSTER_LABEL] !== cluster)
174
+ return yield* unusable(`a network named ${cluster} is not its own`);
175
+ let network = existing;
176
+ if (!network) {
177
+ const chosen = subnet ?? freeSubnet((yield* docker.listNetworks).flatMap(({ IPAM }) => (IPAM.Config ?? []).flatMap(({ Subnet }) => Subnet ?? [])));
178
+ if (!chosen)
179
+ return yield* unusable("every 172.30.N.0/24 is another network's: give the cluster a subnet");
180
+ yield* Effect.logInfo(`making network ${cluster} (${chosen})`);
181
+ yield* docker.createNetwork({ Name: cluster, Driver: "bridge", IPAM: { Config: [{ Subnet: chosen }] }, Labels: labels });
182
+ network = yield* docker.inspectNetwork(cluster);
104
183
  }
105
- const body = call.body === undefined ? undefined : JSON.stringify(call.body);
106
- const request = httpRequest({
107
- socketPath,
108
- method: call.method,
109
- path: `/${API_VERSION}${call.path}${query.size ? `?${query}` : ""}`,
110
- headers: body === undefined ? headers : { ...headers, "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
111
- });
112
- request.end(body);
113
- return request;
184
+ const actual = network?.IPAM.Config?.[0]?.Subnet;
185
+ if (!actual)
186
+ return yield* unusable(`network ${cluster} has no subnet`);
187
+ if (subnet && actual !== subnet)
188
+ return yield* unusable(`network ${cluster} has the subnet ${actual}, not ${subnet}`);
189
+ return actual;
190
+ });
191
+ /** The `index`th node's address in `networkSubnet`. */
192
+ const nodeAddress = (networkSubnet, index) => {
193
+ const address = subnetAddress(networkSubnet, 2 + index);
194
+ return address ? Effect.succeed(address) : Effect.fail(unusable(`its subnet ${networkSubnet} has no room for ${nodes.length} nodes`));
114
195
  };
115
- const readAll = (call, stream) => Effect.tryPromise({ try: () => buffer(stream), catch: (cause) => unreachable(call, cause) });
116
- /** Docker's refusal of `call`: the status of `response`, and the message its body holds. */
117
- const refusal = (call, response) => readAll(call, response).pipe(Effect.flatMap((body) => {
118
- const status = response.statusCode ?? 0;
119
- const text = body.toString("utf8").trim();
120
- let reason = text || STATUS_CODES[status] || "no message";
121
- try {
122
- const parsed = JSON.parse(text);
123
- if (typeof parsed === "object" && parsed !== null && "message" in parsed && typeof parsed.message === "string")
124
- reason = parsed.message;
196
+ /** `node`'s container made as `spec` says, anew when it was made otherwise, and running. */
197
+ const ensureNode = Effect.fnUntraced(function* (node, spec) {
198
+ const hash = specHash(spec);
199
+ const existing = yield* docker.inspectContainer(node);
200
+ if (existing && existing.Config.Labels?.[CLUSTER_LABEL] !== cluster)
201
+ return yield* unusable(`a container named ${node} is not one of its nodes`);
202
+ const current = existing !== null && existing.Config.Labels?.[SPEC_LABEL] === hash;
203
+ if (existing && !current) {
204
+ yield* Effect.logInfo(`making node ${node} anew, from its current image and settings`);
205
+ yield* docker.stopContainer(node);
206
+ yield* docker.removeContainer(node);
207
+ }
208
+ if (!current) {
209
+ yield* Effect.logInfo(`making node ${node}`);
210
+ yield* Effect.forEach(NODE_VOLUMES, ([suffix]) => docker.createVolume({ Name: `${node}-${suffix}`, Labels: labels }), { discard: true });
211
+ yield* docker.createContainer(node, { ...spec, Labels: { ...spec.Labels, [SPEC_LABEL]: hash } });
125
212
  }
126
- catch {
127
- // Not JSON: the text is the message.
213
+ if (!current || !existing.State.Running) {
214
+ yield* Effect.logInfo(`starting node ${node}`);
215
+ yield* docker.startContainer(node);
128
216
  }
129
- return Effect.fail(new DockerError({ call: describe(call), status, reason }));
217
+ });
218
+ /** The output of `command` in `node`, which fails unless it exits with 0. */
219
+ const inNode = (node, command, options) => docker.exec(node, command, options).pipe(Effect.flatMap(({ exitCode, stdout, stderr }) => exitCode === 0 ? Effect.succeed(stdout) : Effect.fail(new NodeCommandFailed({ node, command: command.join(" "), exitCode, stderr }))));
220
+ const kubectl = (args, options) => inNode(server, ["kubectl", ...args], options);
221
+ const awaitReady = waitFor(`cluster ${cluster}`, { readyTimeout, poll });
222
+ /** When each node's container last started, in milliseconds since the epoch. */
223
+ const startTimes = Effect.forEach(nodes, (node) => Effect.map(docker.inspectContainer(node), (container) => [node, Date.parse(container?.State.StartedAt ?? "")])).pipe(Effect.map((entries) => new Map(entries)));
224
+ /** Every node ready since its container started. */
225
+ const nodesReady = (started) => kubectl(["get", "nodes", "--output=json"]).pipe(Effect.flatMap((stdout) => {
226
+ const { items } = JSON.parse(stdout.toString("utf8"));
227
+ const ready = new Set(items.filter((node) => readySince(node, started.get(node.metadata?.name ?? "") ?? Infinity)).map((node) => node.metadata?.name));
228
+ const waiting = nodes.filter((node) => !ready.has(node));
229
+ return waiting.length === 0 ? Effect.void : Effect.fail(new NotYet({ message: `not reported ready since started: ${waiting.join(", ")}` }));
130
230
  }));
131
- /** The response to `call`, open while the scope is; a status of 400 or over is its refusal (304, already so, is not). */
132
- const open = (call) => Effect.acquireRelease(Effect.callback((resume) => {
133
- const request = send(call);
134
- request.on("response", (response) => resume(Effect.succeed(response)));
135
- request.on("error", (cause) => resume(Effect.fail(unreachable(call, cause))));
136
- return Effect.sync(() => request.destroy());
137
- }), (response) => Effect.sync(() => response.destroy())).pipe(Effect.tap((response) => ((response.statusCode ?? 0) >= 400 ? refusal(call, response) : Effect.void)));
138
- const body = (call) => Effect.scoped(Effect.flatMap(open(call), (response) => readAll(call, response)));
139
- /** The JSON Docker answers `call` with, as the Engine API documents it. */
140
- const json = (call) => Effect.map(body(call), (content) => JSON.parse(content.toString("utf8")));
141
- const orNull = (effect) => effect.pipe(Effect.catchIf(hasStatus(404), () => Effect.succeed(null)));
142
- const orGone = (effect) => effect.pipe(Effect.asVoid, Effect.catchIf(hasStatus(404), () => Effect.void));
143
- const name = (value) => encodeURIComponent(value);
144
- /**
145
- * Starts exec `id` attached, `stdin` written to it and then closed, and collects what it
146
- * writes until it ends. Docker upgrades the connection to a raw stream for this.
147
- */
148
- const attach = (id, stdin) => {
149
- const call = { method: "POST", path: `/exec/${id}/start`, body: { Detach: false, Tty: false } };
150
- return Effect.callback((resume) => {
151
- const request = send(call, { Connection: "Upgrade", Upgrade: "tcp" });
152
- let stream = null;
153
- request.on("upgrade", (_response, socket, head) => {
154
- stream = socket;
155
- const chunks = [head];
156
- socket.on("data", (chunk) => chunks.push(chunk));
157
- socket.on("error", (cause) => resume(Effect.fail(unreachable(call, cause))));
158
- socket.on("close", () => resume(Effect.succeed(Buffer.concat(chunks))));
159
- if (stdin !== undefined)
160
- socket.end(stdin);
161
- });
162
- // A daemon that answers without upgrading refused.
163
- request.on("response", (response) => resume(refusal(call, response)));
164
- request.on("error", (cause) => resume(Effect.fail(unreachable(call, cause))));
165
- return Effect.sync(() => {
166
- request.destroy();
167
- stream?.destroy();
168
- });
169
- });
170
- };
171
- const exec = Effect.fnUntraced(function* (container, command, { stdin } = {}) {
172
- const { Id: id } = yield* json({
173
- method: "POST",
174
- path: `/containers/${name(container)}/exec`,
175
- body: { AttachStdin: stdin !== undefined, AttachStdout: true, AttachStderr: true, Tty: false, Cmd: command },
176
- });
177
- const { stdout, stderr } = demultiplex(yield* attach(id, stdin));
178
- // Docker records the exit code before it closes the exec's streams.
179
- const inspect = { method: "GET", path: `/exec/${id}/json` };
180
- const { ExitCode: exitCode } = yield* json(inspect);
181
- if (exitCode === null)
182
- return yield* new DockerError({ call: describe(inspect), reason: `${JSON.stringify(command[0])} has no exit code` });
183
- return { exitCode, stdout, stderr: stderr.toString("utf8") };
231
+ /** CoreDNS's NodeHosts with the host aliases, patched only when they differ, and only over the version read. */
232
+ const applyHostAliases = Effect.gen(function* () {
233
+ const coredns = JSON.parse((yield* kubectl(["--namespace=kube-system", "get", "configmap", "coredns", "--output=json"])).toString("utf8"));
234
+ const nodeHosts = coredns.data?.["NodeHosts"] ?? "";
235
+ const desired = nodeHostsWith(nodeHosts, nodes, hostAliases);
236
+ if (desired === nodeHosts)
237
+ return;
238
+ const patch = { metadata: { resourceVersion: coredns.metadata?.resourceVersion }, data: { NodeHosts: desired } };
239
+ yield* kubectl(["--namespace=kube-system", "patch", "configmap", "coredns", "--type=merge", `--patch=${JSON.stringify(patch)}`]);
184
240
  });
185
- const pullImage = Effect.fnUntraced(function* (reference) {
186
- const call = { method: "POST", path: "/images/create", query: { fromImage: reference } };
187
- // A pull that fails once it has begun still answers 200: its progress, one JSON object
188
- // a line, ends with the error.
189
- const progress = (yield* body(call)).toString("utf8").split("\n").filter((line) => line.trim());
190
- for (const line of progress) {
191
- const { error } = JSON.parse(line);
192
- if (error)
193
- return yield* new DockerError({ call: describe(call), reason: error });
194
- }
241
+ /** The kubeconfig the server wrote, pointed at the API server's port on the loopback, written to `path`. */
242
+ const fetchKubeconfig = Effect.fnUntraced(function* (path) {
243
+ const content = yield* awaitReady("its kubeconfig", docker.readFile(server, K3S_KUBECONFIG).pipe(Effect.flatMap((written) => {
244
+ const local = clusterKubeconfig(written.toString("utf8"), cluster, `https://127.0.0.1:${apiPort}`);
245
+ return local ? Effect.succeed(local) : Effect.fail(new NotYet({ message: `${K3S_KUBECONFIG} is not yet a kubeconfig with a cluster and a user` }));
246
+ })));
247
+ yield* Effect.provideService(writeKubeconfig(path, content), FileSystem.FileSystem, fs);
248
+ yield* Effect.logInfo(`wrote the kubeconfig of cluster ${cluster} to ${path}`);
195
249
  });
196
- const readFile = Effect.fnUntraced(function* (container, path) {
197
- const call = { method: "GET", path: `/containers/${name(container)}/archive`, query: { path } };
198
- const file = firstFile(yield* body(call));
199
- if (!file)
200
- return yield* new DockerError({ call: describe(call), reason: `${path} in ${container} is not a file` });
201
- return file;
250
+ const up = Effect.fnUntraced(function* (kubeconfig) {
251
+ if (!(yield* docker.inspectImage(image))) {
252
+ yield* Effect.logInfo(`pulling ${image}`);
253
+ yield* docker.pullImage(image);
254
+ }
255
+ const networkSubnet = yield* ensureNetwork();
256
+ yield* fs.makeDirectory(storagePath, { recursive: true });
257
+ yield* ensureNode(server, nodeContainer(server, "server", yield* nodeAddress(networkSubnet, 0), [
258
+ "server",
259
+ "--disable=traefik",
260
+ // Where the kubeconfig reaches it.
261
+ "--tls-san=127.0.0.1",
262
+ `--default-local-storage-path=${storagePath}`,
263
+ ...kubeletArgs,
264
+ ], []));
265
+ yield* awaitReady("its API server", kubectl(["get", "--raw=/readyz"]));
266
+ if (agents > 0) {
267
+ const token = (yield* awaitReady("its token", docker.readFile(server, K3S_TOKEN))).toString("utf8").trim();
268
+ for (const [index, agent] of nodes.slice(1).entries()) {
269
+ yield* ensureNode(agent, nodeContainer(agent, "agent", yield* nodeAddress(networkSubnet, 1 + index), ["agent", ...kubeletArgs], [
270
+ `K3S_URL=https://${server}:${K3S_PORT}`,
271
+ `K3S_TOKEN=${token}`,
272
+ ]));
273
+ }
274
+ }
275
+ yield* awaitReady("its nodes", Effect.flatMap(startTimes, nodesReady));
276
+ yield* awaitReady("CoreDNS's NodeHosts", applyHostAliases);
277
+ yield* awaitReady("the gvisor RuntimeClass", kubectl(["apply", "--filename=-"], { stdin: JSON.stringify(GVISOR_RUNTIME_CLASS) }));
278
+ yield* fetchKubeconfig(kubeconfig);
279
+ yield* Effect.logInfo(`cluster ${cluster} is up`);
202
280
  });
203
- return DockerEngine.of({
204
- version: json({ method: "GET", path: "/version" }),
205
- // A reference as it is: the route takes its slashes and colons.
206
- inspectImage: (reference) => orNull(json({ method: "GET", path: `/images/${reference}/json` })),
207
- pullImage,
208
- inspectNetwork: (network) => orNull(json({ method: "GET", path: `/networks/${name(network)}` })),
209
- listNetworks: json({ method: "GET", path: "/networks" }),
210
- createNetwork: (network) => Effect.asVoid(body({ method: "POST", path: "/networks/create", body: network })),
211
- removeNetwork: (network) => orGone(body({ method: "DELETE", path: `/networks/${name(network)}` })),
212
- disconnectNetwork: (network, container) => Effect.asVoid(body({ method: "POST", path: `/networks/${name(network)}/disconnect`, body: { Container: container, Force: true } })),
213
- createVolume: (volume) => Effect.asVoid(body({ method: "POST", path: "/volumes/create", body: volume })),
214
- listVolumes: (labels) => json({ method: "GET", path: "/volumes", query: { filters: labelFilter(labels) } }).pipe(Effect.map(({ Volumes }) => Volumes ?? [])),
215
- removeVolume: (volume) => orGone(body({ method: "DELETE", path: `/volumes/${name(volume)}` })),
216
- listContainers: (labels) => json({ method: "GET", path: "/containers/json", query: { all: "true", filters: labelFilter(labels) } }),
217
- inspectContainer: (container) => orNull(json({ method: "GET", path: `/containers/${name(container)}/json` })),
218
- createContainer: (container, spec) => Effect.asVoid(body({ method: "POST", path: "/containers/create", query: { name: container }, body: spec })),
219
- startContainer: (container) => Effect.asVoid(body({ method: "POST", path: `/containers/${name(container)}/start` })),
220
- stopContainer: (container) => Effect.asVoid(body({ method: "POST", path: `/containers/${name(container)}/stop` })),
221
- // Asked again while Docker has killed it but not seen it exit, for as long as a
222
- // node's dying processes may take.
223
- removeContainer: (container) => orGone(body({ method: "DELETE", path: `/containers/${name(container)}`, query: { force: "true" } }).pipe(Effect.retry({ while: killedNotYetExited, schedule: Schedule.max([Schedule.spaced("1 second"), Schedule.during("2 minutes")]) }))),
224
- exec,
225
- readFile,
281
+ /** The cluster's nodes, the server first. */
282
+ const existingNodes = docker.listContainers(labels).pipe(Effect.map((found) => found
283
+ .map((container) => ({
284
+ name: container.Names[0]?.replace(/^\//u, "") ?? "",
285
+ role: container.Labels[ROLE_LABEL] === "server" ? "server" : "agent",
286
+ state: container.State,
287
+ }))
288
+ .sort((a, b) => (a.role === b.role ? a.name.localeCompare(b.name) : a.role === "server" ? -1 : 1))));
289
+ return DockerCluster.of({
290
+ up,
291
+ // The agents first, so none is left without its server.
292
+ down: existingNodes.pipe(Effect.flatMap((found) => Effect.forEach(found.toReversed(), ({ name }) => docker.stopContainer(name), { discard: true })), Effect.andThen(Effect.logInfo(`stopped cluster ${cluster}`))),
293
+ remove: ({ volumes = false, storage = false } = {}) => Effect.gen(function* () {
294
+ const found = yield* existingNodes;
295
+ if (storage && found.some(({ name }) => name === server)) {
296
+ yield* docker.startContainer(server);
297
+ // The pods still running write as it empties the directory, and one of its
298
+ // directories they write in as it is removed is not empty: it is emptied again.
299
+ yield* inNode(server, ["find", storagePath, "-mindepth", "1", "-delete"]).pipe(Effect.retry({ times: 9, schedule: Schedule.spaced("1 second") }));
300
+ }
301
+ for (const { name } of found.filter(({ state }) => state === "running"))
302
+ yield* Effect.ignore(inNode(name, ["sh", "-c", ABORT_FUSE]));
303
+ yield* Effect.forEach(found, ({ name }) => docker.removeContainer(name), { discard: true });
304
+ const network = yield* docker.inspectNetwork(cluster);
305
+ if (network?.Labels?.[CLUSTER_LABEL] === cluster) {
306
+ // Docker removes no network a container is on: others' (a collector given an
307
+ // address on it, say) are taken off it, and keep running.
308
+ yield* Effect.forEach(Object.values(network.Containers ?? {}), ({ Name }) => docker.disconnectNetwork(cluster, Name).pipe(Effect.andThen(Effect.logInfo(`took ${Name} off network ${cluster}`))), { discard: true });
309
+ yield* docker.removeNetwork(cluster);
310
+ }
311
+ if (volumes)
312
+ yield* Effect.forEach(yield* docker.listVolumes(labels), ({ Name }) => docker.removeVolume(Name), { discard: true });
313
+ if (storage)
314
+ yield* fs.remove(storagePath, { recursive: true, force: true });
315
+ yield* Effect.logInfo(`removed cluster ${cluster}${volumes ? " and its volumes" : ""}${storage ? `, and ${storagePath}` : ""}`);
316
+ }),
317
+ status: Effect.all({
318
+ docker: Effect.map(docker.version, ({ Version }) => Version),
319
+ nodes: existingNodes,
320
+ }),
226
321
  });
227
- }
322
+ });
228
323
  //# sourceMappingURL=docker.js.map