alasio 4.1.4 → 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.
- package/dist/cli/src/cluster/docker-engine.js +216 -0
- package/dist/cli/src/cluster/docker-engine.js.map +1 -0
- package/dist/cli/src/cluster/docker.js +294 -199
- package/dist/cli/src/cluster/docker.js.map +1 -1
- package/dist/cli/src/cluster/host.js +235 -57
- package/dist/cli/src/cluster/host.js.map +1 -1
- package/dist/cli/src/cluster/k3s.js +108 -0
- package/dist/cli/src/cluster/k3s.js.map +1 -0
- package/dist/cli/src/cluster/machine.js +173 -0
- package/dist/cli/src/cluster/machine.js.map +1 -0
- package/dist/cli/src/cluster/node.js +392 -0
- package/dist/cli/src/cluster/node.js.map +1 -0
- package/dist/cli/src/cluster/root.js +109 -0
- package/dist/cli/src/cluster/root.js.map +1 -0
- package/dist/cli/src/cluster/tar.js +32 -0
- package/dist/cli/src/cluster/tar.js.map +1 -0
- package/dist/cli/src/commands/as-root.js +15 -0
- package/dist/cli/src/commands/as-root.js.map +1 -0
- package/dist/cli/src/commands/common.js +26 -15
- package/dist/cli/src/commands/common.js.map +1 -1
- package/dist/cli/src/commands/down.js +9 -4
- package/dist/cli/src/commands/down.js.map +1 -1
- package/dist/cli/src/commands/init.js +35 -28
- package/dist/cli/src/commands/init.js.map +1 -1
- package/dist/cli/src/commands/status.js +29 -14
- package/dist/cli/src/commands/status.js.map +1 -1
- package/dist/cli/src/commands/uninstall.js +23 -9
- package/dist/cli/src/commands/uninstall.js.map +1 -1
- package/dist/cli/src/commands/up.js +26 -9
- package/dist/cli/src/commands/up.js.map +1 -1
- package/dist/cli/src/commands/upgrade.js +2 -1
- package/dist/cli/src/commands/upgrade.js.map +1 -1
- package/dist/cli/src/commands.js +2 -1
- package/dist/cli/src/commands.js.map +1 -1
- package/dist/cli/src/config.js +55 -27
- package/dist/cli/src/config.js.map +1 -1
- package/dist/cli/src/kube/api.js +1 -0
- package/dist/cli/src/kube/api.js.map +1 -1
- package/dist/cli/src/main.js +4 -2
- package/dist/cli/src/main.js.map +1 -1
- package/dist/cli/src/release.js +6 -6
- package/dist/cli/src/release.js.map +1 -1
- package/dist/cli/src/target.js +43 -27
- package/dist/cli/src/target.js.map +1 -1
- package/dist/cluster/node/config-v3.toml.tmpl +12 -0
- package/dist/cluster/node/pins.json +15 -0
- package/package.json +2 -2
- package/dist/cli/src/cluster/local.js +0 -392
- package/dist/cli/src/cluster/local.js.map +0 -1
|
@@ -1,228 +1,323 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 `
|
|
36
|
+
return `cluster ${this.cluster} cannot be made: ${this.reason}`;
|
|
26
37
|
}
|
|
27
38
|
}
|
|
28
|
-
/**
|
|
29
|
-
export class
|
|
30
|
-
|
|
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
|
|
47
|
+
return `${this.command} in ${this.node} exited with ${this.exitCode}${this.stderr.trim() ? `: ${this.stderr.trim()}` : ""}`;
|
|
34
48
|
}
|
|
35
49
|
}
|
|
36
|
-
/**
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
return
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
213
|
+
if (!current || !existing.State.Running) {
|
|
214
|
+
yield* Effect.logInfo(`starting node ${node}`);
|
|
215
|
+
yield* docker.startContainer(node);
|
|
128
216
|
}
|
|
129
|
-
|
|
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
|
-
/**
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|