@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/client-BkZhmYJx.mjs
DELETED
|
@@ -1,662 +0,0 @@
|
|
|
1
|
-
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
-
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
-
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
4
|
-
import { Context, Duration, Effect, Fiber, Match, Predicate, Result, Schema, SchemaIssue } from "effect";
|
|
5
|
-
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
6
|
-
//#region src/errors.ts
|
|
7
|
-
/**
|
|
8
|
-
* The seven errors every clankerbox call can fail with. `retryable` is derived from the
|
|
9
|
-
* tag, so callers never keep their own table of which failures to repeat.
|
|
10
|
-
*/
|
|
11
|
-
/** The request is malformed: a bad ID or name, an undeclared field, a value out of range. */
|
|
12
|
-
var Invalid = class extends Schema.TaggedError()("Invalid", { message: Schema.String }, { httpApiStatus: 400 }) {
|
|
13
|
-
get retryable() {
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
16
|
-
};
|
|
17
|
-
/** The machine or checkpoint doesn't exist. A `delete` that meets it is done. */
|
|
18
|
-
var NotFound = class extends Schema.TaggedError()("NotFound", { message: Schema.String }, { httpApiStatus: 404 }) {
|
|
19
|
-
get retryable() {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
};
|
|
23
|
-
const ConflictKind = Schema.Literals(["exists", "busy"]);
|
|
24
|
-
/** The name is taken (`exists`), or another action holds the resource (`busy`). */
|
|
25
|
-
var Conflict = class extends Schema.TaggedError()("Conflict", {
|
|
26
|
-
message: Schema.String,
|
|
27
|
-
kind: ConflictKind
|
|
28
|
-
}, { httpApiStatus: 409 }) {
|
|
29
|
-
get retryable() {
|
|
30
|
-
return this.kind === "busy";
|
|
31
|
-
}
|
|
32
|
-
};
|
|
33
|
-
/** The resource's state doesn't allow the action, or no host offers the requested base. */
|
|
34
|
-
var Precondition = class extends Schema.TaggedError()("Precondition", { message: Schema.String }, { httpApiStatus: 412 }) {
|
|
35
|
-
get retryable() {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
/** The host has no room now; room can free up, so a later call may succeed. */
|
|
40
|
-
var Capacity = class extends Schema.TaggedError()("Capacity", { message: Schema.String }, { httpApiStatus: 503 }) {
|
|
41
|
-
get retryable() {
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
/**
|
|
46
|
-
* The client couldn't reach the host, or lost its reply. Only the client library produces
|
|
47
|
-
* it. `readOnly` is that of the action that met it: a mutation's request may have reached
|
|
48
|
-
* the host and run, so it is never retryable.
|
|
49
|
-
*/
|
|
50
|
-
var Unavailable = class extends Schema.TaggedError()("Unavailable", {
|
|
51
|
-
message: Schema.String,
|
|
52
|
-
readOnly: Schema.Boolean
|
|
53
|
-
}) {
|
|
54
|
-
get retryable() {
|
|
55
|
-
return this.readOnly;
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
var Internal = class extends Schema.TaggedError()("Internal", { message: Schema.String }, { httpApiStatus: 500 }) {
|
|
59
|
-
get retryable() {
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
/** What a host answers with; every action declares these. */
|
|
64
|
-
const hostErrors = [
|
|
65
|
-
Invalid,
|
|
66
|
-
NotFound,
|
|
67
|
-
Conflict,
|
|
68
|
-
Precondition,
|
|
69
|
-
Capacity,
|
|
70
|
-
Internal
|
|
71
|
-
];
|
|
72
|
-
/** The tag a host records on a failed action: always one of the errors it answers with. */
|
|
73
|
-
const HostErrorTag = Schema.Literals(hostErrors.map((error) => error.fields._tag.schema.literal));
|
|
74
|
-
/** The tag of any error a call through the client library can fail with. */
|
|
75
|
-
const ErrorTag = Schema.Literals([...HostErrorTag.literals, Unavailable.fields._tag.schema.literal]);
|
|
76
|
-
//#endregion
|
|
77
|
-
//#region src/ids.ts
|
|
78
|
-
/**
|
|
79
|
-
* Machine and checkpoint IDs, `<host>_<name>`: the one place that parses and formats them.
|
|
80
|
-
* Neither part contains `_`, so an ID has exactly one. The whole ID matches
|
|
81
|
-
* `^[A-Za-z0-9_-]{1,62}$` because it is written to `/var/lib/clankerbox/machine-id`, and
|
|
82
|
-
* clankercreds accepts only that pattern.
|
|
83
|
-
*/
|
|
84
|
-
const separator = "_";
|
|
85
|
-
const maxIdLength = 62;
|
|
86
|
-
const idPattern = /^[A-Za-z0-9_-]{1,62}$/u;
|
|
87
|
-
/**
|
|
88
|
-
* boat's named snapshots are `cbx-<host>-<inst>`, and boat allows `^[a-z0-9][a-z0-9-]{0,62}$`:
|
|
89
|
-
* a lowercase host ID of at most 32 characters keeps the name within 63.
|
|
90
|
-
*/
|
|
91
|
-
const hostIdPattern = /^[a-z][a-z0-9-]{0,31}$/u;
|
|
92
|
-
const hostIdRule = "a host ID is 1 to 32 lowercase letters, digits and '-', starting with a letter";
|
|
93
|
-
/**
|
|
94
|
-
* smolvm names a machine `<name>-<inst>` and refuses consecutive hyphens, so a name has no
|
|
95
|
-
* `--` and doesn't end in `-`.
|
|
96
|
-
*/
|
|
97
|
-
const namePattern = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*$/u;
|
|
98
|
-
const nameRule = "a name starts with a letter, then letters, digits and '-', with no '--' and no '-' at the end";
|
|
99
|
-
/** A host's part of every ID it holds. */
|
|
100
|
-
const HostId = Schema.String.check(Schema.isPattern(hostIdPattern, { message: hostIdRule }));
|
|
101
|
-
/** A machine's or checkpoint's name, unique per host and per resource type. */
|
|
102
|
-
const Name = Schema.String.check(Schema.isPattern(namePattern, { message: nameRule }));
|
|
103
|
-
const describeId = (id) => {
|
|
104
|
-
if (!idPattern.test(id)) return `ID ${JSON.stringify(id)} must be 1 to ${maxIdLength} letters, digits, '_' or '-'`;
|
|
105
|
-
const at = id.indexOf(separator);
|
|
106
|
-
if (at <= 0 || at === id.length - 1) return `ID ${JSON.stringify(id)} must be <host>_<name>`;
|
|
107
|
-
if (!hostIdPattern.test(id.slice(0, at))) return `ID ${JSON.stringify(id)} has an invalid host: ${hostIdRule}`;
|
|
108
|
-
if (!namePattern.test(id.slice(at + 1))) return `ID ${JSON.stringify(id)} has an invalid name: ${nameRule}`;
|
|
109
|
-
};
|
|
110
|
-
/** A full `<host>_<name>` ID. */
|
|
111
|
-
const Id = Schema.String.check(Schema.makeFilter((id) => describeId(id) ?? true));
|
|
112
|
-
/** Splits an ID at its `_` into host and name. */
|
|
113
|
-
const parseId = (id) => {
|
|
114
|
-
const problem = describeId(id);
|
|
115
|
-
if (problem !== void 0) return Effect.fail(new Invalid({ message: problem }));
|
|
116
|
-
const at = id.indexOf(separator);
|
|
117
|
-
return Effect.succeed({
|
|
118
|
-
host: id.slice(0, at),
|
|
119
|
-
name: id.slice(at + 1)
|
|
120
|
-
});
|
|
121
|
-
};
|
|
122
|
-
/** Checks a name on its own, before it is joined to a host. */
|
|
123
|
-
const parseName = (name) => namePattern.test(name) ? Effect.succeed(name) : Effect.fail(new Invalid({ message: `name ${JSON.stringify(name)}: ${nameRule}` }));
|
|
124
|
-
/** Joins a host ID and a name into the resource's ID, checking both parts and the whole. */
|
|
125
|
-
const formatId = (host, name) => {
|
|
126
|
-
if (!hostIdPattern.test(host)) return Effect.fail(new Invalid({ message: `host ID ${JSON.stringify(host)}: ${hostIdRule}` }));
|
|
127
|
-
const id = `${host}${separator}${name}`;
|
|
128
|
-
return parseName(name).pipe(Effect.andThen(parseId(id)), Effect.as(id));
|
|
129
|
-
};
|
|
130
|
-
//#endregion
|
|
131
|
-
//#region package.json
|
|
132
|
-
var version$1 = "0.15.6";
|
|
133
|
-
//#endregion
|
|
134
|
-
//#region src/resources.ts
|
|
135
|
-
/** The resources a host reports: machines, checkpoints and the host itself. */
|
|
136
|
-
const Runtime = Schema.Literals([
|
|
137
|
-
"smolvm",
|
|
138
|
-
"tart",
|
|
139
|
-
"boat"
|
|
140
|
-
]);
|
|
141
|
-
const Size = Schema.Int.check(Schema.isGreaterThan(0));
|
|
142
|
-
/** The mutations that record themselves on a resource's `action`. */
|
|
143
|
-
const ActionName = Schema.Literals([
|
|
144
|
-
"create",
|
|
145
|
-
"start",
|
|
146
|
-
"stop",
|
|
147
|
-
"delete",
|
|
148
|
-
"fork",
|
|
149
|
-
"restore",
|
|
150
|
-
"capture"
|
|
151
|
-
]);
|
|
152
|
-
/**
|
|
153
|
-
* A resource's last action: `running` while it holds the row, `failed` with its error
|
|
154
|
-
* after a native failure, `done` after a success. The next action replaces it.
|
|
155
|
-
*/
|
|
156
|
-
const ActionRecord = Schema.Struct({
|
|
157
|
-
name: ActionName,
|
|
158
|
-
status: Schema.Literals([
|
|
159
|
-
"running",
|
|
160
|
-
"failed",
|
|
161
|
-
"done"
|
|
162
|
-
]),
|
|
163
|
-
error: Schema.optionalKey(Schema.Struct({
|
|
164
|
-
tag: HostErrorTag,
|
|
165
|
-
message: Schema.String
|
|
166
|
-
}))
|
|
167
|
-
});
|
|
168
|
-
/** Where a machine's guest port 22 is reached. */
|
|
169
|
-
const SshEndpoint = Schema.Struct({
|
|
170
|
-
host: Schema.String,
|
|
171
|
-
port: Schema.Int.check(Schema.isBetween({
|
|
172
|
-
minimum: 1,
|
|
173
|
-
maximum: 65535
|
|
174
|
-
}))
|
|
175
|
-
});
|
|
176
|
-
/** Where a running machine's SSH is reached, and the guest user its runtime's logins are for. */
|
|
177
|
-
const SshLogin = Schema.Struct({
|
|
178
|
-
user: Schema.String,
|
|
179
|
-
...SshEndpoint.fields
|
|
180
|
-
});
|
|
181
|
-
const Machine = Schema.Struct({
|
|
182
|
-
id: Id,
|
|
183
|
-
runtime: Runtime,
|
|
184
|
-
createdAt: Schema.DateTimeUtcFromString,
|
|
185
|
-
base: Schema.String,
|
|
186
|
-
/** The label the client passed at create, normally its profile's name. */
|
|
187
|
-
profile: Schema.optionalKey(Schema.String),
|
|
188
|
-
cpu: Size,
|
|
189
|
-
ramMib: Size,
|
|
190
|
-
diskGib: Size,
|
|
191
|
-
/**
|
|
192
|
-
* Read from the runtime, never stored: `missing` when the runtime doesn't have the machine,
|
|
193
|
-
* `unknown` when it couldn't be read.
|
|
194
|
-
*/
|
|
195
|
-
state: Schema.Literals([
|
|
196
|
-
"running",
|
|
197
|
-
"stopped",
|
|
198
|
-
"missing",
|
|
199
|
-
"unknown"
|
|
200
|
-
]),
|
|
201
|
-
/**
|
|
202
|
-
* Whether its create, fork or restore has made it. One that isn't made, because that action
|
|
203
|
-
* still runs or failed, can only be read, stopped or deleted.
|
|
204
|
-
*/
|
|
205
|
-
made: Schema.Boolean,
|
|
206
|
-
action: ActionRecord,
|
|
207
|
-
/** Only on a running machine whose runtime gives it an endpoint. */
|
|
208
|
-
ssh: Schema.optionalKey(SshLogin),
|
|
209
|
-
/** The guest's SSH host public key, as `<type> <base64>`, once preparation has recorded it. */
|
|
210
|
-
hostKey: Schema.optionalKey(Schema.String)
|
|
211
|
-
}).annotate({ identifier: "Machine" });
|
|
212
|
-
const Checkpoint = Schema.Struct({
|
|
213
|
-
id: Id,
|
|
214
|
-
createdAt: Schema.DateTimeUtcFromString,
|
|
215
|
-
/** The ID of the machine it was captured from. */
|
|
216
|
-
machine: Id,
|
|
217
|
-
/** What it holds, by runtime: `ram` (RAM and disks) on smolvm, `disk` on Tart and boat. */
|
|
218
|
-
kind: Schema.Literals(["ram", "disk"]),
|
|
219
|
-
base: Schema.String,
|
|
220
|
-
profile: Schema.optionalKey(Schema.String),
|
|
221
|
-
cpu: Size,
|
|
222
|
-
ramMib: Size,
|
|
223
|
-
diskGib: Size,
|
|
224
|
-
action: ActionRecord
|
|
225
|
-
}).annotate({ identifier: "Checkpoint" });
|
|
226
|
-
/** The SDK's version, which is also the version of the clankerbox binaries it ships with. */
|
|
227
|
-
const version = version$1;
|
|
228
|
-
/** A version's major.minor: releases that share it speak the same API. */
|
|
229
|
-
const release = (of) => of.split(".").slice(0, 2).join(".");
|
|
230
|
-
const Host = Schema.Struct({
|
|
231
|
-
id: HostId,
|
|
232
|
-
runtime: Runtime,
|
|
233
|
-
/** The clankerbox version the host runs. */
|
|
234
|
-
version: Schema.String,
|
|
235
|
-
/** The runtime's version; for boat, its API version. */
|
|
236
|
-
runtimeVersion: Schema.String,
|
|
237
|
-
bases: Schema.Array(Schema.String)
|
|
238
|
-
}).annotate({ identifier: "Host" });
|
|
239
|
-
/**
|
|
240
|
-
* One limit a host's `Capacity` refusals come from, and how much of it is in use, counted as
|
|
241
|
-
* the refusal counts it, so `used` needn't match the host's own machines. Read from the runtime,
|
|
242
|
-
* never stored.
|
|
243
|
-
*/
|
|
244
|
-
const Limit = Schema.Struct({
|
|
245
|
-
host: HostId,
|
|
246
|
-
/**
|
|
247
|
-
* `ramMib`: smolvm's RAM budget. `runningVms`: Tart's, Apple's two VMs per Mac.
|
|
248
|
-
* `activeSandboxes` and the `starts` of each rolling window: the boat account's.
|
|
249
|
-
*/
|
|
250
|
-
resource: Schema.Literals([
|
|
251
|
-
"ramMib",
|
|
252
|
-
"runningVms",
|
|
253
|
-
"activeSandboxes",
|
|
254
|
-
"startsPerMinute",
|
|
255
|
-
"startsPerHour",
|
|
256
|
-
"startsPerDay"
|
|
257
|
-
]),
|
|
258
|
-
limit: Schema.Int,
|
|
259
|
-
used: Schema.Int
|
|
260
|
-
}).annotate({ identifier: "Limit" });
|
|
261
|
-
/**
|
|
262
|
-
* A setup script and how long it may run, the host's default when it doesn't say. Nothing logs
|
|
263
|
-
* the script: it can carry secrets.
|
|
264
|
-
*/
|
|
265
|
-
const Setup = Schema.Struct({
|
|
266
|
-
script: Schema.String,
|
|
267
|
-
timeoutSeconds: Schema.optionalKey(Size)
|
|
268
|
-
});
|
|
269
|
-
const specFields = {
|
|
270
|
-
base: Schema.String,
|
|
271
|
-
cpu: Size,
|
|
272
|
-
ramMib: Size,
|
|
273
|
-
diskGib: Size,
|
|
274
|
-
setup: Schema.optionalKey(Setup),
|
|
275
|
-
profile: Schema.optionalKey(Schema.String)
|
|
276
|
-
};
|
|
277
|
-
/**
|
|
278
|
-
* What a new machine is made from: a base, its sizes, an optional setup script with its
|
|
279
|
-
* timeout, and an optional `profile` label. A profile file fills these in; the host never
|
|
280
|
-
* sees the profile itself.
|
|
281
|
-
*/
|
|
282
|
-
const MachineSpec = Schema.Struct(specFields);
|
|
283
|
-
/**
|
|
284
|
-
* `machine.create`'s input: the spec and the new machine's full ID. The host checks that the ID
|
|
285
|
-
* names it, so a create never lands under another host's ID.
|
|
286
|
-
*/
|
|
287
|
-
const CreateRequest = Schema.Struct({
|
|
288
|
-
id: Id,
|
|
289
|
-
...specFields
|
|
290
|
-
});
|
|
291
|
-
/**
|
|
292
|
-
* A profile file: a client-side file that fills in a create request. `setup.path` is a script
|
|
293
|
-
* file or a recipe directory, relative to the profile file; without a `timeoutSeconds` the host's
|
|
294
|
-
* default applies. `host` places the create on that host instead of by base.
|
|
295
|
-
*/
|
|
296
|
-
const Profile = Schema.Struct({
|
|
297
|
-
base: specFields.base,
|
|
298
|
-
cpu: specFields.cpu,
|
|
299
|
-
ramMib: specFields.ramMib,
|
|
300
|
-
diskGib: specFields.diskGib,
|
|
301
|
-
setup: Schema.optionalKey(Schema.Struct({
|
|
302
|
-
path: Schema.String,
|
|
303
|
-
timeoutSeconds: Schema.optionalKey(Size)
|
|
304
|
-
})),
|
|
305
|
-
host: Schema.optionalKey(HostId)
|
|
306
|
-
});
|
|
307
|
-
//#endregion
|
|
308
|
-
//#region src/api.ts
|
|
309
|
-
/**
|
|
310
|
-
* The host API: one effect-actions binding, each action a JSON POST route at `/api/<action>`.
|
|
311
|
-
* A mutation replies when its action has finished. Every action is public: a host listens
|
|
312
|
-
* only on the tailnet or loopback.
|
|
313
|
-
*/
|
|
314
|
-
/**
|
|
315
|
-
* How long a client waits for a read's reply from one host, unless it sets its own. A host
|
|
316
|
-
* bounds its own reads of runtime state under it, so a list answers in time.
|
|
317
|
-
*/
|
|
318
|
-
const readTimeout = Duration.seconds(10);
|
|
319
|
-
/**
|
|
320
|
-
* The header every client request carries the SDK's `version` in. A host refuses a request
|
|
321
|
-
* whose release (major.minor) differs from its own, or that carries none, before it runs
|
|
322
|
-
* anything.
|
|
323
|
-
*/
|
|
324
|
-
const versionHeader = "clankerbox-version";
|
|
325
|
-
const ById = Schema.Struct({ id: Id });
|
|
326
|
-
/** Input of the actions that make a new resource from a machine: its ID and the new name. */
|
|
327
|
-
const FromMachine = Schema.Struct({
|
|
328
|
-
machine: Id,
|
|
329
|
-
name: Name
|
|
330
|
-
});
|
|
331
|
-
/**
|
|
332
|
-
* What every action shares, by whether it reads or changes the host. Every action declares
|
|
333
|
-
* `hostErrors`, so a client decodes the `Invalid` the host's version check answers with.
|
|
334
|
-
*/
|
|
335
|
-
const read = {
|
|
336
|
-
caller: Action.Anyone,
|
|
337
|
-
error: hostErrors,
|
|
338
|
-
readOnly: true
|
|
339
|
-
};
|
|
340
|
-
const write = {
|
|
341
|
-
caller: Action.Anyone,
|
|
342
|
-
error: hostErrors,
|
|
343
|
-
readOnly: false
|
|
344
|
-
};
|
|
345
|
-
const Http = ActionHttp.make([
|
|
346
|
-
Action.make("listMachines", {
|
|
347
|
-
...read,
|
|
348
|
-
description: "List the host's machines.",
|
|
349
|
-
success: Schema.Array(Machine)
|
|
350
|
-
}),
|
|
351
|
-
Action.make("getMachine", {
|
|
352
|
-
...read,
|
|
353
|
-
description: "Read one machine.",
|
|
354
|
-
input: ById,
|
|
355
|
-
success: Machine
|
|
356
|
-
}),
|
|
357
|
-
Action.make("createMachine", {
|
|
358
|
-
...write,
|
|
359
|
-
description: "Create a machine from a base, run its setup once, and prepare it.",
|
|
360
|
-
input: CreateRequest,
|
|
361
|
-
success: Machine
|
|
362
|
-
}),
|
|
363
|
-
Action.make("startMachine", {
|
|
364
|
-
...write,
|
|
365
|
-
description: "Start a machine. On a running machine, run preparation again.",
|
|
366
|
-
input: ById,
|
|
367
|
-
success: Machine
|
|
368
|
-
}),
|
|
369
|
-
Action.make("stopMachine", {
|
|
370
|
-
...write,
|
|
371
|
-
description: "Stop a machine. A stopped machine stays stopped, and the stop is still its last action.",
|
|
372
|
-
input: ById,
|
|
373
|
-
success: Machine
|
|
374
|
-
}),
|
|
375
|
-
Action.make("deleteMachine", {
|
|
376
|
-
...write,
|
|
377
|
-
description: "Delete a machine and everything native it owns.",
|
|
378
|
-
input: ById
|
|
379
|
-
}),
|
|
380
|
-
Action.make("forkMachine", {
|
|
381
|
-
...write,
|
|
382
|
-
description: "Copy a machine to a new name on the same host.",
|
|
383
|
-
input: FromMachine,
|
|
384
|
-
success: Machine
|
|
385
|
-
}),
|
|
386
|
-
Action.make("restoreMachine", {
|
|
387
|
-
...write,
|
|
388
|
-
description: "Create a machine on the checkpoint's host from that checkpoint.",
|
|
389
|
-
input: Schema.Struct({
|
|
390
|
-
checkpoint: Id,
|
|
391
|
-
name: Name
|
|
392
|
-
}),
|
|
393
|
-
success: Machine
|
|
394
|
-
}),
|
|
395
|
-
Action.make("listCheckpoints", {
|
|
396
|
-
...read,
|
|
397
|
-
description: "List the host's checkpoints.",
|
|
398
|
-
success: Schema.Array(Checkpoint)
|
|
399
|
-
}),
|
|
400
|
-
Action.make("getCheckpoint", {
|
|
401
|
-
...read,
|
|
402
|
-
description: "Read one checkpoint.",
|
|
403
|
-
input: ById,
|
|
404
|
-
success: Checkpoint
|
|
405
|
-
}),
|
|
406
|
-
Action.make("captureCheckpoint", {
|
|
407
|
-
...write,
|
|
408
|
-
description: "Capture a checkpoint of a machine. Its kind follows the runtime: ram on smolvm, which captures only a running machine, and disk on Tart and boat.",
|
|
409
|
-
input: FromMachine,
|
|
410
|
-
success: Checkpoint
|
|
411
|
-
}),
|
|
412
|
-
Action.make("deleteCheckpoint", {
|
|
413
|
-
...write,
|
|
414
|
-
description: "Delete a checkpoint.",
|
|
415
|
-
input: ById
|
|
416
|
-
}),
|
|
417
|
-
Action.make("getHost", {
|
|
418
|
-
...read,
|
|
419
|
-
description: "Read the host: its runtime, versions and bases.",
|
|
420
|
-
success: Host
|
|
421
|
-
}),
|
|
422
|
-
Action.make("getCapacity", {
|
|
423
|
-
...read,
|
|
424
|
-
description: "Read the host's limits and how much of each is in use. It fails when the runtime can't be read.",
|
|
425
|
-
success: Schema.Array(Limit)
|
|
426
|
-
})
|
|
427
|
-
], { prefix: "/api" });
|
|
428
|
-
//#endregion
|
|
429
|
-
//#region src/text.ts
|
|
430
|
-
/** How sizes, decode failures and file failures read in a message or a table cell. */
|
|
431
|
-
const mibPerGib = 1024;
|
|
432
|
-
/**
|
|
433
|
-
* A RAM size, exactly, as a size flag takes one: GiB when it is whole or half GiB, `32G` or
|
|
434
|
-
* `1.5G`, and MiB when it isn't, `512M` or `1500M`.
|
|
435
|
-
*/
|
|
436
|
-
const formatMib = (mib) => mib >= mibPerGib && mib % (mibPerGib / 2) === 0 ? `${mib / mibPerGib}G` : `${mib}M`;
|
|
437
|
-
/**
|
|
438
|
-
* Where a decode failed, as a JSON path: `hosts[0].id`. `names` gives a top-level field the
|
|
439
|
-
* name its caller knows it by.
|
|
440
|
-
*/
|
|
441
|
-
const jsonPath = (path, names) => path.map((key, index) => {
|
|
442
|
-
if (Number.isInteger(key)) return `[${String(key)}]`;
|
|
443
|
-
return index === 0 ? names?.get(key) ?? String(key) : `.${String(key)}`;
|
|
444
|
-
}).join("");
|
|
445
|
-
const flatten = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => Match.value(issue).pipe(Match.tag("MissingKey", () => "missing"), Match.tag("UnexpectedKey", () => "not a known field"), Match.orElse(SchemaIssue.defaultLeafHook)) });
|
|
446
|
-
/**
|
|
447
|
-
* Everything a decode refused, on one line, each under its JSON path: `cpu: missing; ram: not a
|
|
448
|
-
* known field`. `names` renames top-level fields, as when a flag set each: `--cpu: missing`.
|
|
449
|
-
*/
|
|
450
|
-
const describeIssues = (error, names) => flatten(error.issue).issues.map(({ path = [], message }) => {
|
|
451
|
-
const keys = path.map((segment) => Predicate.isPropertyKey(segment) ? segment : segment.key);
|
|
452
|
-
return keys.length === 0 ? message : `${jsonPath(keys, names)}: ${message}`;
|
|
453
|
-
}).join("; ");
|
|
454
|
-
/**
|
|
455
|
-
* Why a file couldn't be read, without the platform call that met it. Node describes a failure
|
|
456
|
-
* only in its cause, so what has no wording here reads as the system's own.
|
|
457
|
-
*/
|
|
458
|
-
const describeFileError = ({ reason }) => {
|
|
459
|
-
if (Predicate.isTagged(reason, "NotFound")) return "no such file or directory";
|
|
460
|
-
if (Predicate.isTagged(reason, "PermissionDenied")) return "permission denied";
|
|
461
|
-
return reason.description ?? (reason.cause instanceof Error ? reason.cause.message : reason._tag);
|
|
462
|
-
};
|
|
463
|
-
//#endregion
|
|
464
|
-
//#region src/client.ts
|
|
465
|
-
/**
|
|
466
|
-
* The client library: a host list in placement order, routing by ID, list fan-out and
|
|
467
|
-
* create placement. Nothing here retries: a mutation whose reply is lost, or that outlasts
|
|
468
|
-
* the client's mutation timeout, is `Unavailable`, and the caller reads the resource to see
|
|
469
|
-
* what happened.
|
|
470
|
-
*/
|
|
471
|
-
var client_exports = /* @__PURE__ */ __exportAll({
|
|
472
|
-
Client: () => Client,
|
|
473
|
-
HostEntry: () => HostEntry,
|
|
474
|
-
make: () => make
|
|
475
|
-
});
|
|
476
|
-
/** One host a client talks to. `id` is the host part of every ID it holds. */
|
|
477
|
-
const HostEntry = Schema.Struct({
|
|
478
|
-
id: HostId,
|
|
479
|
-
url: Schema.String.check(Schema.makeFilter((url) => URL.canParse(url) || "a URL, such as http://linux:8484"))
|
|
480
|
-
});
|
|
481
|
-
var Client = class extends Context.Service()("@gjermundgaraba/clankerbox-sdk/Client") {};
|
|
482
|
-
/**
|
|
483
|
-
* A client for one host. Every request carries the SDK's version, which the host checks
|
|
484
|
-
* against its own. Input that doesn't encode fails as `InvalidInput` before anything is
|
|
485
|
-
* sent, so a `SchemaError` that reaches `settle` is a reply that didn't decode.
|
|
486
|
-
*/
|
|
487
|
-
const makeHostApi = (entry) => ActionHttp.client(Http, {
|
|
488
|
-
baseUrl: entry.url,
|
|
489
|
-
transformClient: HttpClient.mapRequest(HttpClientRequest.setHeader(versionHeader, version))
|
|
490
|
-
});
|
|
491
|
-
/** Every action's contract, by name: a call's timeout and retry follow its `readOnly`. */
|
|
492
|
-
const actions = Action.byName(Http.actions);
|
|
493
|
-
const causeDetail = (error) => error.cause instanceof Error ? error.cause.message : error.message;
|
|
494
|
-
/**
|
|
495
|
-
* No reply came: the connection failed or dropped, or the timeout passed. A mutation's
|
|
496
|
-
* request may or may not have reached its host, so it may have run.
|
|
497
|
-
*/
|
|
498
|
-
const unanswered = (context, detail) => context.readOnly ? new Unavailable({
|
|
499
|
-
message: `host ${context.host.id} (${context.host.url}) didn't answer ${context.action}: ${detail}`,
|
|
500
|
-
readOnly: context.readOnly
|
|
501
|
-
}) : new Unavailable({
|
|
502
|
-
message: `no reply from host ${context.host.id} to ${context.action} (${detail}); the action may have run: read ${context.target ?? "the resource"} to see`,
|
|
503
|
-
readOnly: context.readOnly
|
|
504
|
-
});
|
|
505
|
-
/** Bounds the wait for the reply; the clock starts when the request is sent. */
|
|
506
|
-
const bounded = (call, context) => {
|
|
507
|
-
const { timeout } = context;
|
|
508
|
-
return timeout === void 0 ? call : Effect.timeoutOrElse(call, {
|
|
509
|
-
duration: timeout,
|
|
510
|
-
orElse: () => Effect.fail(unanswered(context, `timed out after ${Duration.format(timeout)}`))
|
|
511
|
-
});
|
|
512
|
-
};
|
|
513
|
-
/** The host answered with something the call doesn't expect. */
|
|
514
|
-
const unexpected = (context, error) => new Internal({ message: `host ${context.host.id} answered ${context.action} unexpectedly: ${error.message}` });
|
|
515
|
-
const settle = (call, context) => bounded(call, context).pipe(Effect.catchTags({
|
|
516
|
-
HttpClientError: (error) => Effect.fail(Match.value(error.reason).pipe(Match.tag("TransportError", () => unanswered(context, causeDetail(error))), Match.when({
|
|
517
|
-
_tag: "DecodeError",
|
|
518
|
-
cause: (cause) => cause !== void 0 && !(cause instanceof SyntaxError)
|
|
519
|
-
}, () => unanswered(context, causeDetail(error))), Match.orElse(() => unexpected(context, error)))),
|
|
520
|
-
InvalidInput: (error) => Effect.fail(new Invalid({ message: `${context.action}: the request doesn't match the action's input: ${error.message}` })),
|
|
521
|
-
SchemaError: (error) => Effect.fail(new Internal({ message: `host ${context.host.id}'s reply didn't decode: ${error.message}` })),
|
|
522
|
-
Unauthenticated: (error) => Effect.fail(unexpected(context, error)),
|
|
523
|
-
Forbidden: (error) => Effect.fail(unexpected(context, error))
|
|
524
|
-
}));
|
|
525
|
-
/** A host this client can use: it calls itself by its entry's ID. */
|
|
526
|
-
const checkHost = (entry, host) => host.id === entry.id ? Effect.succeed(host) : Effect.fail(new Invalid({ message: `host ${entry.id} (${entry.url}) calls itself ${host.id}; its entry in the host list must use that ID` }));
|
|
527
|
-
/** Every entry is checked here, so a call never meets a malformed URL. */
|
|
528
|
-
const decodeHosts = Schema.decodeUnknownEffect(Schema.Array(HostEntry));
|
|
529
|
-
/**
|
|
530
|
-
* Builds a client over `hosts`, in placement order. Making it sends nothing. An empty list
|
|
531
|
-
* is Invalid, and so is a malformed entry or a host ID that appears twice: IDs route by it.
|
|
532
|
-
*/
|
|
533
|
-
const make = (hosts, options) => Effect.gen(function* () {
|
|
534
|
-
if (hosts.length === 0) return yield* new Invalid({ message: "the host list is empty: a client needs a host" });
|
|
535
|
-
yield* decodeHosts(hosts, { errors: "all" }).pipe(Effect.mapError((error) => new Invalid({ message: `host list: ${describeIssues(error)}` })));
|
|
536
|
-
const twice = hosts.find(({ id }, index) => hosts.findIndex((other) => other.id === id) < index);
|
|
537
|
-
if (twice !== void 0) return yield* new Invalid({ message: `host ${twice.id} appears twice in the host list` });
|
|
538
|
-
const routed = yield* Effect.forEach(hosts, (entry) => Effect.map(makeHostApi(entry), (api) => ({
|
|
539
|
-
entry,
|
|
540
|
-
api
|
|
541
|
-
})));
|
|
542
|
-
const routes = new Map(routed.map((route) => [route.entry.id, route]));
|
|
543
|
-
const route = (host) => {
|
|
544
|
-
const found = routes.get(host);
|
|
545
|
-
return found === void 0 ? Effect.fail(new Invalid({ message: `host ${host} isn't in the host list` })) : Effect.succeed(found);
|
|
546
|
-
};
|
|
547
|
-
/**
|
|
548
|
-
* Sends one call to one host, bounded by the read or the mutation timeout. `target` is the
|
|
549
|
-
* ID to read when a mutation's reply is lost.
|
|
550
|
-
*/
|
|
551
|
-
const ask = ({ entry, api }, { readOnly }, action, call, target) => settle(call(api), {
|
|
552
|
-
host: entry,
|
|
553
|
-
timeout: readOnly ? options?.readTimeout ?? readTimeout : options?.timeout,
|
|
554
|
-
readOnly,
|
|
555
|
-
action,
|
|
556
|
-
target
|
|
557
|
-
});
|
|
558
|
-
/**
|
|
559
|
-
* Routes a call by the ID's host part: no network is needed to find the host. `made` is
|
|
560
|
-
* the ID of the resource the call makes, if any, which is what to read after a lost reply.
|
|
561
|
-
*/
|
|
562
|
-
const byId = (id, contract, action, call, made) => Effect.gen(function* () {
|
|
563
|
-
const { host } = yield* parseId(id);
|
|
564
|
-
return yield* ask(yield* route(host), contract, action, call, made ?? id);
|
|
565
|
-
});
|
|
566
|
-
/** Asks every host at once; a host that fails is named rather than failing the whole. */
|
|
567
|
-
const gather = (call) => Effect.forEach(routed, (route) => Effect.map(Effect.result(call(route)), (result) => ({
|
|
568
|
-
host: route.entry.id,
|
|
569
|
-
result
|
|
570
|
-
})), { concurrency: "unbounded" }).pipe(Effect.map((results) => {
|
|
571
|
-
const answers = [];
|
|
572
|
-
const unreachable = [];
|
|
573
|
-
for (const { host, result } of results) if (Result.isSuccess(result)) answers.push(...result.success);
|
|
574
|
-
else unreachable.push({
|
|
575
|
-
host,
|
|
576
|
-
error: result.failure
|
|
577
|
-
});
|
|
578
|
-
return {
|
|
579
|
-
answers,
|
|
580
|
-
unreachable
|
|
581
|
-
};
|
|
582
|
-
}));
|
|
583
|
-
/**
|
|
584
|
-
* Reads a host. IDs route by its entry's ID, so a host that calls itself something else
|
|
585
|
-
* fails rather than being placed on.
|
|
586
|
-
*/
|
|
587
|
-
const readHost = (route) => ask(route, actions.getHost, "get host", (api) => Effect.flatMap(api.getHost(), (host) => checkHost(route.entry, host)));
|
|
588
|
-
const createOn = (host, name, spec) => Effect.gen(function* () {
|
|
589
|
-
const id = yield* formatId(host, name);
|
|
590
|
-
return yield* ask(yield* route(host), actions.createMachine, `create ${id}`, (api) => api.createMachine({
|
|
591
|
-
...spec,
|
|
592
|
-
id
|
|
593
|
-
}), id);
|
|
594
|
-
});
|
|
595
|
-
/**
|
|
596
|
-
* Asks every host at once and goes through the answers in list order: the first host
|
|
597
|
-
* that answers and offers `base` wins, and the requests still out are interrupted, so a
|
|
598
|
-
* silent host after it delays nothing. With no winner the create is `Unavailable` only if
|
|
599
|
-
* a host gave no reply; otherwise asking again changes nothing, and it is `Precondition`.
|
|
600
|
-
*/
|
|
601
|
-
const offering = (base) => Effect.gen(function* () {
|
|
602
|
-
const asked = yield* Effect.forEach(routed, (route) => Effect.map(Effect.forkScoped(Effect.result(readHost(route))), (fiber) => ({
|
|
603
|
-
host: route.entry.id,
|
|
604
|
-
fiber
|
|
605
|
-
})));
|
|
606
|
-
/** What each host answered, in list order. */
|
|
607
|
-
const misses = [];
|
|
608
|
-
let noReply = false;
|
|
609
|
-
for (const { host, fiber } of asked) {
|
|
610
|
-
const result = yield* Fiber.join(fiber);
|
|
611
|
-
if (Result.isFailure(result)) {
|
|
612
|
-
noReply ||= Predicate.isTagged(result.failure, "Unavailable");
|
|
613
|
-
misses.push(`${host} failed: ${result.failure.message}`);
|
|
614
|
-
} else if (result.success.bases.includes(base)) return result.success;
|
|
615
|
-
else {
|
|
616
|
-
const { bases } = result.success;
|
|
617
|
-
misses.push(`${host} offers ${bases.length > 0 ? bases.join(", ") : "no bases"}`);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
const message = `no host offers base ${base}: ${misses.join("; ")}`;
|
|
621
|
-
return yield* noReply ? new Unavailable({
|
|
622
|
-
message,
|
|
623
|
-
readOnly: true
|
|
624
|
-
}) : new Precondition({ message });
|
|
625
|
-
}).pipe(Effect.scoped);
|
|
626
|
-
const create = (name, spec, options) => Effect.gen(function* () {
|
|
627
|
-
yield* parseName(name);
|
|
628
|
-
if (options?.host !== void 0) return yield* createOn(options.host, name, spec);
|
|
629
|
-
return yield* createOn((yield* offering(spec.base)).id, name, spec);
|
|
630
|
-
});
|
|
631
|
-
const place = (base, options) => options?.host === void 0 ? offering(base) : Effect.flatMap(route(options.host), readHost);
|
|
632
|
-
/** The ID a call that makes a resource on `source`'s host gives it. */
|
|
633
|
-
const sibling = (source, name) => Effect.flatMap(parseId(source), ({ host }) => formatId(host, name));
|
|
634
|
-
return {
|
|
635
|
-
hosts: gather((route) => Effect.map(readHost(route), (host) => [host])),
|
|
636
|
-
machines: gather((route) => ask(route, actions.listMachines, "list machines", (api) => api.listMachines())),
|
|
637
|
-
checkpoints: gather((route) => ask(route, actions.listCheckpoints, "list checkpoints", (api) => api.listCheckpoints())),
|
|
638
|
-
capacity: gather((route) => ask(route, actions.getCapacity, "get capacity", (api) => api.getCapacity())),
|
|
639
|
-
machine: (id) => byId(id, actions.getMachine, `get ${id}`, (api) => api.getMachine({ id })),
|
|
640
|
-
checkpoint: (id) => byId(id, actions.getCheckpoint, `get ${id}`, (api) => api.getCheckpoint({ id })),
|
|
641
|
-
place,
|
|
642
|
-
create,
|
|
643
|
-
start: (id) => byId(id, actions.startMachine, `start ${id}`, (api) => api.startMachine({ id })),
|
|
644
|
-
stop: (id) => byId(id, actions.stopMachine, `stop ${id}`, (api) => api.stopMachine({ id })),
|
|
645
|
-
delete: (id) => byId(id, actions.deleteMachine, `delete ${id}`, (api) => api.deleteMachine({ id })),
|
|
646
|
-
fork: (machine, name) => Effect.flatMap(sibling(machine, name), (made) => byId(machine, actions.forkMachine, `fork ${machine} to ${made}`, (api) => api.forkMachine({
|
|
647
|
-
machine,
|
|
648
|
-
name
|
|
649
|
-
}), made)),
|
|
650
|
-
restore: (checkpoint, name) => Effect.flatMap(sibling(checkpoint, name), (made) => byId(checkpoint, actions.restoreMachine, `restore ${checkpoint} to ${made}`, (api) => api.restoreMachine({
|
|
651
|
-
checkpoint,
|
|
652
|
-
name
|
|
653
|
-
}), made)),
|
|
654
|
-
capture: (machine, name) => Effect.flatMap(sibling(machine, name), (made) => byId(machine, actions.captureCheckpoint, `capture ${machine} to ${made}`, (api) => api.captureCheckpoint({
|
|
655
|
-
machine,
|
|
656
|
-
name
|
|
657
|
-
}), made)),
|
|
658
|
-
deleteCheckpoint: (id) => byId(id, actions.deleteCheckpoint, `delete ${id}`, (api) => api.deleteCheckpoint({ id }))
|
|
659
|
-
};
|
|
660
|
-
});
|
|
661
|
-
//#endregion
|
|
662
|
-
export { parseName as A, Unavailable as B, release as C, Name as D, Id as E, HostErrorTag as F, Internal as I, Invalid as L, Conflict as M, ConflictKind as N, formatId as O, ErrorTag as P, NotFound as R, SshLogin as S, HostId as T, hostErrors as V, MachineSpec as _, describeIssues as a, Setup as b, readTimeout as c, ActionRecord as d, Checkpoint as f, Machine as g, Limit as h, describeFileError as i, Capacity as j, parseId as k, versionHeader as l, Host as m, client_exports as n, formatMib as o, CreateRequest as p, make as r, Http as s, Client as t, ActionName as u, Profile as v, version as w, SshEndpoint as x, Runtime as y, Precondition as z };
|