@gjermundgaraba/clankerbox-sdk 0.15.1 → 0.15.3
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.
|
@@ -297,6 +297,11 @@ interface Interface {
|
|
|
297
297
|
readonly capacity: Effect.Effect<Gathered<Limit>>;
|
|
298
298
|
readonly machine: (id: string) => Effect.Effect<Machine, ClankerboxError>;
|
|
299
299
|
readonly checkpoint: (id: string) => Effect.Effect<Checkpoint, ClankerboxError>;
|
|
300
|
+
/**
|
|
301
|
+
* The host a create of `base` goes to: the options' host, or else the first host in the list
|
|
302
|
+
* that offers the base. It is read either way, so a caller knows its runtime before it creates.
|
|
303
|
+
*/
|
|
304
|
+
readonly place: (base: string, options?: CreateOptions) => Effect.Effect<Host, ClankerboxError>;
|
|
300
305
|
/**
|
|
301
306
|
* Creates a machine named `name` on the options' host, or else on the first host in the
|
|
302
307
|
* list that offers the spec's base.
|
|
@@ -320,4 +325,4 @@ declare class Client extends Client_base {}
|
|
|
320
325
|
*/
|
|
321
326
|
declare const make: (hosts: ReadonlyArray<HostEntry>, options?: Options) => Effect.Effect<Interface, Invalid, HttpClient.HttpClient>;
|
|
322
327
|
//#endregion
|
|
323
|
-
export {
|
|
328
|
+
export { Invalid as A, ClankerboxError as C, HostError as D, ErrorTag as E, Precondition as M, Unavailable as N, HostErrorTag as O, hostErrors as P, Capacity as S, ConflictKind as T, Setup as _, Options as a, release as b, ActionRecord as c, Host as d, Limit as f, Runtime as g, Profile as h, Interface as i, NotFound as j, Internal as k, Checkpoint as l, MachineSpec as m, CreateOptions as n, client_d_exports as o, Machine as p, HostEntry as r, ActionName as s, Client as t, CreateRequest as u, SshEndpoint as v, Conflict as w, version as x, SshLogin as y };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
2
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
3
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
4
|
-
import { Context, Duration, Effect, Fiber, Match, Predicate, Result, Schema } from "effect";
|
|
4
|
+
import { Context, Duration, Effect, Fiber, Match, Predicate, Result, Schema, SchemaIssue } from "effect";
|
|
5
5
|
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
6
6
|
//#region src/errors.ts
|
|
7
7
|
/**
|
|
@@ -129,7 +129,7 @@ const formatId = (host, name) => {
|
|
|
129
129
|
};
|
|
130
130
|
//#endregion
|
|
131
131
|
//#region package.json
|
|
132
|
-
var version$1 = "0.15.
|
|
132
|
+
var version$1 = "0.15.3";
|
|
133
133
|
//#endregion
|
|
134
134
|
//#region src/resources.ts
|
|
135
135
|
/** The resources a host reports: machines, checkpoints and the host itself. */
|
|
@@ -426,6 +426,41 @@ const Http = ActionHttp.make([
|
|
|
426
426
|
})
|
|
427
427
|
], { prefix: "/api" });
|
|
428
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
|
|
429
464
|
//#region src/client.ts
|
|
430
465
|
/**
|
|
431
466
|
* The client library: a host list in placement order, routing by ID, list fan-out and
|
|
@@ -497,7 +532,7 @@ const decodeHosts = Schema.decodeUnknownEffect(Schema.Array(HostEntry));
|
|
|
497
532
|
*/
|
|
498
533
|
const make = (hosts, options) => Effect.gen(function* () {
|
|
499
534
|
if (hosts.length === 0) return yield* new Invalid({ message: "the host list is empty: a client needs a host" });
|
|
500
|
-
yield* decodeHosts(hosts).pipe(Effect.mapError((error) => new Invalid({ message: `host list: ${error
|
|
535
|
+
yield* decodeHosts(hosts, { errors: "all" }).pipe(Effect.mapError((error) => new Invalid({ message: `host list: ${describeIssues(error)}` })));
|
|
501
536
|
const twice = hosts.find(({ id }, index) => hosts.findIndex((other) => other.id === id) < index);
|
|
502
537
|
if (twice !== void 0) return yield* new Invalid({ message: `host ${twice.id} appears twice in the host list` });
|
|
503
538
|
const routed = yield* Effect.forEach(hosts, (entry) => Effect.map(makeHostApi(entry), (api) => ({
|
|
@@ -563,7 +598,7 @@ const make = (hosts, options) => Effect.gen(function* () {
|
|
|
563
598
|
* silent host after it delays nothing. With no winner the create is `Unavailable` only if
|
|
564
599
|
* a host gave no reply; otherwise asking again changes nothing, and it is `Precondition`.
|
|
565
600
|
*/
|
|
566
|
-
const
|
|
601
|
+
const offering = (base) => Effect.gen(function* () {
|
|
567
602
|
const asked = yield* Effect.forEach(routed, (route) => Effect.map(Effect.forkScoped(Effect.result(readHost(route))), (fiber) => ({
|
|
568
603
|
host: route.entry.id,
|
|
569
604
|
fiber
|
|
@@ -575,8 +610,8 @@ const make = (hosts, options) => Effect.gen(function* () {
|
|
|
575
610
|
const result = yield* Fiber.join(fiber);
|
|
576
611
|
if (Result.isFailure(result)) {
|
|
577
612
|
noReply ||= Predicate.isTagged(result.failure, "Unavailable");
|
|
578
|
-
misses.push(`${host} failed: ${result.failure.
|
|
579
|
-
} else if (result.success.bases.includes(base)) return
|
|
613
|
+
misses.push(`${host} failed: ${result.failure.message}`);
|
|
614
|
+
} else if (result.success.bases.includes(base)) return result.success;
|
|
580
615
|
else {
|
|
581
616
|
const { bases } = result.success;
|
|
582
617
|
misses.push(`${host} offers ${bases.length > 0 ? bases.join(", ") : "no bases"}`);
|
|
@@ -591,8 +626,9 @@ const make = (hosts, options) => Effect.gen(function* () {
|
|
|
591
626
|
const create = (name, spec, options) => Effect.gen(function* () {
|
|
592
627
|
yield* parseName(name);
|
|
593
628
|
if (options?.host !== void 0) return yield* createOn(options.host, name, spec);
|
|
594
|
-
return yield* createOn(yield*
|
|
629
|
+
return yield* createOn((yield* offering(spec.base)).id, name, spec);
|
|
595
630
|
});
|
|
631
|
+
const place = (base, options) => options?.host === void 0 ? offering(base) : Effect.flatMap(route(options.host), readHost);
|
|
596
632
|
/** The ID a call that makes a resource on `source`'s host gives it. */
|
|
597
633
|
const sibling = (source, name) => Effect.flatMap(parseId(source), ({ host }) => formatId(host, name));
|
|
598
634
|
return {
|
|
@@ -602,6 +638,7 @@ const make = (hosts, options) => Effect.gen(function* () {
|
|
|
602
638
|
capacity: gather((route) => ask(route, actions.getCapacity, "get capacity", (api) => api.getCapacity())),
|
|
603
639
|
machine: (id) => byId(id, actions.getMachine, `get ${id}`, (api) => api.getMachine({ id })),
|
|
604
640
|
checkpoint: (id) => byId(id, actions.getCheckpoint, `get ${id}`, (api) => api.getCheckpoint({ id })),
|
|
641
|
+
place,
|
|
605
642
|
create,
|
|
606
643
|
start: (id) => byId(id, actions.startMachine, `start ${id}`, (api) => api.startMachine({ id })),
|
|
607
644
|
stop: (id) => byId(id, actions.stopMachine, `stop ${id}`, (api) => api.stopMachine({ id })),
|
|
@@ -622,4 +659,4 @@ const make = (hosts, options) => Effect.gen(function* () {
|
|
|
622
659
|
};
|
|
623
660
|
});
|
|
624
661
|
//#endregion
|
|
625
|
-
export {
|
|
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 };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as Invalid, C as ClankerboxError, D as HostError, E as ErrorTag, M as Precondition, N as Unavailable, O as HostErrorTag, P as hostErrors, S as Capacity, T as ConflictKind, _ as Setup, b as release, c as ActionRecord, d as Host, f as Limit, g as Runtime, h as Profile, i as Interface, j as NotFound, k as Internal, l as Checkpoint, m as MachineSpec, n as CreateOptions, o as client_d_exports, p as Machine, s as ActionName, u as CreateRequest, v as SshEndpoint, w as Conflict, x as version, y as SshLogin } from "./client-Cn1bj4It.mjs";
|
|
2
2
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
3
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
4
|
-
import { Duration, Effect, FileSystem, Path, Schema } from "effect";
|
|
5
|
-
import { ChildProcessSpawner } from "effect/process";
|
|
4
|
+
import { Duration, Effect, FileSystem, Path, PlatformError, Schema } from "effect";
|
|
6
5
|
//#region src/api.d.ts
|
|
7
6
|
/**
|
|
8
7
|
* How long a client waits for a read's reply from one host, unless it sets its own. A host
|
|
@@ -307,6 +306,54 @@ export declare const parseId: (id: string) => Effect.Effect<IdParts, Invalid>;
|
|
|
307
306
|
export declare const parseName: (name: string) => Effect.Effect<string, Invalid>;
|
|
308
307
|
/** Joins a host ID and a name into the resource's ID, checking both parts and the whole. */
|
|
309
308
|
export declare const formatId: (host: string, name: string) => Effect.Effect<string, Invalid>;
|
|
309
|
+
declare namespace prebuild_d_exports {
|
|
310
|
+
export { Built, Created, build, create, nameOf };
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The name of the spec's prebuild on a host: a digest of the spec's label, base and sizes, the
|
|
314
|
+
* setup script as sent, and the host's runtime version. A `ram` checkpoint restores only under
|
|
315
|
+
* the release that saved it, so after a runtime bump a create misses the old prebuild instead
|
|
316
|
+
* of failing to restore it. The setup's timeout is no part of it, and neither is the base's
|
|
317
|
+
* image: a host only names it. The script holds whatever secrets a recipe carries, and the name
|
|
318
|
+
* 12 hex characters of a digest of it.
|
|
319
|
+
*/
|
|
320
|
+
declare const nameOf: (spec: MachineSpec, host: Pick<Host, "runtimeVersion">) => Effect.Effect<string>;
|
|
321
|
+
interface Created {
|
|
322
|
+
readonly machine: Machine;
|
|
323
|
+
/** The prebuild the machine was restored from; without one, its setup ran. */
|
|
324
|
+
readonly prebuild?: Checkpoint | undefined;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Creates a machine named `name` as `client.create` does, but restores it from the spec's
|
|
328
|
+
* prebuild when the host it lands on has one whose capture is done. A spec without a setup has
|
|
329
|
+
* none. Placement is the create's own, and a prebuild stays on the host that made it, so a spec
|
|
330
|
+
* placed on another host runs its setup. A failed restore is the reply.
|
|
331
|
+
*/
|
|
332
|
+
declare const create: (client: Interface, name: string, spec: MachineSpec, options?: CreateOptions) => Effect.Effect<Created, ClankerboxError>;
|
|
333
|
+
interface Built {
|
|
334
|
+
readonly checkpoint: Checkpoint;
|
|
335
|
+
/** Whether this call built it; false when it was already there. */
|
|
336
|
+
readonly built: boolean;
|
|
337
|
+
/**
|
|
338
|
+
* The ID of the machine that built it, when its delete failed: it may still run, holding its
|
|
339
|
+
* RAM, until it is deleted.
|
|
340
|
+
*/
|
|
341
|
+
readonly leftover?: string | undefined;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Builds the spec's prebuild on the host a create of it lands on: a machine of its own runs the
|
|
345
|
+
* setup, is captured under the prebuild's name, and is deleted. smolvm captures a running
|
|
346
|
+
* machine, RAM included, and boat a running one too; Tart only a stopped one, so its machine is
|
|
347
|
+
* stopped first. A prebuild whose capture is done is the answer as it is; one that failed or is
|
|
348
|
+
* still captured is refused.
|
|
349
|
+
*
|
|
350
|
+
* Each build's machine has a random name of its own, and the host takes a checkpoint name once,
|
|
351
|
+
* so of two builds of one spec at once both run the setup and the second's capture is
|
|
352
|
+
* `Conflict`: neither touches the other's machine or prebuild. A build deletes its machine
|
|
353
|
+
* however it ends. If that fails after a failure, the first failure is the reply; after a
|
|
354
|
+
* capture, the prebuild is made, and the machine is the reply's `leftover`.
|
|
355
|
+
*/
|
|
356
|
+
declare const build: (client: Interface, spec: MachineSpec, options?: CreateOptions) => Effect.Effect<Built, ClankerboxError>;
|
|
310
357
|
//#endregion
|
|
311
358
|
//#region src/profile.d.ts
|
|
312
359
|
/** A profile file as read: its fields, with `setup.path` resolved, and its name as `label`. */
|
|
@@ -322,12 +369,33 @@ export declare const loadProfile: (file: string) => Effect.Effect<LoadedProfile,
|
|
|
322
369
|
//#endregion
|
|
323
370
|
//#region src/setup.d.ts
|
|
324
371
|
/**
|
|
325
|
-
* Packs a recipe directory into one self-extracting script
|
|
326
|
-
*
|
|
327
|
-
*
|
|
372
|
+
* Packs a recipe directory into one self-extracting script, which makes each of its entries
|
|
373
|
+
* with plain shell: a directory with `mkdir`, a symlink with `ln -s` as it is, and a file from
|
|
374
|
+
* base64, made executable when any of its execute bits is set. A symlink is never walked into,
|
|
375
|
+
* so nothing outside the recipe is packed, and in the guest nothing is written through one.
|
|
376
|
+
* Of a mode only the execute bit is kept, as git keeps it, so a checkout's umask changes
|
|
377
|
+
* nothing. Entries go in sorted order and nothing records a time or an owner, so the same
|
|
378
|
+
* files pack into the same script: a prebuild is named after it.
|
|
328
379
|
*/
|
|
329
|
-
export declare const packRecipe: (directory: string) => Effect.Effect<string, Invalid, FileSystem.FileSystem
|
|
380
|
+
export declare const packRecipe: (directory: string) => Effect.Effect<string, Invalid, FileSystem.FileSystem>;
|
|
330
381
|
/** Reads a setup: a script file's text, or a recipe directory packed into one script. */
|
|
331
|
-
export declare const readSetup: (path: string) => Effect.Effect<string, Invalid, FileSystem.FileSystem
|
|
382
|
+
export declare const readSetup: (path: string) => Effect.Effect<string, Invalid, FileSystem.FileSystem>;
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region src/text.d.ts
|
|
385
|
+
/**
|
|
386
|
+
* A RAM size, exactly, as a size flag takes one: GiB when it is whole or half GiB, `32G` or
|
|
387
|
+
* `1.5G`, and MiB when it isn't, `512M` or `1500M`.
|
|
388
|
+
*/
|
|
389
|
+
export declare const formatMib: (mib: number) => string;
|
|
390
|
+
/**
|
|
391
|
+
* Everything a decode refused, on one line, each under its JSON path: `cpu: missing; ram: not a
|
|
392
|
+
* known field`. `names` renames top-level fields, as when a flag set each: `--cpu: missing`.
|
|
393
|
+
*/
|
|
394
|
+
export declare const describeIssues: (error: Schema.SchemaError, names?: ReadonlyMap<PropertyKey, string>) => string;
|
|
395
|
+
/**
|
|
396
|
+
* Why a file couldn't be read, without the platform call that met it. Node describes a failure
|
|
397
|
+
* only in its cause, so what has no wording here reads as the system's own.
|
|
398
|
+
*/
|
|
399
|
+
export declare const describeFileError: ({ reason }: PlatformError.PlatformError) => string;
|
|
332
400
|
//#endregion
|
|
333
|
-
export { ActionName, ActionRecord, Capacity, Checkpoint, ClankerboxError, client_d_exports as Client, Conflict, ConflictKind, CreateRequest, ErrorTag, Host, HostError, HostErrorTag, Internal, Invalid, Limit, Machine, MachineSpec, NotFound, Precondition, Profile, Runtime, Setup, SshEndpoint, SshLogin, Unavailable, hostErrors, release, version };
|
|
401
|
+
export { ActionName, ActionRecord, Capacity, Checkpoint, ClankerboxError, client_d_exports as Client, Conflict, ConflictKind, CreateRequest, ErrorTag, Host, HostError, HostErrorTag, Internal, Invalid, Limit, Machine, MachineSpec, NotFound, prebuild_d_exports as Prebuild, Precondition, Profile, Runtime, Setup, SshEndpoint, SshLogin, Unavailable, hostErrors, release, version };
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,105 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import { A as parseName, B as Unavailable, C as release, D as Name, E as Id, F as HostErrorTag, I as Internal, L as Invalid, M as Conflict, N as ConflictKind, O as formatId, P as ErrorTag, R as NotFound, S as SshLogin, T as HostId, V as hostErrors, _ as MachineSpec, a as describeIssues, b as Setup, c as readTimeout, d as ActionRecord, f as Checkpoint, g as Machine, h as Limit, i as describeFileError, j as Capacity, k as parseId, l as versionHeader, m as Host, n as client_exports, o as formatMib, p as CreateRequest, s as Http, u as ActionName, v as Profile, w as version, x as SshEndpoint, y as Runtime, z as Precondition } from "./client-CtI-rSJQ.mjs";
|
|
3
|
+
import { Effect, FileSystem, Option, Path, Predicate, Schema } from "effect";
|
|
4
|
+
//#region src/prebuild.ts
|
|
5
|
+
/**
|
|
6
|
+
* Prebuilds: a checkpoint of a machine whose setup has run, which a create of the same spec
|
|
7
|
+
* restores instead of running the setup again. They are the client's alone. A prebuild is an
|
|
8
|
+
* ordinary checkpoint under a name derived from what it is of, so a host stores no profile and
|
|
9
|
+
* knows nothing of them, and a create finds its prebuild with one read by ID. A prebuild never
|
|
10
|
+
* changes and nothing here deletes one: building anew is a `checkpoint delete` first.
|
|
11
|
+
*/
|
|
12
|
+
var prebuild_exports = /* @__PURE__ */ __exportAll({
|
|
13
|
+
build: () => build,
|
|
14
|
+
create: () => create,
|
|
15
|
+
nameOf: () => nameOf
|
|
16
|
+
});
|
|
17
|
+
const prefix = "prebuilt-";
|
|
18
|
+
/** How much of the digest a prebuild's name carries. */
|
|
19
|
+
const digestDigits = 12;
|
|
20
|
+
/**
|
|
21
|
+
* How many random bytes a builder's name carries: 64 bits, so no two builds draw one name. With
|
|
22
|
+
* `prebuilding-`, it is 28 characters, inside the 29 a 32-character host ID and its `_` leave of
|
|
23
|
+
* an ID's 62.
|
|
24
|
+
*/
|
|
25
|
+
const builderBytes = 8;
|
|
26
|
+
const hex = (bytes) => Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
27
|
+
/**
|
|
28
|
+
* The name of the spec's prebuild on a host: a digest of the spec's label, base and sizes, the
|
|
29
|
+
* setup script as sent, and the host's runtime version. A `ram` checkpoint restores only under
|
|
30
|
+
* the release that saved it, so after a runtime bump a create misses the old prebuild instead
|
|
31
|
+
* of failing to restore it. The setup's timeout is no part of it, and neither is the base's
|
|
32
|
+
* image: a host only names it. The script holds whatever secrets a recipe carries, and the name
|
|
33
|
+
* 12 hex characters of a digest of it.
|
|
34
|
+
*/
|
|
35
|
+
const nameOf = (spec, host) => Effect.promise(() => globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([
|
|
36
|
+
spec.profile ?? null,
|
|
37
|
+
spec.base,
|
|
38
|
+
spec.cpu,
|
|
39
|
+
spec.ramMib,
|
|
40
|
+
spec.diskGib,
|
|
41
|
+
spec.setup?.script ?? null,
|
|
42
|
+
host.runtimeVersion
|
|
43
|
+
])))).pipe(Effect.map((digest) => `${prefix}${hex(digest).slice(0, digestDigits)}`));
|
|
44
|
+
/** The checkpoint `id`, if the host has it. */
|
|
45
|
+
const lookup = (client, id) => client.checkpoint(id).pipe(Effect.map(Option.some), Effect.catchTag("NotFound", () => Effect.succeedNone));
|
|
46
|
+
/**
|
|
47
|
+
* Creates a machine named `name` as `client.create` does, but restores it from the spec's
|
|
48
|
+
* prebuild when the host it lands on has one whose capture is done. A spec without a setup has
|
|
49
|
+
* none. Placement is the create's own, and a prebuild stays on the host that made it, so a spec
|
|
50
|
+
* placed on another host runs its setup. A failed restore is the reply.
|
|
51
|
+
*/
|
|
52
|
+
const create = (client, name, spec, options) => Effect.gen(function* () {
|
|
53
|
+
if (spec.setup === void 0) return { machine: yield* client.create(name, spec, options) };
|
|
54
|
+
const host = yield* client.place(spec.base, options);
|
|
55
|
+
const id = yield* formatId(host.id, yield* nameOf(spec, host));
|
|
56
|
+
const found = yield* lookup(client, id);
|
|
57
|
+
if (Option.isSome(found) && found.value.action.status === "done") return {
|
|
58
|
+
machine: yield* client.restore(id, name),
|
|
59
|
+
prebuild: found.value
|
|
60
|
+
};
|
|
61
|
+
return { machine: yield* client.create(name, spec, { host: host.id }) };
|
|
62
|
+
});
|
|
63
|
+
/**
|
|
64
|
+
* Builds the spec's prebuild on the host a create of it lands on: a machine of its own runs the
|
|
65
|
+
* setup, is captured under the prebuild's name, and is deleted. smolvm captures a running
|
|
66
|
+
* machine, RAM included, and boat a running one too; Tart only a stopped one, so its machine is
|
|
67
|
+
* stopped first. A prebuild whose capture is done is the answer as it is; one that failed or is
|
|
68
|
+
* still captured is refused.
|
|
69
|
+
*
|
|
70
|
+
* Each build's machine has a random name of its own, and the host takes a checkpoint name once,
|
|
71
|
+
* so of two builds of one spec at once both run the setup and the second's capture is
|
|
72
|
+
* `Conflict`: neither touches the other's machine or prebuild. A build deletes its machine
|
|
73
|
+
* however it ends. If that fails after a failure, the first failure is the reply; after a
|
|
74
|
+
* capture, the prebuild is made, and the machine is the reply's `leftover`.
|
|
75
|
+
*/
|
|
76
|
+
const build = (client, spec, options) => Effect.gen(function* () {
|
|
77
|
+
if (spec.setup === void 0) return yield* new Invalid({ message: "a prebuild needs a setup" });
|
|
78
|
+
const host = yield* client.place(spec.base, options);
|
|
79
|
+
const name = yield* nameOf(spec, host);
|
|
80
|
+
const id = yield* formatId(host.id, name);
|
|
81
|
+
const found = yield* lookup(client, id);
|
|
82
|
+
if (Option.isSome(found)) {
|
|
83
|
+
const { action } = found.value;
|
|
84
|
+
return action.status === "done" ? {
|
|
85
|
+
checkpoint: found.value,
|
|
86
|
+
built: false
|
|
87
|
+
} : yield* new Precondition({ message: `prebuild ${id}'s ${action.name} is ${action.status}; delete it to prebuild again` });
|
|
88
|
+
}
|
|
89
|
+
const builder = `prebuilding-${hex(globalThis.crypto.getRandomValues(new Uint8Array(builderBytes)))}`;
|
|
90
|
+
const builderId = yield* formatId(host.id, builder);
|
|
91
|
+
const removal = Effect.catchTag(client.delete(builderId), "NotFound", () => Effect.void);
|
|
92
|
+
return {
|
|
93
|
+
checkpoint: yield* Effect.gen(function* () {
|
|
94
|
+
yield* client.create(builder, spec, { host: host.id });
|
|
95
|
+
if (host.runtime === "tart") yield* client.stop(builderId);
|
|
96
|
+
return yield* client.capture(builderId, name);
|
|
97
|
+
}).pipe(Effect.onError(() => Effect.ignore(removal))),
|
|
98
|
+
built: true,
|
|
99
|
+
leftover: yield* removal.pipe(Effect.as(void 0), Effect.catch(() => Effect.succeed(builderId)))
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
//#endregion
|
|
4
103
|
//#region src/profile.ts
|
|
5
104
|
/** Reads a profile file for `create`. Hosts never see a profile. */
|
|
6
105
|
/** Profile files are JSON, decoded with closed input like every call: unknown keys are refused. */
|
|
@@ -13,8 +112,11 @@ const decodeProfile = Schema.decodeUnknownEffect(Schema.fromJsonString(Profile))
|
|
|
13
112
|
const loadProfile = (file) => Effect.gen(function* () {
|
|
14
113
|
const fs = yield* FileSystem.FileSystem;
|
|
15
114
|
const path = yield* Path.Path;
|
|
16
|
-
const text = yield* fs.readFileString(file).pipe(Effect.mapError((error) => new Invalid({ message: `couldn't read profile ${file}: ${error
|
|
17
|
-
const profile = yield* decodeProfile(text, {
|
|
115
|
+
const text = yield* fs.readFileString(file).pipe(Effect.mapError((error) => new Invalid({ message: `couldn't read profile ${file}: ${describeFileError(error)}` })));
|
|
116
|
+
const profile = yield* decodeProfile(text, {
|
|
117
|
+
onExcessProperty: "error",
|
|
118
|
+
errors: "all"
|
|
119
|
+
}).pipe(Effect.mapError((error) => new Invalid({ message: `profile ${file}: ${describeIssues(error)}` })));
|
|
18
120
|
const label = path.basename(file, ".json");
|
|
19
121
|
return profile.setup === void 0 ? {
|
|
20
122
|
...profile,
|
|
@@ -36,71 +138,84 @@ const loadProfile = (file) => Effect.gen(function* () {
|
|
|
36
138
|
* script. Nothing here logs a script or a packed recipe: a recipe can carry secrets.
|
|
37
139
|
*/
|
|
38
140
|
const lineLength = 76;
|
|
39
|
-
/** base64 has no `_`, so no line of
|
|
141
|
+
/** base64 has no `_`, so no line of a file's payload can end its here-document early. */
|
|
40
142
|
const terminator = "CLANKERBOX_RECIPE";
|
|
41
143
|
const wrap = (base64) => {
|
|
42
144
|
const lines = [];
|
|
43
145
|
for (let at = 0; at < base64.length; at += lineLength) lines.push(base64.slice(at, at + lineLength));
|
|
44
146
|
return lines.join("\n");
|
|
45
147
|
};
|
|
148
|
+
/** A string as one single-quoted shell word, whatever it holds. */
|
|
149
|
+
const quote = (text) => `'${text.replaceAll("'", `'\\''`)}'`;
|
|
150
|
+
/**
|
|
151
|
+
* An entry's path as a command's argument: under `./`, so a name that starts with `-` is never
|
|
152
|
+
* read as an option.
|
|
153
|
+
*/
|
|
154
|
+
const argument = (entry) => quote(`./${entry}`);
|
|
155
|
+
/** Any execute bit, which is the one bit of a mode a recipe carries, as git does. */
|
|
156
|
+
const executable = 73;
|
|
46
157
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* rather than through `exec`, so the cleanup trap still runs after it. `--no-same-owner`
|
|
53
|
-
* keeps the packer's uid off the files when root unpacks them in the guest.
|
|
158
|
+
* Makes the recipe's files in a fresh directory, then runs `./setup.sh` from there: its `#!`
|
|
159
|
+
* line picks the interpreter, as for a single-file setup, and the unpacker marks it executable
|
|
160
|
+
* whatever mode it was packed with. The directory is under `/var/tmp`, as a single-file setup
|
|
161
|
+
* is, which nothing mounts `noexec`, and is removed however setup ends. Setup runs as a child
|
|
162
|
+
* rather than through `exec`, so the cleanup trap still runs after it.
|
|
54
163
|
*/
|
|
55
|
-
const selfExtracting = (
|
|
164
|
+
const selfExtracting = (steps) => [
|
|
56
165
|
"#!/bin/sh",
|
|
57
166
|
"set -eu",
|
|
58
167
|
"recipe=$(mktemp -d /var/tmp/clankerbox-recipe.XXXXXX)",
|
|
59
168
|
`trap 'rm -rf "$recipe"' EXIT`,
|
|
60
169
|
"trap 'exit 1' HUP INT TERM",
|
|
61
|
-
`base64 -d <<'${terminator}' | tar -x -f - --no-same-owner -C "$recipe"`,
|
|
62
|
-
wrap(archive),
|
|
63
|
-
terminator,
|
|
64
170
|
"cd \"$recipe\"",
|
|
171
|
+
...steps,
|
|
65
172
|
"chmod +x setup.sh",
|
|
66
173
|
"./setup.sh",
|
|
67
174
|
""
|
|
68
175
|
].join("\n");
|
|
69
176
|
/**
|
|
70
|
-
* Packs a recipe directory into one self-extracting script
|
|
71
|
-
*
|
|
72
|
-
*
|
|
177
|
+
* Packs a recipe directory into one self-extracting script, which makes each of its entries
|
|
178
|
+
* with plain shell: a directory with `mkdir`, a symlink with `ln -s` as it is, and a file from
|
|
179
|
+
* base64, made executable when any of its execute bits is set. A symlink is never walked into,
|
|
180
|
+
* so nothing outside the recipe is packed, and in the guest nothing is written through one.
|
|
181
|
+
* Of a mode only the execute bit is kept, as git keeps it, so a checkout's umask changes
|
|
182
|
+
* nothing. Entries go in sorted order and nothing records a time or an owner, so the same
|
|
183
|
+
* files pack into the same script: a prebuild is named after it.
|
|
73
184
|
*/
|
|
74
185
|
const packRecipe = (directory) => Effect.gen(function* () {
|
|
75
186
|
const fs = yield* FileSystem.FileSystem;
|
|
76
187
|
const noSetup = new Invalid({ message: `recipe ${directory} has no setup.sh` });
|
|
77
188
|
if ((yield* fs.stat(`${directory}/setup.sh`).pipe(Effect.mapError((error) => Predicate.isTagged(error.reason, "NotFound") ? noSetup : error))).type !== "File") return yield* noSetup;
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
189
|
+
const steps = [];
|
|
190
|
+
/** Packs the entries of the recipe's directory `under`, `""` being the recipe itself. */
|
|
191
|
+
const walk = (under) => Effect.gen(function* () {
|
|
192
|
+
const names = yield* fs.readDirectory(under === "" ? directory : `${directory}/${under}`);
|
|
193
|
+
for (const name of names.toSorted()) {
|
|
194
|
+
const entry = under === "" ? name : `${under}/${name}`;
|
|
195
|
+
const path = `${directory}/${entry}`;
|
|
196
|
+
const link = yield* Effect.option(fs.readLink(path));
|
|
197
|
+
if (Option.isSome(link)) {
|
|
198
|
+
steps.push(`ln -s -- ${quote(link.value)} ${argument(entry)}`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const info = yield* fs.stat(path);
|
|
202
|
+
if (info.type === "Directory") {
|
|
203
|
+
steps.push(`mkdir ${argument(entry)}`);
|
|
204
|
+
yield* walk(entry);
|
|
205
|
+
} else if (info.type === "File") {
|
|
206
|
+
steps.push(`base64 -d >${argument(entry)} <<'${terminator}'`, wrap(Buffer.from(yield* fs.readFile(path)).toString("base64")), terminator);
|
|
207
|
+
if ((info.mode & executable) !== 0) steps.push(`chmod +x ${argument(entry)}`);
|
|
208
|
+
} else return yield* new Invalid({ message: `recipe ${directory} holds ${entry}, a ${info.type}: a recipe packs only files, directories and symlinks` });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
yield* walk("");
|
|
212
|
+
return selfExtracting(steps);
|
|
213
|
+
}).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(new Invalid({ message: `couldn't pack recipe ${directory}: ${describeFileError(error)}` }))));
|
|
99
214
|
/** Reads a setup: a script file's text, or a recipe directory packed into one script. */
|
|
100
215
|
const readSetup = (path) => Effect.gen(function* () {
|
|
101
216
|
const fs = yield* FileSystem.FileSystem;
|
|
102
217
|
if ((yield* fs.stat(path)).type === "Directory") return yield* packRecipe(path);
|
|
103
218
|
return yield* fs.readFileString(path);
|
|
104
|
-
}).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(new Invalid({ message: `couldn't read setup ${path}: ${error
|
|
219
|
+
}).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(new Invalid({ message: `couldn't read setup ${path}: ${describeFileError(error)}` }))));
|
|
105
220
|
//#endregion
|
|
106
|
-
export { ActionName, ActionRecord, Capacity, Checkpoint, client_exports as Client, Conflict, ConflictKind, CreateRequest, ErrorTag, Host, HostErrorTag, HostId, Http, Id, Internal, Invalid, Limit, Machine, MachineSpec, Name, NotFound, Precondition, Profile, Runtime, Setup, SshEndpoint, SshLogin, Unavailable, formatId, hostErrors, loadProfile, packRecipe, parseId, parseName, readSetup, readTimeout, release, version, versionHeader };
|
|
221
|
+
export { ActionName, ActionRecord, Capacity, Checkpoint, client_exports as Client, Conflict, ConflictKind, CreateRequest, ErrorTag, Host, HostErrorTag, HostId, Http, Id, Internal, Invalid, Limit, Machine, MachineSpec, Name, NotFound, prebuild_exports as Prebuild, Precondition, Profile, Runtime, Setup, SshEndpoint, SshLogin, Unavailable, describeFileError, describeIssues, formatId, formatMib, hostErrors, loadProfile, packRecipe, parseId, parseName, readSetup, readTimeout, release, version, versionHeader };
|
package/dist/node.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { A as Invalid, a as Options, r as HostEntry, t as Client } from "./client-Cn1bj4It.mjs";
|
|
2
2
|
import { Layer } from "effect";
|
|
3
3
|
//#region src/node.d.ts
|
|
4
4
|
/** Node's `http` module sets no timeout of its own: a mutation can run for hours. */
|
package/dist/node.mjs
CHANGED