@gjermundgaraba/clankerbox-sdk 0.15.6 → 0.18.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/client-8af586hb.d.mts +1007 -0
- package/dist/client-CbMWwHdT.mjs +708 -0
- package/dist/index.d.mts +91 -360
- package/dist/index.mjs +161 -140
- package/dist/node.d.mts +6 -2
- package/dist/node.mjs +5 -5
- package/package.json +6 -4
- package/dist/client-BkZhmYJx.mjs +0 -662
- package/dist/client-Cn1bj4It.d.mts +0 -328
package/dist/index.mjs
CHANGED
|
@@ -1,36 +1,21 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
-
import { A as
|
|
3
|
-
import { Effect, FileSystem, Option, Path, Predicate, Schema } from "effect";
|
|
2
|
+
import { A as parseId, B as NotFound, C as Usage, D as Id, E as HostId, F as ConflictKind, G as Refused, H as NotOffered, I as HostError, J as Unsizable, K as ScriptFailed, L as Internal, M as Capacity, N as ClankerboxError, O as Name, P as Conflict, R as Invalid, S as SshLogin, T as stateLabel, U as NotReady, V as NotMade, W as PinMismatch, X as hostErrors, Y as WrongState, Z as retryable, _ as Machine, a as release, b as Setup, c as Http, d as ActionName, f as ActionRecord, g as Host, h as CreateRequest, i as describeIssues, j as parseName, k as formatId, l as actionsByName, m as Checkpoint, o as version, p as ActionStatus, q as Unavailable, r as describeFileError, s as versionHeader, t as client_exports, u as readTimeout, v as MachineSpec, w as isReady, x as SshEndpoint, y as Runtime, z as Missing } from "./client-CbMWwHdT.mjs";
|
|
3
|
+
import { Effect, FileSystem, Option, Path, Predicate, Record, Schema } from "effect";
|
|
4
|
+
import { Base64, Hex } from "effect/encoding";
|
|
4
5
|
//#region src/prebuild.ts
|
|
5
|
-
/**
|
|
6
|
-
* Prebuilds: a checkpoint of a machine whose setup has run, which a create of the same spec
|
|
7
|
-
* restores instead of running the setup again. They are the client's alone. A prebuild is an
|
|
8
|
-
* ordinary checkpoint under a name derived from what it is of, so a host stores no profile and
|
|
9
|
-
* knows nothing of them, and a create finds its prebuild with one read by ID. A prebuild never
|
|
10
|
-
* changes and nothing here deletes one: building anew is a `checkpoint delete` first.
|
|
11
|
-
*/
|
|
12
6
|
var prebuild_exports = /* @__PURE__ */ __exportAll({
|
|
13
7
|
build: () => build,
|
|
14
8
|
create: () => create,
|
|
15
9
|
nameOf: () => nameOf
|
|
16
10
|
});
|
|
17
11
|
const prefix = "prebuilt-";
|
|
18
|
-
/** How much of the digest a prebuild's name carries. */
|
|
19
12
|
const digestDigits = 12;
|
|
20
|
-
/**
|
|
21
|
-
* How many random bytes a builder's name carries: 64 bits, so no two builds draw one name. With
|
|
22
|
-
* `prebuilding-`, it is 28 characters, inside the 29 a 32-character host ID and its `_` leave of
|
|
23
|
-
* an ID's 62.
|
|
24
|
-
*/
|
|
25
13
|
const builderBytes = 8;
|
|
26
|
-
const hex = (bytes) => Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
27
14
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* image: a host only names it. The script holds whatever secrets a recipe carries, and the name
|
|
33
|
-
* 12 hex characters of a digest of it.
|
|
15
|
+
* A digest of the spec's label, base, sizes, setup script as sent, and the host's runtime
|
|
16
|
+
* version: a `ram` checkpoint restores only under the release that saved it, so after a runtime
|
|
17
|
+
* bump a create misses the old prebuild instead of failing to restore it. The setup's timeout and
|
|
18
|
+
* the base's image are no part of it.
|
|
34
19
|
*/
|
|
35
20
|
const nameOf = (spec, host) => Effect.promise(() => globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([
|
|
36
21
|
spec.profile ?? null,
|
|
@@ -40,127 +25,81 @@ const nameOf = (spec, host) => Effect.promise(() => globalThis.crypto.subtle.dig
|
|
|
40
25
|
spec.diskGib,
|
|
41
26
|
spec.setup?.script ?? null,
|
|
42
27
|
host.runtimeVersion
|
|
43
|
-
])))).pipe(Effect.map((digest) => `${prefix}${
|
|
44
|
-
|
|
45
|
-
const lookup = (client, id) => client.checkpoint(id).pipe(Effect.map(Option.some), Effect.catchTag("NotFound", () => Effect.succeedNone));
|
|
28
|
+
])))).pipe(Effect.map((digest) => `${prefix}${Hex.encode(new Uint8Array(digest)).slice(0, digestDigits)}`));
|
|
29
|
+
const lookup = (client, id) => client.getCheckpoint({ id }).pipe(Effect.map(Option.some), Effect.catchTag("NotFound", () => Effect.succeedNone));
|
|
46
30
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* none. Placement is the create's own, and a prebuild stays on the host that made it, so a spec
|
|
50
|
-
* placed on another host runs its setup. A failed restore is the reply.
|
|
31
|
+
* `client.create`, restoring the spec's prebuild when the host it is placed on has one whose
|
|
32
|
+
* capture is done, unless `fresh`. A failed restore is the reply.
|
|
51
33
|
*/
|
|
52
34
|
const create = (client, name, spec, options) => Effect.gen(function* () {
|
|
53
|
-
if (spec.setup === void 0) return { machine: yield* client.create(name, spec, options) };
|
|
35
|
+
if (spec.setup === void 0 || options?.fresh === true) return { machine: yield* client.create(name, spec, { host: options?.host }) };
|
|
54
36
|
const host = yield* client.place(spec.base, options);
|
|
55
37
|
const id = yield* formatId(host.id, yield* nameOf(spec, host));
|
|
56
38
|
const found = yield* lookup(client, id);
|
|
57
|
-
if (Option.isSome(found) && found.value
|
|
58
|
-
machine: yield* client.
|
|
39
|
+
if (Option.isSome(found) && isReady(found.value)) return {
|
|
40
|
+
machine: yield* client.restoreMachine({
|
|
41
|
+
checkpoint: id,
|
|
42
|
+
name
|
|
43
|
+
}),
|
|
59
44
|
prebuild: found.value
|
|
60
45
|
};
|
|
61
46
|
return { machine: yield* client.create(name, spec, { host: host.id }) };
|
|
62
47
|
});
|
|
63
48
|
/**
|
|
64
|
-
* Builds the spec's prebuild on the host a create of it
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* Each build's machine has a random name of its own, and the host takes a checkpoint name once,
|
|
71
|
-
* so of two builds of one spec at once both run the setup and the second's capture is
|
|
72
|
-
* `Conflict`: neither touches the other's machine or prebuild. A build deletes its machine
|
|
73
|
-
* however it ends. If that fails after a failure, the first failure is the reply; after a
|
|
74
|
-
* capture, the prebuild is made, and the machine is the reply's `leftover`.
|
|
49
|
+
* Builds the spec's prebuild on the host a create of it is placed on, with a machine of its own
|
|
50
|
+
* that it deletes however it ends. A prebuild that is there and done is the answer, one that
|
|
51
|
+
* failed is deleted and built again, and one still running is `Conflict{busy}`, as its capture
|
|
52
|
+
* holds it. Of two builds of one spec at once, the second's capture is `Conflict`, and neither
|
|
53
|
+
* touches the other's machine.
|
|
75
54
|
*/
|
|
76
55
|
const build = (client, spec, options) => Effect.gen(function* () {
|
|
77
|
-
if (spec.setup === void 0) return yield* new Invalid({ message: "a prebuild needs a setup" });
|
|
78
56
|
const host = yield* client.place(spec.base, options);
|
|
79
57
|
const name = yield* nameOf(spec, host);
|
|
80
58
|
const id = yield* formatId(host.id, name);
|
|
81
59
|
const found = yield* lookup(client, id);
|
|
82
60
|
if (Option.isSome(found)) {
|
|
83
61
|
const { action } = found.value;
|
|
84
|
-
|
|
62
|
+
if (isReady(found.value)) return {
|
|
85
63
|
checkpoint: found.value,
|
|
86
64
|
built: false
|
|
87
|
-
}
|
|
65
|
+
};
|
|
66
|
+
if (action.status === "running") return yield* new Conflict({
|
|
67
|
+
message: `prebuild ${id} is busy: its ${action.name} is running`,
|
|
68
|
+
kind: "busy",
|
|
69
|
+
host: host.id,
|
|
70
|
+
action: "captureCheckpoint",
|
|
71
|
+
id
|
|
72
|
+
});
|
|
73
|
+
yield* client.deleteCheckpoint({ id });
|
|
88
74
|
}
|
|
89
|
-
const builder = `prebuilding-${
|
|
75
|
+
const builder = `prebuilding-${Hex.encode(globalThis.crypto.getRandomValues(new Uint8Array(builderBytes)))}`;
|
|
90
76
|
const builderId = yield* formatId(host.id, builder);
|
|
91
|
-
const removal =
|
|
77
|
+
const removal = client.deleteMachine({ id: builderId });
|
|
92
78
|
return {
|
|
93
79
|
checkpoint: yield* Effect.gen(function* () {
|
|
94
80
|
yield* client.create(builder, spec, { host: host.id });
|
|
95
|
-
if (host.
|
|
96
|
-
return yield* client.
|
|
81
|
+
if (host.captureFrom === "stopped") yield* client.stopMachine({ id: builderId });
|
|
82
|
+
return yield* client.captureCheckpoint({
|
|
83
|
+
machine: builderId,
|
|
84
|
+
name
|
|
85
|
+
});
|
|
97
86
|
}).pipe(Effect.onError(() => Effect.ignore(removal))),
|
|
98
87
|
built: true,
|
|
99
88
|
leftover: yield* removal.pipe(Effect.as(void 0), Effect.catch(() => Effect.succeed(builderId)))
|
|
100
89
|
};
|
|
101
90
|
});
|
|
102
91
|
//#endregion
|
|
103
|
-
//#region src/profile.ts
|
|
104
|
-
/** Reads a profile file for `create`. Hosts never see a profile. */
|
|
105
|
-
/** Profile files are JSON, decoded with closed input like every call: unknown keys are refused. */
|
|
106
|
-
const decodeProfile = Schema.decodeUnknownEffect(Schema.fromJsonString(Profile));
|
|
107
|
-
/**
|
|
108
|
-
* Reads the profile file at `file`. Its name, the file name without `.json`, is the machine's
|
|
109
|
-
* `profile` label, and its `setup.path` is resolved against the file's directory. The setup
|
|
110
|
-
* itself is read by whoever sends it, with `readSetup`.
|
|
111
|
-
*/
|
|
112
|
-
const loadProfile = (file) => Effect.gen(function* () {
|
|
113
|
-
const fs = yield* FileSystem.FileSystem;
|
|
114
|
-
const path = yield* Path.Path;
|
|
115
|
-
const text = yield* fs.readFileString(file).pipe(Effect.mapError((error) => new Invalid({ message: `couldn't read profile ${file}: ${describeFileError(error)}` })));
|
|
116
|
-
const profile = yield* decodeProfile(text, {
|
|
117
|
-
onExcessProperty: "error",
|
|
118
|
-
errors: "all"
|
|
119
|
-
}).pipe(Effect.mapError((error) => new Invalid({ message: `profile ${file}: ${describeIssues(error)}` })));
|
|
120
|
-
const label = path.basename(file, ".json");
|
|
121
|
-
return profile.setup === void 0 ? {
|
|
122
|
-
...profile,
|
|
123
|
-
label
|
|
124
|
-
} : {
|
|
125
|
-
...profile,
|
|
126
|
-
setup: {
|
|
127
|
-
...profile.setup,
|
|
128
|
-
path: path.resolve(path.dirname(file), profile.setup.path)
|
|
129
|
-
},
|
|
130
|
-
label
|
|
131
|
-
};
|
|
132
|
-
});
|
|
133
|
-
//#endregion
|
|
134
92
|
//#region src/setup.ts
|
|
135
|
-
/**
|
|
136
|
-
* Setup scripts for create. A host receives one script: a file is sent as its text, and a
|
|
137
|
-
* recipe directory (a `setup.sh` and the files it needs) is packed into one self-extracting
|
|
138
|
-
* script. Nothing here logs a script or a packed recipe: a recipe can carry secrets.
|
|
139
|
-
*/
|
|
140
93
|
const lineLength = 76;
|
|
141
|
-
/** base64 has no `_`, so no line of a file's payload can end its here-document early. */
|
|
142
94
|
const terminator = "CLANKERBOX_RECIPE";
|
|
143
95
|
const wrap = (base64) => {
|
|
144
96
|
const lines = [];
|
|
145
97
|
for (let at = 0; at < base64.length; at += lineLength) lines.push(base64.slice(at, at + lineLength));
|
|
146
98
|
return lines.join("\n");
|
|
147
99
|
};
|
|
148
|
-
/** A string as one single-quoted shell word, whatever it holds. */
|
|
149
100
|
const quote = (text) => `'${text.replaceAll("'", `'\\''`)}'`;
|
|
150
|
-
/**
|
|
151
|
-
* An entry's path as a command's argument: under `./`, so a name that starts with `-` is never
|
|
152
|
-
* read as an option.
|
|
153
|
-
*/
|
|
154
101
|
const argument = (entry) => quote(`./${entry}`);
|
|
155
|
-
/** Any execute bit, which is the one bit of a mode a recipe carries, as git does. */
|
|
156
102
|
const executable = 73;
|
|
157
|
-
/**
|
|
158
|
-
* Makes the recipe's files in a fresh directory, then runs `./setup.sh` from there: its `#!`
|
|
159
|
-
* line picks the interpreter, as for a single-file setup, and the unpacker marks it executable
|
|
160
|
-
* whatever mode it was packed with. The directory is under `/var/tmp`, as a single-file setup
|
|
161
|
-
* is, which nothing mounts `noexec`, and is removed however setup ends. Setup runs as a child
|
|
162
|
-
* rather than through `exec`, so the cleanup trap still runs after it.
|
|
163
|
-
*/
|
|
164
103
|
const selfExtracting = (steps) => [
|
|
165
104
|
"#!/bin/sh",
|
|
166
105
|
"set -eu",
|
|
@@ -174,20 +113,18 @@ const selfExtracting = (steps) => [
|
|
|
174
113
|
""
|
|
175
114
|
].join("\n");
|
|
176
115
|
/**
|
|
177
|
-
* Packs a recipe directory into one self-extracting script
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* so nothing outside the recipe is packed, and in the guest nothing is written through one.
|
|
181
|
-
* Of a mode only the execute bit is kept, as git keeps it, so a checkout's umask changes
|
|
182
|
-
* nothing. Entries go in sorted order and nothing records a time or an owner, so the same
|
|
183
|
-
* files pack into the same script: a prebuild is named after it.
|
|
116
|
+
* Packs a recipe directory, a `setup.sh` and the files it needs, into one self-extracting script.
|
|
117
|
+
* Symlinks are recreated as they are and never walked into. The same files always pack into the
|
|
118
|
+
* same script, as a prebuild is named after it.
|
|
184
119
|
*/
|
|
185
120
|
const packRecipe = (directory) => Effect.gen(function* () {
|
|
186
121
|
const fs = yield* FileSystem.FileSystem;
|
|
187
|
-
const noSetup = new Invalid({
|
|
122
|
+
const noSetup = new Invalid({
|
|
123
|
+
message: `recipe ${directory} has no setup.sh`,
|
|
124
|
+
file: directory
|
|
125
|
+
});
|
|
188
126
|
if ((yield* fs.stat(`${directory}/setup.sh`).pipe(Effect.mapError((error) => Predicate.isTagged(error.reason, "NotFound") ? noSetup : error))).type !== "File") return yield* noSetup;
|
|
189
127
|
const steps = [];
|
|
190
|
-
/** Packs the entries of the recipe's directory `under`, `""` being the recipe itself. */
|
|
191
128
|
const walk = (under) => Effect.gen(function* () {
|
|
192
129
|
const names = yield* fs.readDirectory(under === "" ? directory : `${directory}/${under}`);
|
|
193
130
|
for (const name of names.toSorted()) {
|
|
@@ -203,55 +140,133 @@ const packRecipe = (directory) => Effect.gen(function* () {
|
|
|
203
140
|
steps.push(`mkdir ${argument(entry)}`);
|
|
204
141
|
yield* walk(entry);
|
|
205
142
|
} else if (info.type === "File") {
|
|
206
|
-
steps.push(`base64 -d >${argument(entry)} <<'${terminator}'`, wrap(
|
|
143
|
+
steps.push(`base64 -d >${argument(entry)} <<'${terminator}'`, wrap(Base64.encode(yield* fs.readFile(path))), terminator);
|
|
207
144
|
if ((info.mode & executable) !== 0) steps.push(`chmod +x ${argument(entry)}`);
|
|
208
|
-
} else return yield* new Invalid({
|
|
145
|
+
} else return yield* new Invalid({
|
|
146
|
+
message: `recipe ${directory} holds ${entry}, a ${info.type}: a recipe packs only files, directories and symlinks`,
|
|
147
|
+
file: path
|
|
148
|
+
});
|
|
209
149
|
}
|
|
210
150
|
});
|
|
211
151
|
yield* walk("");
|
|
212
152
|
return selfExtracting(steps);
|
|
213
|
-
}).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(new Invalid({
|
|
214
|
-
|
|
153
|
+
}).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(new Invalid({
|
|
154
|
+
message: `couldn't pack recipe ${directory}: ${describeFileError(error)}`,
|
|
155
|
+
file: directory
|
|
156
|
+
}))));
|
|
157
|
+
/** A script file's text, or a recipe directory packed into one script. */
|
|
215
158
|
const readSetup = (path) => Effect.gen(function* () {
|
|
216
159
|
const fs = yield* FileSystem.FileSystem;
|
|
217
160
|
if ((yield* fs.stat(path)).type === "Directory") return yield* packRecipe(path);
|
|
218
161
|
return yield* fs.readFileString(path);
|
|
219
|
-
}).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(new Invalid({
|
|
162
|
+
}).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(new Invalid({
|
|
163
|
+
message: `couldn't read setup ${path}: ${describeFileError(error)}`,
|
|
164
|
+
file: path
|
|
165
|
+
}))));
|
|
220
166
|
//#endregion
|
|
221
|
-
//#region src/
|
|
167
|
+
//#region src/profile.ts
|
|
222
168
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
169
|
+
* A client-side file that fills in a create's spec; hosts never see it. `setup.path` is a script
|
|
170
|
+
* file or a recipe directory, relative to the profile file.
|
|
225
171
|
*/
|
|
172
|
+
const Profile = Schema.Struct({
|
|
173
|
+
base: MachineSpec.fields.base,
|
|
174
|
+
cpu: MachineSpec.fields.cpu,
|
|
175
|
+
ramMib: MachineSpec.fields.ramMib,
|
|
176
|
+
diskGib: MachineSpec.fields.diskGib,
|
|
177
|
+
setup: Schema.optionalKey(Schema.Struct({
|
|
178
|
+
path: Schema.String,
|
|
179
|
+
timeoutSeconds: Setup.fields.timeoutSeconds
|
|
180
|
+
})),
|
|
181
|
+
host: Schema.optionalKey(HostId)
|
|
182
|
+
});
|
|
183
|
+
const decodeProfile = Schema.decodeUnknownEffect(Schema.fromJsonString(Profile));
|
|
226
184
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
185
|
+
* Its file name without `.json` is the machine's `profile` label. Unknown keys are refused. The
|
|
186
|
+
* setup itself is read by whoever sends it, with `readSetup`.
|
|
187
|
+
*/
|
|
188
|
+
const loadProfile = (file) => Effect.gen(function* () {
|
|
189
|
+
const fs = yield* FileSystem.FileSystem;
|
|
190
|
+
const path = yield* Path.Path;
|
|
191
|
+
const text = yield* fs.readFileString(file).pipe(Effect.mapError((error) => new Invalid({
|
|
192
|
+
message: `couldn't read profile ${file}: ${describeFileError(error)}`,
|
|
193
|
+
file
|
|
194
|
+
})));
|
|
195
|
+
const profile = yield* decodeProfile(text, {
|
|
196
|
+
onExcessProperty: "error",
|
|
197
|
+
errors: "all"
|
|
198
|
+
}).pipe(Effect.mapError((error) => new Invalid({
|
|
199
|
+
message: `profile ${file}: ${describeIssues(error)}`,
|
|
200
|
+
file
|
|
201
|
+
})));
|
|
202
|
+
const label = path.basename(file, ".json");
|
|
203
|
+
return profile.setup === void 0 ? {
|
|
204
|
+
...profile,
|
|
205
|
+
label
|
|
206
|
+
} : {
|
|
207
|
+
...profile,
|
|
208
|
+
setup: {
|
|
209
|
+
...profile.setup,
|
|
210
|
+
path: path.resolve(path.dirname(file), profile.setup.path)
|
|
211
|
+
},
|
|
212
|
+
label
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
const decodeSpec = Schema.decodeUnknownEffect(MachineSpec);
|
|
216
|
+
/**
|
|
217
|
+
* The spec and host a create of `profile` asks for, with the overrides applied and the setup
|
|
218
|
+
* read. Each refusal names `what`, and each field as `names` calls it.
|
|
219
|
+
*/
|
|
220
|
+
const specRequest = (what, profile, overrides, names) => Effect.gen(function* () {
|
|
221
|
+
const file = overrides.setup ?? profile?.setup?.path;
|
|
222
|
+
const timeoutSeconds = overrides.setupTimeoutSeconds ?? profile?.setup?.timeoutSeconds;
|
|
223
|
+
if (file === void 0 && overrides.setupTimeoutSeconds !== void 0) return yield* new Invalid({ message: `${what}: ${names?.get("setupTimeoutSeconds") ?? "the setup timeout"} needs a setup to time` });
|
|
224
|
+
const script = file === void 0 ? void 0 : yield* readSetup(file);
|
|
225
|
+
const fields = {
|
|
226
|
+
base: overrides.base ?? profile?.base,
|
|
227
|
+
cpu: overrides.cpu ?? profile?.cpu,
|
|
228
|
+
ramMib: overrides.ramMib ?? profile?.ramMib,
|
|
229
|
+
diskGib: overrides.diskGib ?? profile?.diskGib,
|
|
230
|
+
setup: script === void 0 ? void 0 : Record.filter({
|
|
231
|
+
script,
|
|
232
|
+
timeoutSeconds
|
|
233
|
+
}, Predicate.isNotUndefined),
|
|
234
|
+
profile: profile?.label
|
|
235
|
+
};
|
|
236
|
+
return {
|
|
237
|
+
spec: yield* decodeSpec(Record.filter(fields, Predicate.isNotUndefined), {
|
|
238
|
+
onExcessProperty: "error",
|
|
239
|
+
errors: "all"
|
|
240
|
+
}).pipe(Effect.mapError((error) => new Invalid({ message: `${what}: ${describeIssues(error, names)}` }))),
|
|
241
|
+
host: overrides.host ?? profile?.host
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/ssh.ts
|
|
246
|
+
/**
|
|
247
|
+
* Only a running machine has an endpoint, and only a prepared one a host key; a machine without
|
|
248
|
+
* either is `WrongState`, naming what it lacks and why.
|
|
229
249
|
*/
|
|
230
250
|
const sshTarget = (machine) => {
|
|
231
|
-
const { id, ssh: login, hostKey } = machine;
|
|
232
|
-
if (login
|
|
233
|
-
return hostKey === void 0 ? { lacks: "SSH host key on record" } : {
|
|
251
|
+
const { id, ssh: login, hostKey, action } = machine;
|
|
252
|
+
if (login !== void 0 && hostKey !== void 0) return Effect.succeed({
|
|
234
253
|
id,
|
|
235
254
|
login,
|
|
236
255
|
hostKey
|
|
237
|
-
};
|
|
256
|
+
});
|
|
257
|
+
const lacks = login === void 0 ? "SSH endpoint" : "SSH host key on record";
|
|
258
|
+
const state = stateLabel(machine);
|
|
259
|
+
const message = `${id} has no ${lacks} (${state}, ${action.name} ${action.status}): see clankerbox get ${id}`;
|
|
260
|
+
return Effect.fail(new WrongState({
|
|
261
|
+
message,
|
|
262
|
+
machine: id,
|
|
263
|
+
state
|
|
264
|
+
}));
|
|
238
265
|
};
|
|
239
|
-
/**
|
|
240
|
-
* The known-hosts line pinning the machine's host key under its ID: ssh looks the key up by
|
|
241
|
-
* `HostKeyAlias`, so the line doesn't depend on the endpoint, which changes.
|
|
242
|
-
*/
|
|
243
266
|
const knownHostsLine = (id, hostKey) => {
|
|
244
267
|
const [type = "", key = ""] = hostKey.trim().split(/\s+/u);
|
|
245
268
|
return `${id} ${type} ${key}\n`;
|
|
246
269
|
};
|
|
247
|
-
/**
|
|
248
|
-
* ssh's arguments. OpenSSH keeps the first user it is given, so the leading `-l` decides it:
|
|
249
|
-
* one in the caller's arguments or in an ssh config doesn't apply. The destination is the
|
|
250
|
-
* machine's ID, so a `Host <pattern>` block in the user's ssh config can set keys and options
|
|
251
|
-
* per machine; the endpoint and the pinned host key come from command-line options, which no
|
|
252
|
-
* config overrides. The caller's arguments go after the destination, where OpenSSH still reads
|
|
253
|
-
* options (`-L …`) before a remote command.
|
|
254
|
-
*/
|
|
255
270
|
const sshArguments = (target, user, knownHosts, extra) => [
|
|
256
271
|
"-l",
|
|
257
272
|
user,
|
|
@@ -271,10 +286,9 @@ const sshArguments = (target, user, knownHosts, extra) => [
|
|
|
271
286
|
...extra
|
|
272
287
|
];
|
|
273
288
|
/**
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
* boat at every start, so a target is read anew for each ssh.
|
|
289
|
+
* ssh's arguments, pinned to the target's host key through a known-hosts file that lasts as long
|
|
290
|
+
* as the scope, which must outlast the ssh. Ports change on fork, restore, and every boat start,
|
|
291
|
+
* so read the target anew for each ssh.
|
|
278
292
|
*/
|
|
279
293
|
const pinnedSsh = (target, user, extra) => Effect.gen(function* () {
|
|
280
294
|
const fs = yield* FileSystem.FileSystem;
|
|
@@ -285,4 +299,11 @@ const pinnedSsh = (target, user, extra) => Effect.gen(function* () {
|
|
|
285
299
|
return sshArguments(target, user, knownHosts, extra);
|
|
286
300
|
});
|
|
287
301
|
//#endregion
|
|
288
|
-
|
|
302
|
+
//#region src/sizes.ts
|
|
303
|
+
const mibPerGib = 1024;
|
|
304
|
+
/** Exactly, as a size flag takes it: `32G`, `1.5G`, `512M`, `1500M`. */
|
|
305
|
+
const formatMib = (mib) => mib >= 1024 && mib % 512 === 0 ? `${mib / mibPerGib}G` : `${mib}M`;
|
|
306
|
+
/** As a size flag takes it: `60G`. */
|
|
307
|
+
const formatGib = (gib) => `${gib}G`;
|
|
308
|
+
//#endregion
|
|
309
|
+
export { ActionName, ActionRecord, ActionStatus, Capacity, Checkpoint, ClankerboxError, client_exports as Client, Conflict, ConflictKind, CreateRequest, Host, HostError, HostId, Http, Id, Internal, Invalid, Machine, MachineSpec, Missing, Name, NotFound, NotMade, NotOffered, NotReady, PinMismatch, prebuild_exports as Prebuild, Profile, Refused, Runtime, ScriptFailed, Setup, SshEndpoint, SshLogin, Unavailable, Unsizable, Usage, WrongState, actionsByName, describeFileError, describeIssues, formatGib, formatId, formatMib, hostErrors, isReady, loadProfile, mibPerGib, packRecipe, parseId, parseName, pinnedSsh, readSetup, readTimeout, release, retryable, specRequest, sshTarget, stateLabel, version, versionHeader };
|
package/dist/node.d.mts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { N as Invalid, a as Options, r as HostEntry, t as Client } from "./client-8af586hb.mjs";
|
|
2
2
|
import { Layer } from "effect";
|
|
3
3
|
//#region src/node.d.ts
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* The client over Node's `http`, which sets no timeout of its own: a mutation can run for hours.
|
|
6
|
+
* It is in `./node`, apart from `.`, so an app that brings its own `HttpClient` never loads
|
|
7
|
+
* `@effect/platform-node`.
|
|
8
|
+
*/
|
|
5
9
|
export declare const layer: (hosts: ReadonlyArray<HostEntry>, options?: Options) => Layer.Layer<Client, Invalid>;
|
|
6
10
|
//#endregion
|
package/dist/node.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { n as layer$1 } from "./client-CbMWwHdT.mjs";
|
|
2
2
|
import { Layer } from "effect";
|
|
3
3
|
import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
|
|
4
4
|
//#region src/node.ts
|
|
5
5
|
/**
|
|
6
|
-
* The client over Node's `http
|
|
7
|
-
* app that brings its own `HttpClient`
|
|
6
|
+
* The client over Node's `http`, which sets no timeout of its own: a mutation can run for hours.
|
|
7
|
+
* It is in `./node`, apart from `.`, so an app that brings its own `HttpClient` never loads
|
|
8
|
+
* `@effect/platform-node`.
|
|
8
9
|
*/
|
|
9
|
-
|
|
10
|
-
const layer = (hosts, options) => Layer.effect(Client, make(hosts, options)).pipe(Layer.provide(NodeHttpClient.layerNodeHttp));
|
|
10
|
+
const layer = (hosts, options) => layer$1(hosts, options).pipe(Layer.provide(NodeHttpClient.layerNodeHttp));
|
|
11
11
|
//#endregion
|
|
12
12
|
export { layer };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/clankerbox-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "clankerbox's API contract and client library, for Effect",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,15 +28,17 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@gjermundgaraba/effect-actions": "0.
|
|
31
|
+
"@gjermundgaraba/effect-actions": "0.17.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@effect/platform-node": "4.0.4",
|
|
35
|
+
"@effect/vitest": "4.0.4",
|
|
35
36
|
"@types/node": "26.6.4",
|
|
36
37
|
"effect": "4.0.4",
|
|
37
38
|
"typescript": "7.0.2",
|
|
38
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@1.
|
|
39
|
-
"vite-plus": "1.
|
|
39
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.2.0",
|
|
40
|
+
"vite-plus": "1.2.0",
|
|
41
|
+
"vitest": "5.0.4"
|
|
40
42
|
},
|
|
41
43
|
"peerDependencies": {
|
|
42
44
|
"@effect/platform-node": "~4.0.4",
|