@gjermundgaraba/clankerbox-sdk 0.4.1 → 0.12.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-BVzrg2pq.d.mts +289 -0
- package/dist/client-jXYWrnuZ.mjs +583 -0
- package/dist/index.d.mts +573 -0
- package/dist/index.mjs +106 -0
- package/dist/node.d.mts +6 -0
- package/dist/node.mjs +12 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +33 -44
- package/README.md +0 -100
- package/dist/gen/clankerbox/v1/host_pb.d.ts +0 -432
- package/dist/gen/clankerbox/v1/host_pb.js +0 -99
- package/dist/gen/clankerbox/v1/machine_pb.d.ts +0 -494
- package/dist/gen/clankerbox/v1/machine_pb.js +0 -113
- package/dist/gen/clankerbox/v1/profile_pb.d.ts +0 -600
- package/dist/gen/clankerbox/v1/profile_pb.js +0 -156
- package/dist/gen/clankerbox/v1/resources_pb.d.ts +0 -595
- package/dist/gen/clankerbox/v1/resources_pb.js +0 -283
- package/dist/gen/clankerbox/v1/session_pb.d.ts +0 -876
- package/dist/gen/clankerbox/v1/session_pb.js +0 -219
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -5
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
4
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
5
|
+
import { Context, Duration, Effect, Fiber, Match, Predicate, Result, Schema } from "effect";
|
|
6
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
7
|
+
import { HttpApiClient } from "effect/http-api";
|
|
8
|
+
//#region src/errors.ts
|
|
9
|
+
/** The request is malformed: a bad ID or name, an undeclared field, a value out of range. */
|
|
10
|
+
var Invalid = class extends Schema.TaggedError()("Invalid", { message: Schema.String }, { httpApiStatus: 400 }) {
|
|
11
|
+
get retryable() {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/** The machine or checkpoint doesn't exist. A `delete` that meets it is done. */
|
|
16
|
+
var NotFound = class extends Schema.TaggedError()("NotFound", { message: Schema.String }, { httpApiStatus: 404 }) {
|
|
17
|
+
get retryable() {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const ConflictKind = Schema.Literals(["exists", "busy"]);
|
|
22
|
+
/** The name is taken (`exists`), or another action holds the resource (`busy`). */
|
|
23
|
+
var Conflict = class extends Schema.TaggedError()("Conflict", {
|
|
24
|
+
message: Schema.String,
|
|
25
|
+
kind: ConflictKind
|
|
26
|
+
}, { httpApiStatus: 409 }) {
|
|
27
|
+
get retryable() {
|
|
28
|
+
return this.kind === "busy";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/** The resource's state doesn't allow the action, or no host offers the requested base. */
|
|
32
|
+
var Precondition = class extends Schema.TaggedError()("Precondition", { message: Schema.String }, { httpApiStatus: 412 }) {
|
|
33
|
+
get retryable() {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/** The host has no room now; room can free up, so a later call may succeed. */
|
|
38
|
+
var Capacity = class extends Schema.TaggedError()("Capacity", { message: Schema.String }, { httpApiStatus: 503 }) {
|
|
39
|
+
get retryable() {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The client couldn't reach the host, or lost its reply. Only the client library produces
|
|
45
|
+
* it. `access` is the access of the action that met it: a mutation's request may have
|
|
46
|
+
* reached the host and run, so it is never retryable.
|
|
47
|
+
*/
|
|
48
|
+
var Unavailable = class extends Schema.TaggedError()("Unavailable", {
|
|
49
|
+
message: Schema.String,
|
|
50
|
+
access: Schema.Literals(["read", "write"])
|
|
51
|
+
}) {
|
|
52
|
+
get retryable() {
|
|
53
|
+
return this.access === "read";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var Internal = class extends Schema.TaggedError()("Internal", { message: Schema.String }, { httpApiStatus: 500 }) {
|
|
57
|
+
get retryable() {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/** What a host answers with; every action group declares these. */
|
|
62
|
+
const hostErrors = [
|
|
63
|
+
Invalid,
|
|
64
|
+
NotFound,
|
|
65
|
+
Conflict,
|
|
66
|
+
Precondition,
|
|
67
|
+
Capacity,
|
|
68
|
+
Internal
|
|
69
|
+
];
|
|
70
|
+
/** The tag a host records on a failed action: always one of the errors it answers with. */
|
|
71
|
+
const HostErrorTag = Schema.Literals(hostErrors.map((error) => error.fields._tag.schema.literal));
|
|
72
|
+
/** The tag of any error a call through the client library can fail with. */
|
|
73
|
+
const ErrorTag = Schema.Literals([...HostErrorTag.literals, Unavailable.fields._tag.schema.literal]);
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/ids.ts
|
|
76
|
+
/**
|
|
77
|
+
* Machine and checkpoint IDs, `<host>_<name>`: the one place that parses and formats them.
|
|
78
|
+
* Neither part contains `_`, so an ID has exactly one. The whole ID matches
|
|
79
|
+
* `^[A-Za-z0-9_-]{1,62}$` because it is written to `/var/lib/clankerbox/machine-id`, and
|
|
80
|
+
* clankercreds accepts only that pattern.
|
|
81
|
+
*/
|
|
82
|
+
const separator = "_";
|
|
83
|
+
const maxIdLength = 62;
|
|
84
|
+
const idPattern = /^[A-Za-z0-9_-]{1,62}$/u;
|
|
85
|
+
/**
|
|
86
|
+
* boat's named snapshots are `cbx-<host>-<inst>`, and boat allows `^[a-z0-9][a-z0-9-]{0,62}$`:
|
|
87
|
+
* a lowercase host ID of at most 32 characters keeps the name within 63.
|
|
88
|
+
*/
|
|
89
|
+
const hostIdPattern = /^[a-z][a-z0-9-]{0,31}$/u;
|
|
90
|
+
const hostIdRule = "a host ID is 1 to 32 lowercase letters, digits and '-', starting with a letter";
|
|
91
|
+
/**
|
|
92
|
+
* smolvm names a machine `<name>-<inst>` and refuses consecutive hyphens, so a name has no
|
|
93
|
+
* `--` and doesn't end in `-`.
|
|
94
|
+
*/
|
|
95
|
+
const namePattern = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*$/u;
|
|
96
|
+
const nameRule = "a name starts with a letter, then letters, digits and '-', with no '--' and no '-' at the end";
|
|
97
|
+
/** A host's part of every ID it holds. */
|
|
98
|
+
const HostId = Schema.String.check(Schema.isPattern(hostIdPattern, { message: hostIdRule }));
|
|
99
|
+
/** A machine's or checkpoint's name, unique per host and per resource type. */
|
|
100
|
+
const Name = Schema.String.check(Schema.isPattern(namePattern, { message: nameRule }));
|
|
101
|
+
const describeId = (id) => {
|
|
102
|
+
if (!idPattern.test(id)) return `ID ${JSON.stringify(id)} must be 1 to ${maxIdLength} letters, digits, '_' or '-'`;
|
|
103
|
+
const at = id.indexOf(separator);
|
|
104
|
+
if (at <= 0 || at === id.length - 1) return `ID ${JSON.stringify(id)} must be <host>_<name>`;
|
|
105
|
+
if (!hostIdPattern.test(id.slice(0, at))) return `ID ${JSON.stringify(id)} has an invalid host: ${hostIdRule}`;
|
|
106
|
+
if (!namePattern.test(id.slice(at + 1))) return `ID ${JSON.stringify(id)} has an invalid name: ${nameRule}`;
|
|
107
|
+
};
|
|
108
|
+
/** A full `<host>_<name>` ID. */
|
|
109
|
+
const Id = Schema.String.check(Schema.makeFilter((id) => describeId(id) ?? true));
|
|
110
|
+
/** Whether `create`'s target is a full ID rather than a bare name: a name has no `_`. */
|
|
111
|
+
const isId = (target) => target.includes(separator);
|
|
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.12.0";
|
|
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
|
+
const Machine = Schema.Struct({
|
|
177
|
+
id: Id,
|
|
178
|
+
runtime: Runtime,
|
|
179
|
+
createdAt: Schema.DateTimeUtcFromString,
|
|
180
|
+
base: Schema.String,
|
|
181
|
+
/** The label the client passed at create, normally its profile's name. */
|
|
182
|
+
profile: Schema.optionalKey(Schema.String),
|
|
183
|
+
cpu: Size,
|
|
184
|
+
ramMib: Size,
|
|
185
|
+
diskGib: Size,
|
|
186
|
+
/**
|
|
187
|
+
* Read from the runtime, never stored: `missing` when the runtime doesn't have the machine,
|
|
188
|
+
* `unknown` when it couldn't be read.
|
|
189
|
+
*/
|
|
190
|
+
state: Schema.Literals([
|
|
191
|
+
"running",
|
|
192
|
+
"stopped",
|
|
193
|
+
"missing",
|
|
194
|
+
"unknown"
|
|
195
|
+
]),
|
|
196
|
+
action: ActionRecord,
|
|
197
|
+
ssh: Schema.optionalKey(SshEndpoint),
|
|
198
|
+
/** The guest's SSH host public key, as `<type> <base64>`. */
|
|
199
|
+
hostKey: Schema.optionalKey(Schema.String)
|
|
200
|
+
}).annotate({ identifier: "Machine" });
|
|
201
|
+
const Checkpoint = Schema.Struct({
|
|
202
|
+
id: Id,
|
|
203
|
+
createdAt: Schema.DateTimeUtcFromString,
|
|
204
|
+
/** The ID of the machine it was captured from. */
|
|
205
|
+
machine: Id,
|
|
206
|
+
/** What it holds, by runtime: `ram` (RAM and disks) on smolvm, `disk` on Tart and boat. */
|
|
207
|
+
kind: Schema.Literals(["ram", "disk"]),
|
|
208
|
+
base: Schema.String,
|
|
209
|
+
profile: Schema.optionalKey(Schema.String),
|
|
210
|
+
cpu: Size,
|
|
211
|
+
ramMib: Size,
|
|
212
|
+
diskGib: Size,
|
|
213
|
+
action: ActionRecord
|
|
214
|
+
}).annotate({ identifier: "Checkpoint" });
|
|
215
|
+
/** The SDK's version, which is also the version of the clankerbox binaries it ships with. */
|
|
216
|
+
const version = version$1;
|
|
217
|
+
/** A version's major.minor: releases that share it speak the same API. */
|
|
218
|
+
const release = (of) => of.split(".").slice(0, 2).join(".");
|
|
219
|
+
const Host = Schema.Struct({
|
|
220
|
+
id: HostId,
|
|
221
|
+
runtime: Runtime,
|
|
222
|
+
/** The clankerbox version the host runs. */
|
|
223
|
+
version: Schema.String,
|
|
224
|
+
/** The runtime's version; for boat, its API version. */
|
|
225
|
+
runtimeVersion: Schema.String,
|
|
226
|
+
bases: Schema.Array(Schema.String)
|
|
227
|
+
}).annotate({ identifier: "Host" });
|
|
228
|
+
/** A setup script and how long it may run. Nothing logs the script: it can carry secrets. */
|
|
229
|
+
const Setup = Schema.Struct({
|
|
230
|
+
script: Schema.String,
|
|
231
|
+
timeoutSeconds: Size
|
|
232
|
+
});
|
|
233
|
+
const specFields = {
|
|
234
|
+
base: Schema.String,
|
|
235
|
+
cpu: Size,
|
|
236
|
+
ramMib: Size,
|
|
237
|
+
diskGib: Size,
|
|
238
|
+
setup: Schema.optionalKey(Setup),
|
|
239
|
+
profile: Schema.optionalKey(Schema.String)
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* What a new machine is made from: a base, its sizes, an optional setup script with its
|
|
243
|
+
* timeout, and an optional `profile` label. A profile file fills these in; the host never
|
|
244
|
+
* sees the profile itself.
|
|
245
|
+
*/
|
|
246
|
+
const MachineSpec = Schema.Struct(specFields);
|
|
247
|
+
/**
|
|
248
|
+
* `machine.create`'s input: the spec and the new machine's full ID. The host checks that the ID
|
|
249
|
+
* names it, so a create never lands under another host's ID.
|
|
250
|
+
*/
|
|
251
|
+
const CreateRequest = Schema.Struct({
|
|
252
|
+
id: Id,
|
|
253
|
+
...specFields
|
|
254
|
+
});
|
|
255
|
+
/**
|
|
256
|
+
* A profile file: a client-side file that fills in a create request. `setup.path` is a script
|
|
257
|
+
* file or a recipe directory, relative to the profile file, and comes with its timeout: the
|
|
258
|
+
* profile's author sets it, and there is no default. `host` places the create on that host
|
|
259
|
+
* instead of by base.
|
|
260
|
+
*/
|
|
261
|
+
const Profile = Schema.Struct({
|
|
262
|
+
base: specFields.base,
|
|
263
|
+
cpu: specFields.cpu,
|
|
264
|
+
ramMib: specFields.ramMib,
|
|
265
|
+
diskGib: specFields.diskGib,
|
|
266
|
+
setup: Schema.optionalKey(Schema.Struct({
|
|
267
|
+
path: Schema.String,
|
|
268
|
+
timeoutSeconds: Size
|
|
269
|
+
})),
|
|
270
|
+
host: Schema.optionalKey(HostId)
|
|
271
|
+
});
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/api.ts
|
|
274
|
+
/**
|
|
275
|
+
* The host API: three effect-actions groups, served as JSON POST routes under `/api`.
|
|
276
|
+
* A mutation replies when its action has finished.
|
|
277
|
+
*/
|
|
278
|
+
/**
|
|
279
|
+
* Without a policy, effect-actions answers undeclared or malformed input with an empty 400.
|
|
280
|
+
* Schema issues never carry the rejected values, so a setup script never reaches a message.
|
|
281
|
+
*/
|
|
282
|
+
const schemaError = {
|
|
283
|
+
invalid: {
|
|
284
|
+
schema: Invalid,
|
|
285
|
+
make: (failure) => new Invalid({ message: `the request doesn't match the action's input: ${failure.cause.message}` })
|
|
286
|
+
},
|
|
287
|
+
internal: {
|
|
288
|
+
schema: Internal,
|
|
289
|
+
make: () => new Internal({ message: "the host's reply did not encode" })
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* How long a client waits for a read's reply from one host, unless it sets its own. A host
|
|
294
|
+
* bounds its own reads of runtime state under it, so a list answers in time.
|
|
295
|
+
*/
|
|
296
|
+
const readTimeout = Duration.seconds(10);
|
|
297
|
+
/**
|
|
298
|
+
* The header every client request carries the SDK's `version` in. A host refuses a request
|
|
299
|
+
* whose release (major.minor) differs from its own, or that carries none, before it runs
|
|
300
|
+
* anything.
|
|
301
|
+
*/
|
|
302
|
+
const versionHeader = "clankerbox-version";
|
|
303
|
+
const ById = Schema.Struct({ id: Id });
|
|
304
|
+
/** Input of the actions that make a new resource from a machine: its ID and the new name. */
|
|
305
|
+
const FromMachine = Schema.Struct({
|
|
306
|
+
machine: Id,
|
|
307
|
+
name: Name
|
|
308
|
+
});
|
|
309
|
+
const MachineGroup = ActionGroup.make({
|
|
310
|
+
name: "machine",
|
|
311
|
+
errors: hostErrors,
|
|
312
|
+
schemaError
|
|
313
|
+
}, Action.make("list", {
|
|
314
|
+
description: "List the host's machines.",
|
|
315
|
+
success: Schema.Array(Machine),
|
|
316
|
+
access: "read"
|
|
317
|
+
}), Action.make("get", {
|
|
318
|
+
description: "Read one machine.",
|
|
319
|
+
input: ById,
|
|
320
|
+
success: Machine,
|
|
321
|
+
access: "read"
|
|
322
|
+
}), Action.make("create", {
|
|
323
|
+
description: "Create a machine from a base, run its setup once, and prepare it.",
|
|
324
|
+
input: CreateRequest,
|
|
325
|
+
success: Machine,
|
|
326
|
+
access: "write"
|
|
327
|
+
}), Action.make("start", {
|
|
328
|
+
description: "Start a machine. On a running machine, run preparation again.",
|
|
329
|
+
input: ById,
|
|
330
|
+
success: Machine,
|
|
331
|
+
access: "write"
|
|
332
|
+
}), Action.make("stop", {
|
|
333
|
+
description: "Stop a machine. A stopped machine stays stopped, and the stop is still its last action.",
|
|
334
|
+
input: ById,
|
|
335
|
+
success: Machine,
|
|
336
|
+
access: "write"
|
|
337
|
+
}), Action.make("delete", {
|
|
338
|
+
description: "Delete a machine and everything native it owns.",
|
|
339
|
+
input: ById,
|
|
340
|
+
success: Schema.Void,
|
|
341
|
+
access: "write"
|
|
342
|
+
}), Action.make("fork", {
|
|
343
|
+
description: "Copy a machine to a new name on the same host.",
|
|
344
|
+
input: FromMachine,
|
|
345
|
+
success: Machine,
|
|
346
|
+
access: "write"
|
|
347
|
+
}), Action.make("restore", {
|
|
348
|
+
description: "Create a machine on the checkpoint's host from that checkpoint.",
|
|
349
|
+
input: Schema.Struct({
|
|
350
|
+
checkpoint: Id,
|
|
351
|
+
name: Name
|
|
352
|
+
}),
|
|
353
|
+
success: Machine,
|
|
354
|
+
access: "write"
|
|
355
|
+
}));
|
|
356
|
+
const CheckpointGroup = ActionGroup.make({
|
|
357
|
+
name: "checkpoint",
|
|
358
|
+
errors: hostErrors,
|
|
359
|
+
schemaError
|
|
360
|
+
}, Action.make("list", {
|
|
361
|
+
description: "List the host's checkpoints.",
|
|
362
|
+
success: Schema.Array(Checkpoint),
|
|
363
|
+
access: "read"
|
|
364
|
+
}), Action.make("get", {
|
|
365
|
+
description: "Read one checkpoint.",
|
|
366
|
+
input: ById,
|
|
367
|
+
success: Checkpoint,
|
|
368
|
+
access: "read"
|
|
369
|
+
}), Action.make("capture", {
|
|
370
|
+
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.",
|
|
371
|
+
input: FromMachine,
|
|
372
|
+
success: Checkpoint,
|
|
373
|
+
access: "write"
|
|
374
|
+
}), Action.make("delete", {
|
|
375
|
+
description: "Delete a checkpoint.",
|
|
376
|
+
input: ById,
|
|
377
|
+
success: Schema.Void,
|
|
378
|
+
access: "write"
|
|
379
|
+
}));
|
|
380
|
+
const HostGroup = ActionGroup.make({
|
|
381
|
+
name: "host",
|
|
382
|
+
errors: hostErrors,
|
|
383
|
+
schemaError
|
|
384
|
+
}, Action.make("get", {
|
|
385
|
+
description: "Read the host: its runtime, versions and bases.",
|
|
386
|
+
success: Host,
|
|
387
|
+
access: "read"
|
|
388
|
+
}));
|
|
389
|
+
const Http = ActionHttp.make({ apiPath: "/api" }, MachineGroup, CheckpointGroup, HostGroup);
|
|
390
|
+
//#endregion
|
|
391
|
+
//#region src/client.ts
|
|
392
|
+
var client_exports = /* @__PURE__ */ __exportAll({
|
|
393
|
+
Client: () => Client,
|
|
394
|
+
HostEntry: () => HostEntry,
|
|
395
|
+
make: () => make
|
|
396
|
+
});
|
|
397
|
+
/** One host a client talks to. `id` is the host part of every ID it holds. */
|
|
398
|
+
const HostEntry = Schema.Struct({
|
|
399
|
+
id: HostId,
|
|
400
|
+
url: Schema.String.check(Schema.makeFilter((url) => URL.canParse(url) || "a URL, such as http://linux:8484"))
|
|
401
|
+
});
|
|
402
|
+
var Client = class extends Context.Service()("@gjermundgaraba/clankerbox-sdk/Client") {};
|
|
403
|
+
/**
|
|
404
|
+
* A client for one host. Every request carries the SDK's version, which the host checks
|
|
405
|
+
* against its own. Its reply decoding is the only step `transformResponse` wraps, so a reply
|
|
406
|
+
* that doesn't decode is `Internal` here, and a `SchemaError` that reaches `settle` came from
|
|
407
|
+
* encoding the request.
|
|
408
|
+
*/
|
|
409
|
+
const makeHostApi = (entry) => HttpApiClient.make(Http.api, {
|
|
410
|
+
baseUrl: entry.url,
|
|
411
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.setHeader(versionHeader, version)),
|
|
412
|
+
transformResponse: (reply) => Effect.mapError(reply, (error) => Schema.isSchemaError(error) ? new Internal({ message: `host ${entry.id}'s reply didn't decode: ${error.message}` }) : error)
|
|
413
|
+
});
|
|
414
|
+
/** Every action's contract, keyed `<group>.<action>`: a call reads its access from its action. */
|
|
415
|
+
const actions = ActionGroup.contracts(MachineGroup, CheckpointGroup, HostGroup);
|
|
416
|
+
const causeDetail = (error) => error.cause instanceof Error ? error.cause.message : error.message;
|
|
417
|
+
/**
|
|
418
|
+
* No reply came: the connection failed or dropped, or the timeout passed. A mutation's
|
|
419
|
+
* request may or may not have reached its host, so it may have run.
|
|
420
|
+
*/
|
|
421
|
+
const unanswered = (context, detail) => context.access === "read" ? new Unavailable({
|
|
422
|
+
message: `host ${context.host.id} (${context.host.url}) didn't answer ${context.action}: ${detail}`,
|
|
423
|
+
access: "read"
|
|
424
|
+
}) : new Unavailable({
|
|
425
|
+
message: `no reply from host ${context.host.id} to ${context.action} (${detail}); the action may have run: read ${context.target ?? "the resource"} to see`,
|
|
426
|
+
access: "write"
|
|
427
|
+
});
|
|
428
|
+
/** Bounds the wait for the reply; the clock starts when the request is sent. */
|
|
429
|
+
const bounded = (call, context) => {
|
|
430
|
+
const { timeout } = context;
|
|
431
|
+
return timeout === void 0 ? call : Effect.timeoutOrElse(call, {
|
|
432
|
+
duration: timeout,
|
|
433
|
+
orElse: () => Effect.fail(unanswered(context, `timed out after ${Duration.format(timeout)}`))
|
|
434
|
+
});
|
|
435
|
+
};
|
|
436
|
+
const settle = (call, context) => bounded(call, context).pipe(Effect.catchTag("HttpClientError", (error) => Effect.fail(Match.value(error.reason).pipe(Match.tag("TransportError", () => unanswered(context, causeDetail(error))), Match.when({
|
|
437
|
+
_tag: "DecodeError",
|
|
438
|
+
cause: (cause) => cause !== void 0 && !(cause instanceof SyntaxError)
|
|
439
|
+
}, () => unanswered(context, causeDetail(error))), Match.orElse(() => new Internal({ message: `host ${context.host.id} answered ${context.action} unexpectedly: ${error.message}` }))))), Effect.catchTag("SchemaError", (error) => Effect.fail(new Invalid({ message: `${context.action}: the request doesn't match the action's input: ${error.message}` }))));
|
|
440
|
+
/** A host this client can use: it calls itself by its entry's ID. */
|
|
441
|
+
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` }));
|
|
442
|
+
/** Every entry is checked here, so a call never meets a malformed URL. */
|
|
443
|
+
const decodeHosts = Schema.decodeUnknownEffect(Schema.Array(HostEntry));
|
|
444
|
+
/**
|
|
445
|
+
* Builds a client over `hosts`, in placement order. Making it sends nothing. An empty list
|
|
446
|
+
* is Invalid, and so is a malformed entry or a host ID that appears twice: IDs route by it.
|
|
447
|
+
*/
|
|
448
|
+
const make = (hosts, options) => Effect.gen(function* () {
|
|
449
|
+
if (hosts.length === 0) return yield* new Invalid({ message: "the host list is empty: a client needs a host" });
|
|
450
|
+
yield* decodeHosts(hosts).pipe(Effect.mapError((error) => new Invalid({ message: `host list: ${error.message}` })));
|
|
451
|
+
const twice = hosts.find(({ id }, index) => hosts.findIndex((other) => other.id === id) < index);
|
|
452
|
+
if (twice !== void 0) return yield* new Invalid({ message: `host ${twice.id} appears twice in the host list` });
|
|
453
|
+
const routed = yield* Effect.forEach(hosts, (entry) => Effect.map(makeHostApi(entry), (api) => ({
|
|
454
|
+
entry,
|
|
455
|
+
api
|
|
456
|
+
})));
|
|
457
|
+
const routes = new Map(routed.map((route) => [route.entry.id, route]));
|
|
458
|
+
/** How long a call waits for its reply, by its action's access. */
|
|
459
|
+
const bounds = {
|
|
460
|
+
read: options?.readTimeout ?? readTimeout,
|
|
461
|
+
write: options?.timeout
|
|
462
|
+
};
|
|
463
|
+
const route = (host) => {
|
|
464
|
+
const found = routes.get(host);
|
|
465
|
+
return found === void 0 ? Effect.fail(new Invalid({ message: `host ${host} isn't in the host list` })) : Effect.succeed(found);
|
|
466
|
+
};
|
|
467
|
+
/**
|
|
468
|
+
* Sends one call to one host, bounded by its action's access. `target` is the ID to read
|
|
469
|
+
* when a mutation's reply is lost.
|
|
470
|
+
*/
|
|
471
|
+
const ask = ({ entry, api }, contract, action, call, target) => settle(call(api), {
|
|
472
|
+
host: entry,
|
|
473
|
+
timeout: bounds[contract.access],
|
|
474
|
+
access: contract.access,
|
|
475
|
+
action,
|
|
476
|
+
target
|
|
477
|
+
});
|
|
478
|
+
/**
|
|
479
|
+
* Routes a call by the ID's host part: no network is needed to find the host. `made` is
|
|
480
|
+
* the ID of the resource the call makes, if any, which is what to read after a lost reply.
|
|
481
|
+
*/
|
|
482
|
+
const byId = (id, contract, action, call, made) => Effect.gen(function* () {
|
|
483
|
+
const { host } = yield* parseId(id);
|
|
484
|
+
return yield* ask(yield* route(host), contract, action, call, made ?? id);
|
|
485
|
+
});
|
|
486
|
+
/** Asks every host at once; a host that fails is named rather than failing the whole. */
|
|
487
|
+
const gather = (call) => Effect.forEach(routed, (route) => Effect.map(Effect.result(call(route)), (result) => ({
|
|
488
|
+
host: route.entry.id,
|
|
489
|
+
result
|
|
490
|
+
})), { concurrency: "unbounded" }).pipe(Effect.map((results) => {
|
|
491
|
+
const answers = [];
|
|
492
|
+
const unreachable = [];
|
|
493
|
+
for (const { host, result } of results) if (Result.isSuccess(result)) answers.push(...result.success);
|
|
494
|
+
else unreachable.push({
|
|
495
|
+
host,
|
|
496
|
+
error: result.failure
|
|
497
|
+
});
|
|
498
|
+
return {
|
|
499
|
+
answers,
|
|
500
|
+
unreachable
|
|
501
|
+
};
|
|
502
|
+
}));
|
|
503
|
+
/**
|
|
504
|
+
* Reads a host. IDs route by its entry's ID, so a host that calls itself something else
|
|
505
|
+
* fails rather than being placed on.
|
|
506
|
+
*/
|
|
507
|
+
const readHost = (route) => ask(route, actions["host.get"], "get host", (api) => Effect.flatMap(api.host.get({ payload: {} }), (host) => checkHost(route.entry, host)));
|
|
508
|
+
const createOn = (host, name, spec) => Effect.gen(function* () {
|
|
509
|
+
const id = yield* formatId(host, name);
|
|
510
|
+
return yield* ask(yield* route(host), actions["machine.create"], `create ${id}`, (api) => api.machine.create({ payload: {
|
|
511
|
+
...spec,
|
|
512
|
+
id
|
|
513
|
+
} }), id);
|
|
514
|
+
});
|
|
515
|
+
/**
|
|
516
|
+
* Asks every host at once and goes through the answers in list order: the first host
|
|
517
|
+
* that answers and offers `base` wins, and the requests still out are interrupted, so a
|
|
518
|
+
* silent host after it delays nothing. With no winner the create is `Unavailable` only if
|
|
519
|
+
* a host gave no reply; otherwise asking again changes nothing, and it is `Precondition`.
|
|
520
|
+
*/
|
|
521
|
+
const place = (base) => Effect.gen(function* () {
|
|
522
|
+
const asked = yield* Effect.forEach(routed, (route) => Effect.map(Effect.forkScoped(Effect.result(readHost(route))), (fiber) => ({
|
|
523
|
+
host: route.entry.id,
|
|
524
|
+
fiber
|
|
525
|
+
})));
|
|
526
|
+
/** What each host answered, in list order. */
|
|
527
|
+
const misses = [];
|
|
528
|
+
let noReply = false;
|
|
529
|
+
for (const { host, fiber } of asked) {
|
|
530
|
+
const result = yield* Fiber.join(fiber);
|
|
531
|
+
if (Result.isFailure(result)) {
|
|
532
|
+
noReply ||= Predicate.isTagged(result.failure, "Unavailable");
|
|
533
|
+
misses.push(`${host} failed: ${result.failure._tag}: ${result.failure.message}`);
|
|
534
|
+
} else if (result.success.bases.includes(base)) return host;
|
|
535
|
+
else {
|
|
536
|
+
const { bases } = result.success;
|
|
537
|
+
misses.push(`${host} offers ${bases.length > 0 ? bases.join(", ") : "no bases"}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const message = `no host offers base ${base}: ${misses.join("; ")}`;
|
|
541
|
+
return yield* noReply ? new Unavailable({
|
|
542
|
+
message,
|
|
543
|
+
access: "read"
|
|
544
|
+
}) : new Precondition({ message });
|
|
545
|
+
}).pipe(Effect.scoped);
|
|
546
|
+
const create = (target, spec, options) => Effect.gen(function* () {
|
|
547
|
+
if (isId(target)) {
|
|
548
|
+
const { host, name } = yield* parseId(target);
|
|
549
|
+
return yield* createOn(host, name, spec);
|
|
550
|
+
}
|
|
551
|
+
const name = yield* parseName(target);
|
|
552
|
+
if (options?.host !== void 0) return yield* createOn(options.host, name, spec);
|
|
553
|
+
return yield* createOn(yield* place(spec.base), name, spec);
|
|
554
|
+
});
|
|
555
|
+
/** The ID a call that makes a resource on `source`'s host gives it. */
|
|
556
|
+
const sibling = (source, name) => Effect.flatMap(parseId(source), ({ host }) => formatId(host, name));
|
|
557
|
+
return {
|
|
558
|
+
hosts: gather((route) => Effect.map(readHost(route), (host) => [host])),
|
|
559
|
+
machines: gather((route) => ask(route, actions["machine.list"], "list machines", (api) => api.machine.list({ payload: {} }))),
|
|
560
|
+
checkpoints: gather((route) => ask(route, actions["checkpoint.list"], "list checkpoints", (api) => api.checkpoint.list({ payload: {} }))),
|
|
561
|
+
machine: (id) => byId(id, actions["machine.get"], `get ${id}`, (api) => api.machine.get({ payload: { id } })),
|
|
562
|
+
checkpoint: (id) => byId(id, actions["checkpoint.get"], `get ${id}`, (api) => api.checkpoint.get({ payload: { id } })),
|
|
563
|
+
create,
|
|
564
|
+
start: (id) => byId(id, actions["machine.start"], `start ${id}`, (api) => api.machine.start({ payload: { id } })),
|
|
565
|
+
stop: (id) => byId(id, actions["machine.stop"], `stop ${id}`, (api) => api.machine.stop({ payload: { id } })),
|
|
566
|
+
delete: (id) => byId(id, actions["machine.delete"], `delete ${id}`, (api) => api.machine.delete({ payload: { id } })),
|
|
567
|
+
fork: (machine, name) => Effect.flatMap(sibling(machine, name), (made) => byId(machine, actions["machine.fork"], `fork ${machine} to ${made}`, (api) => api.machine.fork({ payload: {
|
|
568
|
+
machine,
|
|
569
|
+
name
|
|
570
|
+
} }), made)),
|
|
571
|
+
restore: (checkpoint, name) => Effect.flatMap(sibling(checkpoint, name), (made) => byId(checkpoint, actions["machine.restore"], `restore ${checkpoint} to ${made}`, (api) => api.machine.restore({ payload: {
|
|
572
|
+
checkpoint,
|
|
573
|
+
name
|
|
574
|
+
} }), made)),
|
|
575
|
+
capture: (machine, name) => Effect.flatMap(sibling(machine, name), (made) => byId(machine, actions["checkpoint.capture"], `capture ${machine} to ${made}`, (api) => api.checkpoint.capture({ payload: {
|
|
576
|
+
machine,
|
|
577
|
+
name
|
|
578
|
+
} }), made)),
|
|
579
|
+
deleteCheckpoint: (id) => byId(id, actions["checkpoint.delete"], `delete ${id}`, (api) => api.checkpoint.delete({ payload: { id } }))
|
|
580
|
+
};
|
|
581
|
+
});
|
|
582
|
+
//#endregion
|
|
583
|
+
export { Capacity as A, hostErrors as B, HostId as C, isId as D, formatId as E, Internal as F, Invalid as I, NotFound as L, ConflictKind as M, ErrorTag as N, parseId as O, HostErrorTag as P, Precondition as R, version as S, Name as T, Profile as _, HostGroup as a, SshEndpoint as b, readTimeout as c, ActionRecord as d, Checkpoint as f, MachineSpec as g, Machine as h, CheckpointGroup as i, Conflict as j, parseName as k, versionHeader as l, Host as m, client_exports as n, Http as o, CreateRequest as p, make as r, MachineGroup as s, Client as t, ActionName as u, Runtime as v, Id as w, release as x, Setup as y, Unavailable as z };
|