@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
|
@@ -0,0 +1,708 @@
|
|
|
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, Layer, Match, Predicate, Record, Result, Schema, SchemaIssue } from "effect";
|
|
5
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
6
|
+
//#region src/errors.ts
|
|
7
|
+
const located = {
|
|
8
|
+
host: Schema.optionalKey(Schema.String),
|
|
9
|
+
action: Schema.optionalKey(Schema.String),
|
|
10
|
+
id: Schema.optionalKey(Schema.String),
|
|
11
|
+
made: Schema.optionalKey(Schema.String)
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The request is malformed, or the host list is one a client can't route by, or a config,
|
|
15
|
+
* profile or setup file couldn't be read or doesn't hold what it should: then `file` names it.
|
|
16
|
+
*/
|
|
17
|
+
var Invalid = class extends Schema.TaggedError()("Invalid", {
|
|
18
|
+
message: Schema.String,
|
|
19
|
+
...located,
|
|
20
|
+
file: Schema.optionalKey(Schema.String)
|
|
21
|
+
}, { httpApiStatus: 400 }) {};
|
|
22
|
+
/** The machine or checkpoint doesn't exist. A `delete` that meets it is done. */
|
|
23
|
+
var NotFound = class extends Schema.TaggedError()("NotFound", {
|
|
24
|
+
message: Schema.String,
|
|
25
|
+
...located
|
|
26
|
+
}, { httpApiStatus: 404 }) {};
|
|
27
|
+
const ConflictKind = Schema.Literals(["exists", "busy"]);
|
|
28
|
+
/** The name is taken (`exists`), or another action holds the resource (`busy`). */
|
|
29
|
+
var Conflict = class extends Schema.TaggedError()("Conflict", {
|
|
30
|
+
message: Schema.String,
|
|
31
|
+
kind: ConflictKind,
|
|
32
|
+
...located
|
|
33
|
+
}, { httpApiStatus: 409 }) {};
|
|
34
|
+
/** No host offers `base`: the host a create names, or any in the client's list. */
|
|
35
|
+
var NotOffered = class extends Schema.TaggedError()("NotOffered", {
|
|
36
|
+
message: Schema.String,
|
|
37
|
+
...located,
|
|
38
|
+
base: Schema.String
|
|
39
|
+
}, { httpApiStatus: 412 }) {};
|
|
40
|
+
/** `machine`'s create, fork or restore failed before it was made: it can only be deleted. */
|
|
41
|
+
var NotMade = class extends Schema.TaggedError()("NotMade", {
|
|
42
|
+
message: Schema.String,
|
|
43
|
+
...located,
|
|
44
|
+
machine: Schema.String
|
|
45
|
+
}, { httpApiStatus: 412 }) {};
|
|
46
|
+
/** The runtime no longer has `machine`: it can only be deleted. */
|
|
47
|
+
var Missing = class extends Schema.TaggedError()("Missing", {
|
|
48
|
+
message: Schema.String,
|
|
49
|
+
...located,
|
|
50
|
+
machine: Schema.String
|
|
51
|
+
}, { httpApiStatus: 412 }) {};
|
|
52
|
+
/** `checkpoint`'s capture failed, so nothing restores from it: it can only be deleted. */
|
|
53
|
+
var NotReady = class extends Schema.TaggedError()("NotReady", {
|
|
54
|
+
message: Schema.String,
|
|
55
|
+
...located,
|
|
56
|
+
checkpoint: Schema.String
|
|
57
|
+
}, { httpApiStatus: 412 }) {};
|
|
58
|
+
/** `checkpoint` holds RAM state saved under another runtime pin than the one the host runs. */
|
|
59
|
+
var PinMismatch = class extends Schema.TaggedError()("PinMismatch", {
|
|
60
|
+
message: Schema.String,
|
|
61
|
+
...located,
|
|
62
|
+
checkpoint: Schema.String
|
|
63
|
+
}, { httpApiStatus: 412 }) {};
|
|
64
|
+
/** `machine` is in `state`, which the action doesn't act from. */
|
|
65
|
+
var WrongState = class extends Schema.TaggedError()("WrongState", {
|
|
66
|
+
message: Schema.String,
|
|
67
|
+
...located,
|
|
68
|
+
machine: Schema.String,
|
|
69
|
+
state: Schema.String
|
|
70
|
+
}, { httpApiStatus: 412 }) {};
|
|
71
|
+
/** The runtime can't give the requested sizes, such as a disk below the base's. */
|
|
72
|
+
var Unsizable = class extends Schema.TaggedError()("Unsizable", {
|
|
73
|
+
message: Schema.String,
|
|
74
|
+
...located
|
|
75
|
+
}, { httpApiStatus: 422 }) {};
|
|
76
|
+
/**
|
|
77
|
+
* A guest script exited non-zero or ran past its timeout; the message says which, with its
|
|
78
|
+
* output's tail. A failed `setup` leaves the machine unmade, a failed `preparation` made.
|
|
79
|
+
*/
|
|
80
|
+
var ScriptFailed = class extends Schema.TaggedError()("ScriptFailed", {
|
|
81
|
+
message: Schema.String,
|
|
82
|
+
...located,
|
|
83
|
+
script: Schema.Literals(["setup", "preparation"])
|
|
84
|
+
}, { httpApiStatus: 422 }) {};
|
|
85
|
+
/** The provider under the runtime refused the call for good, such as for its plan or API key. */
|
|
86
|
+
var Refused = class extends Schema.TaggedError()("Refused", {
|
|
87
|
+
message: Schema.String,
|
|
88
|
+
...located
|
|
89
|
+
}, { httpApiStatus: 403 }) {};
|
|
90
|
+
/** The host has no room now; room can free up, so a later call may succeed. */
|
|
91
|
+
var Capacity = class extends Schema.TaggedError()("Capacity", {
|
|
92
|
+
message: Schema.String,
|
|
93
|
+
...located
|
|
94
|
+
}, { httpApiStatus: 503 }) {};
|
|
95
|
+
/**
|
|
96
|
+
* No reply: only the client library makes it. A mutation's request may have reached the host
|
|
97
|
+
* and run, so it is retryable only when `readOnly`.
|
|
98
|
+
*/
|
|
99
|
+
var Unavailable = class extends Schema.TaggedError()("Unavailable", {
|
|
100
|
+
message: Schema.String,
|
|
101
|
+
readOnly: Schema.Boolean,
|
|
102
|
+
...located
|
|
103
|
+
}) {};
|
|
104
|
+
var Internal = class extends Schema.TaggedError()("Internal", {
|
|
105
|
+
message: Schema.String,
|
|
106
|
+
...located
|
|
107
|
+
}, { httpApiStatus: 500 }) {};
|
|
108
|
+
/** What a host may answer with; each action declares its own share. */
|
|
109
|
+
const hostErrors = [
|
|
110
|
+
Invalid,
|
|
111
|
+
NotFound,
|
|
112
|
+
Conflict,
|
|
113
|
+
NotOffered,
|
|
114
|
+
NotMade,
|
|
115
|
+
Missing,
|
|
116
|
+
NotReady,
|
|
117
|
+
PinMismatch,
|
|
118
|
+
WrongState,
|
|
119
|
+
Unsizable,
|
|
120
|
+
ScriptFailed,
|
|
121
|
+
Refused,
|
|
122
|
+
Capacity,
|
|
123
|
+
Internal
|
|
124
|
+
];
|
|
125
|
+
const HostError = Schema.Union(hostErrors);
|
|
126
|
+
/**
|
|
127
|
+
* Each carries, where known: `host` and `action`, which the client sets; `id`, the resource the
|
|
128
|
+
* error is about, which a host sets on `NotFound` and `Conflict` and the client sets to the call's
|
|
129
|
+
* subject otherwise; and `made`, the resource a create, fork, restore or capture makes, which a
|
|
130
|
+
* failure may leave behind.
|
|
131
|
+
*/
|
|
132
|
+
const ClankerboxError = Schema.Union([...hostErrors, Unavailable]);
|
|
133
|
+
/**
|
|
134
|
+
* Whether the same call may succeed later: a busy resource or a full host may free up, and a read
|
|
135
|
+
* that got no reply may get one.
|
|
136
|
+
*/
|
|
137
|
+
const retryable = (error) => Match.value(error).pipe(Match.tagsExhaustive({
|
|
138
|
+
Invalid: () => false,
|
|
139
|
+
NotFound: () => false,
|
|
140
|
+
Conflict: ({ kind }) => kind === "busy",
|
|
141
|
+
NotOffered: () => false,
|
|
142
|
+
NotMade: () => false,
|
|
143
|
+
Missing: () => false,
|
|
144
|
+
NotReady: () => false,
|
|
145
|
+
PinMismatch: () => false,
|
|
146
|
+
WrongState: () => false,
|
|
147
|
+
Unsizable: () => false,
|
|
148
|
+
ScriptFailed: () => false,
|
|
149
|
+
Refused: () => false,
|
|
150
|
+
Capacity: () => true,
|
|
151
|
+
Unavailable: ({ readOnly }) => readOnly,
|
|
152
|
+
Internal: () => false
|
|
153
|
+
}));
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/ids.ts
|
|
156
|
+
const separator = "_";
|
|
157
|
+
const maxIdLength = 62;
|
|
158
|
+
const idPattern = /^[A-Za-z0-9_-]{1,62}$/u;
|
|
159
|
+
const hostIdPattern = /^[a-z][a-z0-9-]{0,31}$/u;
|
|
160
|
+
const hostIdRule = "a host ID is 1 to 32 lowercase letters, digits and '-', starting with a letter";
|
|
161
|
+
const namePattern = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*$/u;
|
|
162
|
+
const nameRule = "a name starts with a letter, then letters, digits and '-', with no '--' and no '-' at the end";
|
|
163
|
+
const HostId = Schema.String.check(Schema.isPattern(hostIdPattern, { message: hostIdRule }));
|
|
164
|
+
/** Unique per host and per resource type. */
|
|
165
|
+
const Name = Schema.String.check(Schema.isPattern(namePattern, { message: nameRule }));
|
|
166
|
+
const describeId = (id) => {
|
|
167
|
+
if (!idPattern.test(id)) return `ID ${JSON.stringify(id)} must be 1 to ${maxIdLength} letters, digits, '_' or '-'`;
|
|
168
|
+
const at = id.indexOf(separator);
|
|
169
|
+
if (at <= 0 || at === id.length - 1) return `ID ${JSON.stringify(id)} must be <host>_<name>`;
|
|
170
|
+
if (!hostIdPattern.test(id.slice(0, at))) return `ID ${JSON.stringify(id)} has an invalid host: ${hostIdRule}`;
|
|
171
|
+
if (!namePattern.test(id.slice(at + 1))) return `ID ${JSON.stringify(id)} has an invalid name: ${nameRule}`;
|
|
172
|
+
};
|
|
173
|
+
/** `<host>_<name>`: neither part contains `_`, so an ID has exactly one. */
|
|
174
|
+
const Id = Schema.String.check(Schema.makeFilter((id) => describeId(id) ?? true));
|
|
175
|
+
const parseId = (id) => {
|
|
176
|
+
const problem = describeId(id);
|
|
177
|
+
if (problem !== void 0) return Effect.fail(new Invalid({ message: problem }));
|
|
178
|
+
const at = id.indexOf(separator);
|
|
179
|
+
return Effect.succeed({
|
|
180
|
+
host: id.slice(0, at),
|
|
181
|
+
name: id.slice(at + 1)
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
const parseName = (name) => namePattern.test(name) ? Effect.succeed(name) : Effect.fail(new Invalid({ message: `name ${JSON.stringify(name)}: ${nameRule}` }));
|
|
185
|
+
/** Checks both parts and the whole. */
|
|
186
|
+
const formatId = (host, name) => {
|
|
187
|
+
if (!hostIdPattern.test(host)) return Effect.fail(new Invalid({ message: `host ID ${JSON.stringify(host)}: ${hostIdRule}` }));
|
|
188
|
+
const id = `${host}${separator}${name}`;
|
|
189
|
+
return parseName(name).pipe(Effect.andThen(parseId(id)), Effect.as(id));
|
|
190
|
+
};
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/resources.ts
|
|
193
|
+
const Runtime = Schema.Literals([
|
|
194
|
+
"smolvm",
|
|
195
|
+
"tart",
|
|
196
|
+
"boat"
|
|
197
|
+
]);
|
|
198
|
+
const Size = Schema.Int.check(Schema.isGreaterThan(0));
|
|
199
|
+
/** The contract's mutations, which record themselves on a resource's `action`. */
|
|
200
|
+
const ActionName = Schema.Literals([
|
|
201
|
+
"createMachine",
|
|
202
|
+
"startMachine",
|
|
203
|
+
"stopMachine",
|
|
204
|
+
"deleteMachine",
|
|
205
|
+
"forkMachine",
|
|
206
|
+
"restoreMachine",
|
|
207
|
+
"captureCheckpoint",
|
|
208
|
+
"deleteCheckpoint"
|
|
209
|
+
]);
|
|
210
|
+
const ActionStatus = Schema.Literals([
|
|
211
|
+
"running",
|
|
212
|
+
"failed",
|
|
213
|
+
"done"
|
|
214
|
+
]);
|
|
215
|
+
/** A resource's last action; the next action replaces it. */
|
|
216
|
+
const ActionRecord = Schema.Union([Schema.Struct({
|
|
217
|
+
name: ActionName,
|
|
218
|
+
status: Schema.Literals(["running", "done"])
|
|
219
|
+
}), Schema.Struct({
|
|
220
|
+
name: ActionName,
|
|
221
|
+
status: Schema.Literal("failed"),
|
|
222
|
+
error: HostError
|
|
223
|
+
})]);
|
|
224
|
+
const SshEndpoint = Schema.Struct({
|
|
225
|
+
host: Schema.String,
|
|
226
|
+
port: Schema.Int.check(Schema.isBetween({
|
|
227
|
+
minimum: 1,
|
|
228
|
+
maximum: 65535
|
|
229
|
+
}))
|
|
230
|
+
});
|
|
231
|
+
const SshLogin = Schema.Struct({
|
|
232
|
+
user: Schema.String,
|
|
233
|
+
...SshEndpoint.fields
|
|
234
|
+
});
|
|
235
|
+
/**
|
|
236
|
+
* `state` is `missing` when the runtime doesn't have the machine, and `unknown` when it couldn't
|
|
237
|
+
* be read. Until its create, fork or restore has `made` it, a machine can only be read, stopped or
|
|
238
|
+
* deleted. `ssh` is only on a running machine whose runtime gives it an endpoint, and `hostKey`,
|
|
239
|
+
* `<type> <base64>`, once preparation has recorded it.
|
|
240
|
+
*/
|
|
241
|
+
const Machine = Schema.Struct({
|
|
242
|
+
id: Id,
|
|
243
|
+
runtime: Runtime,
|
|
244
|
+
createdAt: Schema.DateTimeUtcFromString,
|
|
245
|
+
base: Schema.String,
|
|
246
|
+
profile: Schema.optionalKey(Schema.String),
|
|
247
|
+
cpu: Size,
|
|
248
|
+
ramMib: Size,
|
|
249
|
+
diskGib: Size,
|
|
250
|
+
state: Schema.Literals([
|
|
251
|
+
"running",
|
|
252
|
+
"stopped",
|
|
253
|
+
"missing",
|
|
254
|
+
"unknown"
|
|
255
|
+
]),
|
|
256
|
+
made: Schema.Boolean,
|
|
257
|
+
action: ActionRecord,
|
|
258
|
+
ssh: Schema.optionalKey(SshLogin),
|
|
259
|
+
hostKey: Schema.optionalKey(Schema.String)
|
|
260
|
+
}).annotate({ identifier: "Machine" });
|
|
261
|
+
/** The state as people read it: `running`, or `stopped (unmade)` for one never made. */
|
|
262
|
+
const stateLabel = ({ state, made }) => made ? state : `${state} (unmade)`;
|
|
263
|
+
/** `kind` is `ram` (RAM and disks) on smolvm, and `disk` on Tart and boat. */
|
|
264
|
+
const Checkpoint = Schema.Struct({
|
|
265
|
+
id: Id,
|
|
266
|
+
createdAt: Schema.DateTimeUtcFromString,
|
|
267
|
+
machine: Id,
|
|
268
|
+
kind: Schema.Literals(["ram", "disk"]),
|
|
269
|
+
base: Schema.String,
|
|
270
|
+
profile: Schema.optionalKey(Schema.String),
|
|
271
|
+
cpu: Size,
|
|
272
|
+
ramMib: Size,
|
|
273
|
+
diskGib: Size,
|
|
274
|
+
action: ActionRecord
|
|
275
|
+
}).annotate({ identifier: "Checkpoint" });
|
|
276
|
+
/** A checkpoint restores once its capture is done; it never changes after. */
|
|
277
|
+
const isReady = (checkpoint) => checkpoint.action.status === "done";
|
|
278
|
+
/**
|
|
279
|
+
* `runtimeVersion` is boat's API version on boat. `captureFrom` is the state a machine must be in
|
|
280
|
+
* to be captured: Tart's is `stopped`.
|
|
281
|
+
*/
|
|
282
|
+
const Host = Schema.Struct({
|
|
283
|
+
id: HostId,
|
|
284
|
+
runtime: Runtime,
|
|
285
|
+
version: Schema.String,
|
|
286
|
+
runtimeVersion: Schema.String,
|
|
287
|
+
captureFrom: Schema.Literals(["running", "stopped"]),
|
|
288
|
+
bases: Schema.Array(Schema.String)
|
|
289
|
+
}).annotate({ identifier: "Host" });
|
|
290
|
+
const usageFields = {
|
|
291
|
+
host: HostId,
|
|
292
|
+
resource: Schema.Literals([
|
|
293
|
+
"cpu",
|
|
294
|
+
"ramMib",
|
|
295
|
+
"runningVms",
|
|
296
|
+
"activeSandboxes",
|
|
297
|
+
"startsPerMinute",
|
|
298
|
+
"startsPerHour",
|
|
299
|
+
"startsPerDay"
|
|
300
|
+
]),
|
|
301
|
+
limit: Schema.optionalKey(Schema.Int)
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* A resource of a host and how much of it is in use, counted as a refusal counts it, so it
|
|
305
|
+
* needn't match the host's own machines. When a machine the sum counts couldn't be read, the
|
|
306
|
+
* sum would be a guess, so the row names those machines instead.
|
|
307
|
+
*
|
|
308
|
+
* `cpu` and `ramMib` are what the host's running machines were given, `runningVms` Apple's two
|
|
309
|
+
* VMs per Mac, and `activeSandboxes` and the `starts` of each rolling window the boat account's.
|
|
310
|
+
* `limit` is there only where the host refuses an action that would pass it.
|
|
311
|
+
*/
|
|
312
|
+
const Usage = Schema.Union([Schema.Struct({
|
|
313
|
+
...usageFields,
|
|
314
|
+
used: Schema.Int
|
|
315
|
+
}), Schema.Struct({
|
|
316
|
+
...usageFields,
|
|
317
|
+
unread: Schema.Array(Id)
|
|
318
|
+
})]).annotate({ identifier: "Usage" });
|
|
319
|
+
/** Nothing logs the script: it can carry secrets. */
|
|
320
|
+
const Setup = Schema.Struct({
|
|
321
|
+
script: Schema.String,
|
|
322
|
+
timeoutSeconds: Schema.optionalKey(Size)
|
|
323
|
+
});
|
|
324
|
+
const specFields = {
|
|
325
|
+
base: Schema.String,
|
|
326
|
+
cpu: Size,
|
|
327
|
+
ramMib: Size,
|
|
328
|
+
diskGib: Size,
|
|
329
|
+
setup: Schema.optionalKey(Setup),
|
|
330
|
+
profile: Schema.optionalKey(Schema.String)
|
|
331
|
+
};
|
|
332
|
+
/** A profile file fills these in; the host sees only the `profile` label. */
|
|
333
|
+
const MachineSpec = Schema.Struct(specFields);
|
|
334
|
+
/** The host checks that `id` names it, so a create never lands under another host's ID. */
|
|
335
|
+
const CreateRequest = Schema.Struct({
|
|
336
|
+
id: Id,
|
|
337
|
+
...specFields
|
|
338
|
+
});
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/api.ts
|
|
341
|
+
/** A host bounds its own reads of runtime state under it, so a list answers in time. */
|
|
342
|
+
const readTimeout = Duration.seconds(10);
|
|
343
|
+
const ById = Schema.Struct({ id: Id });
|
|
344
|
+
const FromMachine = Schema.Struct({
|
|
345
|
+
machine: Id,
|
|
346
|
+
name: Name
|
|
347
|
+
});
|
|
348
|
+
const read = {
|
|
349
|
+
caller: Action.Anyone,
|
|
350
|
+
readOnly: true
|
|
351
|
+
};
|
|
352
|
+
const write = {
|
|
353
|
+
caller: Action.Anyone,
|
|
354
|
+
readOnly: false
|
|
355
|
+
};
|
|
356
|
+
const listing = [Invalid, Internal];
|
|
357
|
+
const reading = [
|
|
358
|
+
Invalid,
|
|
359
|
+
NotFound,
|
|
360
|
+
Internal
|
|
361
|
+
];
|
|
362
|
+
const changing = [
|
|
363
|
+
Invalid,
|
|
364
|
+
NotFound,
|
|
365
|
+
Conflict,
|
|
366
|
+
Refused,
|
|
367
|
+
Internal
|
|
368
|
+
];
|
|
369
|
+
const booting = [
|
|
370
|
+
...changing,
|
|
371
|
+
NotMade,
|
|
372
|
+
Missing,
|
|
373
|
+
WrongState,
|
|
374
|
+
Capacity
|
|
375
|
+
];
|
|
376
|
+
const Http = ActionHttp.make([
|
|
377
|
+
Action.make("listMachines", {
|
|
378
|
+
...read,
|
|
379
|
+
error: listing,
|
|
380
|
+
description: "List the host's machines.",
|
|
381
|
+
success: Schema.Array(Machine)
|
|
382
|
+
}),
|
|
383
|
+
Action.make("getMachine", {
|
|
384
|
+
...read,
|
|
385
|
+
error: reading,
|
|
386
|
+
description: "Read one machine.",
|
|
387
|
+
input: ById,
|
|
388
|
+
success: Machine
|
|
389
|
+
}),
|
|
390
|
+
Action.make("createMachine", {
|
|
391
|
+
...write,
|
|
392
|
+
error: [
|
|
393
|
+
Invalid,
|
|
394
|
+
Conflict,
|
|
395
|
+
NotOffered,
|
|
396
|
+
Unsizable,
|
|
397
|
+
ScriptFailed,
|
|
398
|
+
Refused,
|
|
399
|
+
Capacity,
|
|
400
|
+
Internal
|
|
401
|
+
],
|
|
402
|
+
description: "Create a machine from a base, run its setup once, and prepare it.",
|
|
403
|
+
input: CreateRequest,
|
|
404
|
+
success: Machine
|
|
405
|
+
}),
|
|
406
|
+
Action.make("startMachine", {
|
|
407
|
+
...write,
|
|
408
|
+
error: [...booting, ScriptFailed],
|
|
409
|
+
description: "Start a machine. On a running machine, run preparation again.",
|
|
410
|
+
input: ById,
|
|
411
|
+
success: Machine
|
|
412
|
+
}),
|
|
413
|
+
Action.make("stopMachine", {
|
|
414
|
+
...write,
|
|
415
|
+
error: changing,
|
|
416
|
+
description: "Stop a machine: a power-off that keeps its disk and ends its processes. A stopped machine stays stopped, and the stop is still its last action.",
|
|
417
|
+
input: ById,
|
|
418
|
+
success: Machine
|
|
419
|
+
}),
|
|
420
|
+
Action.make("deleteMachine", {
|
|
421
|
+
...write,
|
|
422
|
+
error: changing,
|
|
423
|
+
description: "Delete a machine and everything native it owns.",
|
|
424
|
+
input: ById
|
|
425
|
+
}),
|
|
426
|
+
Action.make("forkMachine", {
|
|
427
|
+
...write,
|
|
428
|
+
error: [
|
|
429
|
+
...booting,
|
|
430
|
+
Unsizable,
|
|
431
|
+
ScriptFailed
|
|
432
|
+
],
|
|
433
|
+
description: "Copy a machine to a new name on the same host.",
|
|
434
|
+
input: FromMachine,
|
|
435
|
+
success: Machine
|
|
436
|
+
}),
|
|
437
|
+
Action.make("restoreMachine", {
|
|
438
|
+
...write,
|
|
439
|
+
error: [
|
|
440
|
+
...changing,
|
|
441
|
+
NotReady,
|
|
442
|
+
PinMismatch,
|
|
443
|
+
Unsizable,
|
|
444
|
+
ScriptFailed,
|
|
445
|
+
Capacity
|
|
446
|
+
],
|
|
447
|
+
description: "Create a machine on the checkpoint's host from that checkpoint.",
|
|
448
|
+
input: Schema.Struct({
|
|
449
|
+
checkpoint: Id,
|
|
450
|
+
name: Name
|
|
451
|
+
}),
|
|
452
|
+
success: Machine
|
|
453
|
+
}),
|
|
454
|
+
Action.make("listCheckpoints", {
|
|
455
|
+
...read,
|
|
456
|
+
error: listing,
|
|
457
|
+
description: "List the host's checkpoints.",
|
|
458
|
+
success: Schema.Array(Checkpoint)
|
|
459
|
+
}),
|
|
460
|
+
Action.make("getCheckpoint", {
|
|
461
|
+
...read,
|
|
462
|
+
error: reading,
|
|
463
|
+
description: "Read one checkpoint.",
|
|
464
|
+
input: ById,
|
|
465
|
+
success: Checkpoint
|
|
466
|
+
}),
|
|
467
|
+
Action.make("captureCheckpoint", {
|
|
468
|
+
...write,
|
|
469
|
+
error: booting,
|
|
470
|
+
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.",
|
|
471
|
+
input: FromMachine,
|
|
472
|
+
success: Checkpoint
|
|
473
|
+
}),
|
|
474
|
+
Action.make("deleteCheckpoint", {
|
|
475
|
+
...write,
|
|
476
|
+
error: changing,
|
|
477
|
+
description: "Delete a checkpoint.",
|
|
478
|
+
input: ById
|
|
479
|
+
}),
|
|
480
|
+
Action.make("getHost", {
|
|
481
|
+
...read,
|
|
482
|
+
error: [Invalid],
|
|
483
|
+
description: "Read the host: its runtime, versions and bases.",
|
|
484
|
+
success: Host
|
|
485
|
+
}),
|
|
486
|
+
Action.make("getCapacity", {
|
|
487
|
+
...read,
|
|
488
|
+
error: [
|
|
489
|
+
Invalid,
|
|
490
|
+
Refused,
|
|
491
|
+
Internal
|
|
492
|
+
],
|
|
493
|
+
description: "Read the host's resources, how much of each is in use, and their limits. A machine whose state can't be read leaves the rows that count it unread; it fails when the runtime can't be read at all.",
|
|
494
|
+
success: Schema.Array(Usage)
|
|
495
|
+
})
|
|
496
|
+
], { prefix: "/api" });
|
|
497
|
+
const actionsByName = Action.byName(Http.actions);
|
|
498
|
+
//#endregion
|
|
499
|
+
//#region src/release.ts
|
|
500
|
+
/** Also the version of the clankerbox binaries it ships with. */
|
|
501
|
+
const version = "0.18.0";
|
|
502
|
+
/** major.minor: releases that share it speak the same API. */
|
|
503
|
+
const release = (of) => of.split(".").slice(0, 2).join(".");
|
|
504
|
+
/** A host refuses a request whose `release` differs from its own, or that has none. */
|
|
505
|
+
const versionHeader = "clankerbox-version";
|
|
506
|
+
//#endregion
|
|
507
|
+
//#region src/describe.ts
|
|
508
|
+
const jsonPath = (path, names) => path.map((key, index) => {
|
|
509
|
+
if (Number.isInteger(key)) return `[${String(key)}]`;
|
|
510
|
+
return index === 0 ? names?.get(key) ?? String(key) : `.${String(key)}`;
|
|
511
|
+
}).join("");
|
|
512
|
+
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)) });
|
|
513
|
+
/** On one line: `cpu: missing; ram: not a known field`. `names` renames top-level fields. */
|
|
514
|
+
const describeIssues = (error, names) => flatten(error.issue).issues.map(({ path = [], message }) => {
|
|
515
|
+
const keys = path.map((segment) => Predicate.isPropertyKey(segment) ? segment : segment.key);
|
|
516
|
+
return keys.length === 0 ? message : `${jsonPath(keys, names)}: ${message}`;
|
|
517
|
+
}).join("; ");
|
|
518
|
+
/**
|
|
519
|
+
* Node describes a failure only in its cause, so what has no wording here reads as the system's
|
|
520
|
+
* own.
|
|
521
|
+
*/
|
|
522
|
+
const describeFileError = ({ reason }) => {
|
|
523
|
+
if (Predicate.isTagged(reason, "NotFound")) return "no such file or directory";
|
|
524
|
+
if (Predicate.isTagged(reason, "PermissionDenied")) return "permission denied";
|
|
525
|
+
return reason.description ?? (reason.cause instanceof Error ? reason.cause.message : reason._tag);
|
|
526
|
+
};
|
|
527
|
+
//#endregion
|
|
528
|
+
//#region src/client.ts
|
|
529
|
+
var client_exports = /* @__PURE__ */ __exportAll({
|
|
530
|
+
Client: () => Client,
|
|
531
|
+
HostEntry: () => HostEntry,
|
|
532
|
+
layer: () => layer,
|
|
533
|
+
make: () => make
|
|
534
|
+
});
|
|
535
|
+
/** `id` is the host part of every ID the host holds. */
|
|
536
|
+
const HostEntry = Schema.Struct({
|
|
537
|
+
id: HostId,
|
|
538
|
+
url: Schema.String.check(Schema.makeFilter((url) => URL.canParse(url) || "a URL, such as http://linux:8484"))
|
|
539
|
+
});
|
|
540
|
+
/**
|
|
541
|
+
* Nothing retries: a mutation whose reply is lost or times out is `Unavailable`, and the caller
|
|
542
|
+
* reads the resource to see what happened.
|
|
543
|
+
*/
|
|
544
|
+
var Client = class extends Context.Service()("@gjermundgaraba/clankerbox-sdk/Client") {};
|
|
545
|
+
const hostApi = (entry) => ActionHttp.client(Http, {
|
|
546
|
+
baseUrl: entry.url,
|
|
547
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.setHeader(versionHeader, version))
|
|
548
|
+
});
|
|
549
|
+
const causeDetail = (error) => error.cause instanceof Error ? error.cause.message : error.message;
|
|
550
|
+
const unanswered = (context, detail) => context.readOnly ? new Unavailable({
|
|
551
|
+
message: `host ${context.host.id} (${context.host.url}) didn't answer ${context.what}: ${detail}`,
|
|
552
|
+
readOnly: true
|
|
553
|
+
}) : new Unavailable({
|
|
554
|
+
message: `no reply from host ${context.host.id} to ${context.what} (${detail}); the action may have run: read ${context.target ?? "the resource"} to see`,
|
|
555
|
+
readOnly: false
|
|
556
|
+
});
|
|
557
|
+
const bounded = (call, context) => {
|
|
558
|
+
const { timeout } = context;
|
|
559
|
+
return timeout === void 0 ? call : Effect.timeoutOrElse(call, {
|
|
560
|
+
duration: timeout,
|
|
561
|
+
orElse: () => Effect.fail(unanswered(context, `timed out after ${Duration.format(timeout)}`))
|
|
562
|
+
});
|
|
563
|
+
};
|
|
564
|
+
const unexpected = (context, error) => new Internal({ message: `host ${context.host.id} answered ${context.what} unexpectedly: ${error.message}` });
|
|
565
|
+
const settle = (call, context) => bounded(call, context).pipe(Effect.catchTags({
|
|
566
|
+
HttpClientError: (error) => Effect.fail(Match.value(error.reason).pipe(Match.tag("TransportError", () => unanswered(context, causeDetail(error))), Match.when({
|
|
567
|
+
_tag: "DecodeError",
|
|
568
|
+
cause: (cause) => cause !== void 0 && !(cause instanceof SyntaxError)
|
|
569
|
+
}, () => unanswered(context, causeDetail(error))), Match.orElse(() => unexpected(context, error)))),
|
|
570
|
+
InvalidInput: (error) => Effect.fail(new Invalid({ message: `${context.what}: the request doesn't match the action's input: ${error.message}` })),
|
|
571
|
+
SchemaError: (error) => Effect.fail(new Internal({ message: `host ${context.host.id}'s reply didn't decode: ${error.message}` })),
|
|
572
|
+
Unauthenticated: (error) => Effect.fail(unexpected(context, error)),
|
|
573
|
+
Forbidden: (error) => Effect.fail(unexpected(context, error))
|
|
574
|
+
}));
|
|
575
|
+
const encodeError = Schema.encodeSync(ClankerboxError);
|
|
576
|
+
const decodeError = Schema.decodeSync(ClankerboxError);
|
|
577
|
+
const locate = ({ action, host, id, made }) => (error) => decodeError({
|
|
578
|
+
...Record.filter({
|
|
579
|
+
action,
|
|
580
|
+
host,
|
|
581
|
+
id,
|
|
582
|
+
made
|
|
583
|
+
}, Predicate.isNotUndefined),
|
|
584
|
+
...encodeError(error)
|
|
585
|
+
});
|
|
586
|
+
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` }));
|
|
587
|
+
const decodeHosts = Schema.decodeUnknownEffect(Schema.Array(HostEntry));
|
|
588
|
+
const subjectOf = (input) => "id" in input ? input.id : "machine" in input ? input.machine : input.checkpoint;
|
|
589
|
+
/**
|
|
590
|
+
* A client over `hosts`, in placement order. It sends nothing until called. An empty list, a
|
|
591
|
+
* malformed entry or a host ID that appears twice is Invalid.
|
|
592
|
+
*/
|
|
593
|
+
const make = (hosts, options) => Effect.gen(function* () {
|
|
594
|
+
if (hosts.length === 0) return yield* new Invalid({ message: "the host list is empty: a client needs a host" });
|
|
595
|
+
yield* decodeHosts(hosts, { errors: "all" }).pipe(Effect.mapError((error) => new Invalid({ message: `host list: ${describeIssues(error)}` })));
|
|
596
|
+
const twice = hosts.find(({ id }, index) => hosts.findIndex((other) => other.id === id) < index);
|
|
597
|
+
if (twice !== void 0) return yield* new Invalid({ message: `host ${twice.id} appears twice in the host list` });
|
|
598
|
+
const routed = yield* Effect.forEach(hosts, (entry) => Effect.map(hostApi(entry), (api) => ({
|
|
599
|
+
entry,
|
|
600
|
+
api
|
|
601
|
+
})));
|
|
602
|
+
const routes = new Map(routed.map((route) => [route.entry.id, route]));
|
|
603
|
+
const route = (host) => {
|
|
604
|
+
const found = routes.get(host);
|
|
605
|
+
return found === void 0 ? Effect.fail(new Invalid({ message: `host ${host} isn't in the host list` })) : Effect.succeed(found);
|
|
606
|
+
};
|
|
607
|
+
const send = ({ entry, api }, name, input, subject, made) => {
|
|
608
|
+
const { readOnly } = actionsByName[name];
|
|
609
|
+
const method = api[name];
|
|
610
|
+
return settle(method(input), {
|
|
611
|
+
host: entry,
|
|
612
|
+
timeout: readOnly ? options?.readTimeout ?? readTimeout : options?.timeout,
|
|
613
|
+
readOnly,
|
|
614
|
+
what: subject === void 0 ? name : `${name} ${subject}`,
|
|
615
|
+
target: made ?? subject
|
|
616
|
+
}).pipe(Effect.mapError(locate({
|
|
617
|
+
action: name,
|
|
618
|
+
host: entry.id,
|
|
619
|
+
id: subject,
|
|
620
|
+
made
|
|
621
|
+
})));
|
|
622
|
+
};
|
|
623
|
+
const naming = (name) => (effect) => Effect.mapError(effect, (error) => error.action === void 0 ? locate({ action: name })(error) : error);
|
|
624
|
+
const gather = (call) => Effect.forEach(routed, (route) => Effect.map(Effect.result(call(route)), (result) => ({
|
|
625
|
+
host: route.entry.id,
|
|
626
|
+
result
|
|
627
|
+
})), { concurrency: "unbounded" }).pipe(Effect.map((results) => {
|
|
628
|
+
const answers = [];
|
|
629
|
+
const unreachable = [];
|
|
630
|
+
for (const { host, result } of results) if (Result.isSuccess(result)) answers.push(...result.success);
|
|
631
|
+
else unreachable.push({
|
|
632
|
+
host,
|
|
633
|
+
error: result.failure
|
|
634
|
+
});
|
|
635
|
+
return {
|
|
636
|
+
answers,
|
|
637
|
+
unreachable
|
|
638
|
+
};
|
|
639
|
+
}));
|
|
640
|
+
const readHost = (route) => Effect.flatMap(send(route, "getHost", {}), (host) => checkHost(route.entry, host)).pipe(Effect.mapError(locate({
|
|
641
|
+
action: "getHost",
|
|
642
|
+
host: route.entry.id
|
|
643
|
+
})));
|
|
644
|
+
const onResource = (name, input, makes) => naming(name)(Effect.gen(function* () {
|
|
645
|
+
const subject = subjectOf(input);
|
|
646
|
+
const { host } = yield* parseId(subject);
|
|
647
|
+
const made = makes === void 0 ? void 0 : yield* formatId(host, makes);
|
|
648
|
+
return yield* send(yield* route(host), name, input, subject, made);
|
|
649
|
+
}));
|
|
650
|
+
const createOn = (host, name, spec) => Effect.flatMap(formatId(host, name), (id) => onResource("createMachine", {
|
|
651
|
+
...spec,
|
|
652
|
+
id
|
|
653
|
+
}, name));
|
|
654
|
+
const offering = (base) => Effect.gen(function* () {
|
|
655
|
+
const asked = yield* Effect.forEach(routed, (route) => Effect.map(Effect.forkScoped(Effect.result(readHost(route))), (fiber) => ({
|
|
656
|
+
host: route.entry.id,
|
|
657
|
+
fiber
|
|
658
|
+
})));
|
|
659
|
+
const misses = [];
|
|
660
|
+
let noReply = false;
|
|
661
|
+
for (const { host, fiber } of asked) {
|
|
662
|
+
const result = yield* Fiber.join(fiber);
|
|
663
|
+
if (Result.isFailure(result)) {
|
|
664
|
+
noReply ||= Predicate.isTagged(result.failure, "Unavailable");
|
|
665
|
+
misses.push(`${host} failed: ${result.failure.message}`);
|
|
666
|
+
} else if (result.success.bases.includes(base)) return result.success;
|
|
667
|
+
else {
|
|
668
|
+
const { bases } = result.success;
|
|
669
|
+
misses.push(`${host} offers ${bases.length > 0 ? bases.join(", ") : "no bases"}`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const message = `no host offers base ${base}: ${misses.join("; ")}`;
|
|
673
|
+
return yield* noReply ? new Unavailable({
|
|
674
|
+
message,
|
|
675
|
+
readOnly: true
|
|
676
|
+
}) : new NotOffered({
|
|
677
|
+
message,
|
|
678
|
+
base
|
|
679
|
+
});
|
|
680
|
+
}).pipe(Effect.scoped);
|
|
681
|
+
const create = (name, spec, options) => Effect.gen(function* () {
|
|
682
|
+
yield* parseName(name);
|
|
683
|
+
if (options?.host !== void 0) return yield* createOn(options.host, name, spec);
|
|
684
|
+
return yield* createOn((yield* offering(spec.base)).id, name, spec);
|
|
685
|
+
});
|
|
686
|
+
const place = (base, options) => options?.host === void 0 ? offering(base) : Effect.flatMap(route(options.host), readHost);
|
|
687
|
+
const deleting = (removal) => removal.pipe(Effect.as("deleted"), Effect.catchTag("NotFound", () => Effect.succeed("absent")));
|
|
688
|
+
return {
|
|
689
|
+
hosts: gather((route) => Effect.map(readHost(route), (host) => [host])),
|
|
690
|
+
machines: gather((route) => send(route, "listMachines", {})),
|
|
691
|
+
checkpoints: gather((route) => send(route, "listCheckpoints", {})),
|
|
692
|
+
capacity: gather((route) => send(route, "getCapacity", {})),
|
|
693
|
+
place: (base, options) => naming("getHost")(place(base, options)),
|
|
694
|
+
create: (name, spec, options) => naming("createMachine")(create(name, spec, options)),
|
|
695
|
+
getMachine: (input) => onResource("getMachine", input),
|
|
696
|
+
startMachine: (input) => onResource("startMachine", input),
|
|
697
|
+
stopMachine: (input) => onResource("stopMachine", input),
|
|
698
|
+
deleteMachine: (input) => deleting(onResource("deleteMachine", input)),
|
|
699
|
+
forkMachine: (input) => onResource("forkMachine", input, input.name),
|
|
700
|
+
restoreMachine: (input) => onResource("restoreMachine", input, input.name),
|
|
701
|
+
getCheckpoint: (input) => onResource("getCheckpoint", input),
|
|
702
|
+
captureCheckpoint: (input) => onResource("captureCheckpoint", input, input.name),
|
|
703
|
+
deleteCheckpoint: (input) => deleting(onResource("deleteCheckpoint", input))
|
|
704
|
+
};
|
|
705
|
+
});
|
|
706
|
+
const layer = (hosts, options) => Layer.effect(Client, make(hosts, options));
|
|
707
|
+
//#endregion
|
|
708
|
+
export { parseId as A, NotFound as B, Usage as C, Id as D, HostId as E, ConflictKind as F, Refused as G, NotOffered as H, HostError as I, Unsizable as J, ScriptFailed as K, Internal as L, Capacity as M, ClankerboxError as N, Name as O, Conflict as P, Invalid as R, SshLogin as S, stateLabel as T, NotReady as U, NotMade as V, PinMismatch as W, hostErrors as X, WrongState as Y, retryable as Z, Machine as _, release as a, Setup as b, Http as c, ActionName as d, ActionRecord as f, Host as g, CreateRequest as h, describeIssues as i, parseName as j, formatId as k, actionsByName as l, Checkpoint as m, layer as n, version as o, ActionStatus as p, Unavailable as q, describeFileError as r, versionHeader as s, client_exports as t, readTimeout as u, MachineSpec as v, isReady as w, SshEndpoint as x, Runtime as y, Missing as z };
|