@intentius/chant-lexicon-fly 0.89.0 → 0.91.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/components/capability-plugin.d.ts +1 -0
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/fly-release.d.ts +148 -0
- package/dist/components/fly-release.d.ts.map +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +11 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/op/activities/emulator-images.d.ts +13 -0
- package/dist/op/activities/emulator-images.d.ts.map +1 -1
- package/dist/op/activities/fly-apply.d.ts +8 -22
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +6 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machine-release.d.ts +176 -0
- package/dist/op/activities/machine-release.d.ts.map +1 -0
- package/dist/op/activities/machines-contract.d.ts +8 -0
- package/dist/op/activities/machines-contract.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.d.ts +53 -0
- package/dist/op/activities/machines-fake.d.ts.map +1 -0
- package/dist/op/activities/sprite-services.d.ts +158 -0
- package/dist/op/activities/sprite-services.d.ts.map +1 -0
- package/dist/op/activities/sprites-contract.d.ts +17 -1
- package/dist/op/activities/sprites-contract.d.ts.map +1 -1
- package/dist/op/activities/sprites-emulator.d.ts +12 -0
- package/dist/op/activities/sprites-emulator.d.ts.map +1 -1
- package/dist/op/activities/sprites-fake.d.ts +4 -0
- package/dist/op/activities/sprites-fake.d.ts.map +1 -1
- package/dist/op/activities/sprites.d.ts +53 -0
- package/dist/op/activities/sprites.d.ts.map +1 -1
- package/dist/op/builders.d.ts +33 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/release-metadata.d.ts +48 -0
- package/dist/release-metadata.d.ts.map +1 -0
- package/dist/release-store.d.ts +36 -0
- package/dist/release-store.d.ts.map +1 -0
- package/dist/skills/chant-fly-ops.md +33 -0
- package/dist/skills/chant-fly-sprites.md +4 -2
- package/package.json +2 -2
- package/src/components/capability-plugin.ts +11 -3
- package/src/components/fly-release.test.ts +244 -0
- package/src/components/fly-release.ts +341 -0
- package/src/components/index.ts +16 -0
- package/src/describe-resources.test.ts +18 -3
- package/src/describe-resources.ts +69 -5
- package/src/index.ts +21 -0
- package/src/op/activities/emulator-images.ts +14 -0
- package/src/op/activities/fly-apply.ts +15 -3
- package/src/op/activities/index.ts +57 -0
- package/src/op/activities/machine-release.integration.test.ts +113 -0
- package/src/op/activities/machine-release.test.ts +138 -0
- package/src/op/activities/machine-release.ts +407 -0
- package/src/op/activities/machines-contract.docker.integration.test.ts +10 -1
- package/src/op/activities/machines-contract.test.ts +11 -1
- package/src/op/activities/machines-contract.ts +17 -0
- package/src/op/activities/machines-fake.ts +165 -0
- package/src/op/activities/sprite-config.test.ts +19 -0
- package/src/op/activities/sprite-services.docker.integration.test.ts +87 -0
- package/src/op/activities/sprite-services.test.ts +263 -0
- package/src/op/activities/sprite-services.ts +311 -0
- package/src/op/activities/sprites-contract.test.ts +15 -4
- package/src/op/activities/sprites-contract.ts +24 -6
- package/src/op/activities/sprites-emulator.ts +52 -1
- package/src/op/activities/sprites-fake.ts +57 -8
- package/src/op/activities/sprites.test.ts +102 -0
- package/src/op/activities/sprites.ts +108 -7
- package/src/op/builders.ts +53 -1
- package/src/release-metadata.ts +72 -0
- package/src/release-store.ts +70 -0
- package/src/skills/chant-fly-ops.md +33 -0
- package/src/skills/chant-fly-sprites.md +4 -2
|
@@ -6,9 +6,13 @@ import { SPRITES_CONTRACT, normalizeEndpoint, contractKeys } from "./sprites-con
|
|
|
6
6
|
|
|
7
7
|
describe("SPRITES_CONTRACT", () => {
|
|
8
8
|
test("covers every sprite activity that calls the Sprites API", () => {
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
9
|
+
// A row's `activity` is comma-separated when more than one activity calls
|
|
10
|
+
// that physical endpoint (#2711) — split before comparing.
|
|
11
|
+
const activities = new Set(SPRITES_CONTRACT.flatMap((e) => e.activity.split(",").map((a) => a.trim())));
|
|
12
|
+
// Every activity across the lifecycle, filesystem, config-reconcile,
|
|
13
|
+
// single-service, and keep-alive modules that makes an HTTP/WS call.
|
|
14
|
+
// (spriteUrl, spriteServiceDelete and spriteServiceLogs are deliberately
|
|
15
|
+
// absent — see the module doc.)
|
|
12
16
|
expect(activities).toEqual(
|
|
13
17
|
new Set([
|
|
14
18
|
// lifecycle (./sprites.ts)
|
|
@@ -18,6 +22,7 @@ describe("SPRITES_CONTRACT", () => {
|
|
|
18
22
|
"listCheckpoints",
|
|
19
23
|
"spriteRestore",
|
|
20
24
|
"spriteDestroy",
|
|
25
|
+
"spriteDelete",
|
|
21
26
|
// filesystem (./sprite-fs.ts)
|
|
22
27
|
"spriteWriteFile",
|
|
23
28
|
"spriteReadFile",
|
|
@@ -26,6 +31,12 @@ describe("SPRITES_CONTRACT", () => {
|
|
|
26
31
|
// config reconcile (./sprite-config.ts)
|
|
27
32
|
"spriteApplyNetworkPolicy",
|
|
28
33
|
"spriteApplyServices",
|
|
34
|
+
// single-service (./sprite-services.ts, #2711)
|
|
35
|
+
"spriteServiceCreate",
|
|
36
|
+
"spriteServiceGet",
|
|
37
|
+
"spriteServiceList",
|
|
38
|
+
"spriteServiceStart",
|
|
39
|
+
"spriteServiceStop",
|
|
29
40
|
// keep-alive tasks (./sprite-tasks.ts)
|
|
30
41
|
"spriteTaskCreate",
|
|
31
42
|
"spriteTaskRefresh",
|
|
@@ -63,7 +74,7 @@ describe("SPRITES_CONTRACT", () => {
|
|
|
63
74
|
// contract must be updated in the same change. Scans every module that owns
|
|
64
75
|
// contract endpoints, not just the lifecycle one.
|
|
65
76
|
const dir = dirname(fileURLToPath(import.meta.url));
|
|
66
|
-
const src = ["sprites.ts", "sprite-fs.ts", "sprite-config.ts", "sprite-tasks.ts"]
|
|
77
|
+
const src = ["sprites.ts", "sprite-fs.ts", "sprite-config.ts", "sprite-services.ts", "sprite-tasks.ts"]
|
|
67
78
|
.map((f) => readFileSync(join(dir, f), "utf-8"))
|
|
68
79
|
.join("\n");
|
|
69
80
|
const segments = new Set(
|
|
@@ -19,6 +19,22 @@
|
|
|
19
19
|
* Path params are written with names matching ./sprites.ts (`{id}`, `{cp}`);
|
|
20
20
|
* the coverage check normalizes param names before comparing, since spritzer
|
|
21
21
|
* spells the checkpoint id `{cid}`.
|
|
22
|
+
*
|
|
23
|
+
* #2711 adds the imperative Services activities (`./sprite-services.ts`).
|
|
24
|
+
* Several of them share a physical endpoint with `spriteApplyServices`'s own
|
|
25
|
+
* calls (one `PUT`/one `GET .../services`/one `.../start` — the reconcile
|
|
26
|
+
* activity and the single-service primitives are two callers of the same
|
|
27
|
+
* REST surface), so one row lists every activity that calls it, comma
|
|
28
|
+
* separated, rather than one row per activity — `contractKeys()` stays one
|
|
29
|
+
* key per physical endpoint. `spriteServiceDelete` and `spriteServiceLogs`
|
|
30
|
+
* are deliberately absent: spritzer's interpreter mode (the coverage test's
|
|
31
|
+
* oracle) doesn't implement them, only its container-mode agent does, which
|
|
32
|
+
* has no per-verb entry in `/_spritzer/health` (real.go reports the whole
|
|
33
|
+
* `/v1/sprites/{id}/services/...` proxy as one wildcard line — nothing to
|
|
34
|
+
* normalize-compare against a specific verb). They're exercised instead by
|
|
35
|
+
* `sprite-services.docker.integration.test.ts` against real container-mode
|
|
36
|
+
* spritzer. `spriteUrl` is absent for the same reason, plus its target
|
|
37
|
+
* (`/s/{name}/...`) isn't under `/v1/sprites` at all.
|
|
22
38
|
*/
|
|
23
39
|
|
|
24
40
|
/** One endpoint the fly sprite activities call. */
|
|
@@ -27,7 +43,7 @@ export interface SpritesEndpoint {
|
|
|
27
43
|
method: "GET" | "POST" | "PUT" | "DELETE" | "WS";
|
|
28
44
|
/** Path template under the Sprites base, e.g. `/v1/sprites/{id}/checkpoint`. */
|
|
29
45
|
path: string;
|
|
30
|
-
/** The fly activity that
|
|
46
|
+
/** The fly activity/activities that call it (comma-separated when more than one shares the endpoint). */
|
|
31
47
|
activity: string;
|
|
32
48
|
}
|
|
33
49
|
|
|
@@ -46,7 +62,7 @@ export const SPRITES_CONTRACT: readonly SpritesEndpoint[] = [
|
|
|
46
62
|
{ method: "POST", path: "/v1/sprites/{id}/checkpoint", activity: "spriteCheckpoint" },
|
|
47
63
|
{ method: "GET", path: "/v1/sprites/{id}/checkpoints", activity: "listCheckpoints" },
|
|
48
64
|
{ method: "POST", path: "/v1/sprites/{id}/checkpoints/{cp}/restore", activity: "spriteRestore" },
|
|
49
|
-
{ method: "DELETE", path: "/v1/sprites/{id}", activity: "spriteDestroy" },
|
|
65
|
+
{ method: "DELETE", path: "/v1/sprites/{id}", activity: "spriteDestroy, spriteDelete" },
|
|
50
66
|
// Filesystem (./sprite-fs.ts)
|
|
51
67
|
{ method: "PUT", path: "/v1/sprites/{id}/fs/write", activity: "spriteWriteFile" },
|
|
52
68
|
{ method: "GET", path: "/v1/sprites/{id}/fs/read", activity: "spriteReadFile" },
|
|
@@ -55,10 +71,12 @@ export const SPRITES_CONTRACT: readonly SpritesEndpoint[] = [
|
|
|
55
71
|
// Network policy (./sprite-config.ts)
|
|
56
72
|
{ method: "GET", path: "/v1/sprites/{id}/policy/network", activity: "spriteApplyNetworkPolicy" },
|
|
57
73
|
{ method: "POST", path: "/v1/sprites/{id}/policy/network", activity: "spriteApplyNetworkPolicy" },
|
|
58
|
-
// Services (./sprite-config.ts)
|
|
59
|
-
{ method: "GET", path: "/v1/sprites/{id}/services", activity: "spriteApplyServices" },
|
|
60
|
-
{ method: "
|
|
61
|
-
{ method: "
|
|
74
|
+
// Services (./sprite-config.ts, ./sprite-services.ts)
|
|
75
|
+
{ method: "GET", path: "/v1/sprites/{id}/services", activity: "spriteApplyServices, spriteServiceList" },
|
|
76
|
+
{ method: "GET", path: "/v1/sprites/{id}/services/{svc}", activity: "spriteServiceGet" },
|
|
77
|
+
{ method: "PUT", path: "/v1/sprites/{id}/services/{svc}", activity: "spriteApplyServices, spriteServiceCreate" },
|
|
78
|
+
{ method: "POST", path: "/v1/sprites/{id}/services/{svc}/start", activity: "spriteApplyServices, spriteServiceStart" },
|
|
79
|
+
{ method: "POST", path: "/v1/sprites/{id}/services/{svc}/stop", activity: "spriteServiceStop" },
|
|
62
80
|
// Keep-alive tasks (./sprite-tasks.ts)
|
|
63
81
|
{ method: "POST", path: "/v1/sprites/{id}/tasks", activity: "spriteTaskCreate" },
|
|
64
82
|
{ method: "PUT", path: "/v1/sprites/{id}/tasks/{name}", activity: "spriteTaskRefresh" },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { emulatorLifecycle, type EmulatorCapability, type EmulatorSpec } from "@intentius/chant/op";
|
|
2
|
-
import { SPRITZER_IMAGE } from "./emulator-images";
|
|
2
|
+
import { SPRITZER_IMAGE, SPRITZER_CONTAINER_IMAGE } from "./emulator-images";
|
|
3
3
|
|
|
4
4
|
export interface SpritesUpArgs {
|
|
5
5
|
/** Container name. Default: `chant-spritzer`. */
|
|
@@ -53,3 +53,54 @@ export const spritesUp = (args: SpritesUpArgs = {}, signal?: AbortSignal): Promi
|
|
|
53
53
|
/** Stop and remove the local spritzer container (no-op if already gone). */
|
|
54
54
|
export const spritesDown = (args: SpritesDownArgs = {}, signal?: AbortSignal): Promise<void> =>
|
|
55
55
|
spritzer.down(args, signal);
|
|
56
|
+
|
|
57
|
+
// ── Container exec mode (#2711, INTENTIUS/spritzer#22) ─────────────────────────
|
|
58
|
+
//
|
|
59
|
+
// A second, distinct spritzer lifecycle: `SPRITZER_EXEC=container` with the
|
|
60
|
+
// Docker runtime, so each sprite it makes is a real container — the mode the
|
|
61
|
+
// Services CRUD activities (`./sprite-services.ts`) and `spriteUrl` need (real
|
|
62
|
+
// processes, a real sprite URL), and that `SPRITZER_SPEC` above (interpreter
|
|
63
|
+
// mode, the default everything else tests against) does not run. The Docker
|
|
64
|
+
// socket is mounted read-write so spritzer can create/exec/delete sibling
|
|
65
|
+
// sprite containers through it (docker-outside-of-docker); no port publishing
|
|
66
|
+
// is needed for the sprites themselves — spritzer reaches them by exec, and
|
|
67
|
+
// proxies their URL (`/s/{name}/...`) through the one published port.
|
|
68
|
+
|
|
69
|
+
/** Container exec mode spritzer, pinned to {@link SPRITZER_CONTAINER_IMAGE} (0.6.0, the first release with this mode). */
|
|
70
|
+
export const SPRITZER_CONTAINER_SPEC: EmulatorSpec = {
|
|
71
|
+
name: "chant-spritzer-container",
|
|
72
|
+
image: SPRITZER_CONTAINER_IMAGE,
|
|
73
|
+
containerPort: 4290,
|
|
74
|
+
healthPath: "/_spritzer/health",
|
|
75
|
+
runArgs: [
|
|
76
|
+
// The image runs as `nonroot` by default, which cannot open a
|
|
77
|
+
// group/other-unwritable host socket — root inside the container is
|
|
78
|
+
// still an unprivileged Linux user account relative to the host/Docker
|
|
79
|
+
// Desktop VM, no different from any other `docker run` that mounts the
|
|
80
|
+
// socket.
|
|
81
|
+
"--user",
|
|
82
|
+
"root",
|
|
83
|
+
"-e",
|
|
84
|
+
"SPRITZER_EXEC=container",
|
|
85
|
+
"-e",
|
|
86
|
+
"SPRITZER_RUNTIME=docker",
|
|
87
|
+
"-v",
|
|
88
|
+
"/var/run/docker.sock:/var/run/docker.sock",
|
|
89
|
+
],
|
|
90
|
+
upstream: { repo: "intentius/spritzer" },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const spritzerContainer = emulatorLifecycle(SPRITZER_CONTAINER_SPEC);
|
|
94
|
+
|
|
95
|
+
export const spritesContainerRunCommand = (args: SpritesUpArgs = {}): string => spritzerContainer.runCommand(args);
|
|
96
|
+
export const spritesContainerExistsCommand = spritzerContainer.existsCommand;
|
|
97
|
+
export const spritesContainerRmCommand = spritzerContainer.rmCommand;
|
|
98
|
+
export const spritesContainerHealthUrl = spritzerContainer.healthUrl;
|
|
99
|
+
|
|
100
|
+
/** Boot a local spritzer in container exec mode (Docker runtime) and return its endpoint. */
|
|
101
|
+
export const spritesContainerUp = (args: SpritesUpArgs = {}, signal?: AbortSignal): Promise<{ endpoint: string }> =>
|
|
102
|
+
spritzerContainer.up(args, signal);
|
|
103
|
+
|
|
104
|
+
/** Stop and remove the local container-exec-mode spritzer (no-op if already gone). Sprite containers it made are the caller's to clean up (see the integration test). */
|
|
105
|
+
export const spritesContainerDown = (args: SpritesDownArgs = {}, signal?: AbortSignal): Promise<void> =>
|
|
106
|
+
spritzerContainer.down(args, signal);
|
|
@@ -41,7 +41,10 @@ interface StoredService {
|
|
|
41
41
|
dir?: string;
|
|
42
42
|
needs?: string[];
|
|
43
43
|
http_port?: number;
|
|
44
|
-
state: { name: string; pid: number; status: string; started_at?: string };
|
|
44
|
+
state: { name: string; pid: number; status: string; started_at?: string; error?: string };
|
|
45
|
+
/** Synthetic log lines (#2711) — the fake runs no real process, so `logs` is a
|
|
46
|
+
* scripted trail of lifecycle events, enough to exercise `spriteServiceLogs`. */
|
|
47
|
+
logs: string[];
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
interface SpriteState {
|
|
@@ -244,6 +247,11 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
|
|
|
244
247
|
res.writeHead(status, { "content-type": "application/json" });
|
|
245
248
|
res.end(JSON.stringify(body ?? {}));
|
|
246
249
|
};
|
|
250
|
+
// No-body reply (real Sprites' 204s carry no content and no body): #2719.
|
|
251
|
+
const sendEmpty = (status: number): void => {
|
|
252
|
+
res.writeHead(status);
|
|
253
|
+
res.end();
|
|
254
|
+
};
|
|
247
255
|
// NDJSON progress stream: an `info` line then a terminal `complete` line.
|
|
248
256
|
const sendNdjson = (status: number, events: Array<Record<string, unknown>>): void => {
|
|
249
257
|
res.writeHead(status, { "content-type": "application/x-ndjson" });
|
|
@@ -311,13 +319,14 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
|
|
|
311
319
|
if (method === "POST") {
|
|
312
320
|
const body = ((await readBody(req)) ?? {}) as { rules?: Array<{ domain: string; action: string }> };
|
|
313
321
|
sprite.netPolicy = body.rules ?? [];
|
|
314
|
-
|
|
322
|
+
// 204 with no body — matches every official SDK and wisp (#2719).
|
|
323
|
+
return sendEmpty(204);
|
|
315
324
|
}
|
|
316
325
|
return send(404, { error: `not found: ${method} ${path}` });
|
|
317
326
|
}
|
|
318
327
|
|
|
319
|
-
// Services: /v1/sprites/{id}/services[/{svc}[/start|stop|restart]].
|
|
320
|
-
const svcm = path.match(/^\/v1\/sprites\/([^/]+)\/services(?:\/([^/]+)(\/start|\/stop|\/restart)?)?\/?$/);
|
|
328
|
+
// Services: /v1/sprites/{id}/services[/{svc}[/start|stop|restart|logs]].
|
|
329
|
+
const svcm = path.match(/^\/v1\/sprites\/([^/]+)\/services(?:\/([^/]+)(\/start|\/stop|\/restart|\/logs)?)?\/?$/);
|
|
321
330
|
if (svcm) {
|
|
322
331
|
const id = decodeURIComponent(svcm[1]);
|
|
323
332
|
const svc = svcm[2] ? decodeURIComponent(svcm[2]) : undefined;
|
|
@@ -328,15 +337,30 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
|
|
|
328
337
|
// GET /services — list.
|
|
329
338
|
if (method === "GET" && !svc) return send(200, Object.values(sprite.services));
|
|
330
339
|
|
|
340
|
+
// GET /services/{svc}/logs — the tail as NDJSON (#2711).
|
|
341
|
+
if (method === "GET" && svc && action === "/logs") {
|
|
342
|
+
const s = sprite.services[svc];
|
|
343
|
+
if (!s) return send(404, { error: `no service ${svc}` });
|
|
344
|
+
const n = Number(url.searchParams.get("lines"));
|
|
345
|
+
const tail = Number.isFinite(n) && n > 0 ? s.logs.slice(-n) : s.logs;
|
|
346
|
+
return sendNdjson(
|
|
347
|
+
200,
|
|
348
|
+
[...tail.map((data) => ({ type: "stdout", data })), { type: "complete", data: `${svc} log tail` }],
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
331
352
|
if (svc && !action) {
|
|
332
353
|
// GET /services/{svc}
|
|
333
354
|
if (method === "GET") {
|
|
334
355
|
const s = sprite.services[svc];
|
|
335
356
|
return s ? send(200, s) : send(404, { error: `no service ${svc}` });
|
|
336
357
|
}
|
|
337
|
-
// PUT /services/{svc} — create
|
|
358
|
+
// PUT /services/{svc} — create-or-update, then start (#2711: real
|
|
359
|
+
// Sprites/spritzer's PUT both defines and starts the service).
|
|
338
360
|
if (method === "PUT") {
|
|
339
|
-
const b = ((await readBody(req)) ?? {}) as Omit<StoredService, "name" | "state">;
|
|
361
|
+
const b = ((await readBody(req)) ?? {}) as Omit<StoredService, "name" | "state" | "logs">;
|
|
362
|
+
const prev = sprite.services[svc];
|
|
363
|
+
const started = new Date().toISOString();
|
|
340
364
|
sprite.services[svc] = {
|
|
341
365
|
name: svc,
|
|
342
366
|
cmd: b.cmd,
|
|
@@ -345,18 +369,26 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
|
|
|
345
369
|
dir: b.dir,
|
|
346
370
|
needs: b.needs,
|
|
347
371
|
http_port: b.http_port,
|
|
348
|
-
state:
|
|
372
|
+
state: { name: svc, pid: 4321, status: "running", started_at: started },
|
|
373
|
+
logs: [...(prev?.logs ?? []), `${svc} started (pid 4321)`],
|
|
349
374
|
};
|
|
350
375
|
return send(200, sprite.services[svc]);
|
|
351
376
|
}
|
|
377
|
+
// DELETE /services/{svc} — idempotent; a 404 means already gone (#2711).
|
|
378
|
+
if (method === "DELETE") {
|
|
379
|
+
if (!(svc in sprite.services)) return send(404, { error: `no service ${svc}` });
|
|
380
|
+
delete sprite.services[svc];
|
|
381
|
+
return send(200, {});
|
|
382
|
+
}
|
|
352
383
|
}
|
|
353
384
|
|
|
354
385
|
// POST /services/{svc}/start|stop|restart — NDJSON, flips status.
|
|
355
|
-
if (method === "POST" && svc && action) {
|
|
386
|
+
if (method === "POST" && svc && (action === "/start" || action === "/stop" || action === "/restart")) {
|
|
356
387
|
const s = sprite.services[svc];
|
|
357
388
|
if (!s) return send(404, { error: `no service ${svc}` });
|
|
358
389
|
const stopped = action === "/stop";
|
|
359
390
|
s.state = { name: svc, pid: stopped ? 0 : 4321, status: stopped ? "stopped" : "running", started_at: new Date().toISOString() };
|
|
391
|
+
s.logs.push(`${svc} ${stopped ? "stopping" : "started"}`);
|
|
360
392
|
return sendNdjson(200, [
|
|
361
393
|
{ type: stopped ? "stopping" : "started", data: `${svc} ${stopped ? "stopping" : "started"}` },
|
|
362
394
|
{ type: "complete", data: `${svc} ${action.slice(1)} complete` },
|
|
@@ -365,6 +397,23 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
|
|
|
365
397
|
return send(404, { error: `not found: ${method} ${path}` });
|
|
366
398
|
}
|
|
367
399
|
|
|
400
|
+
// The sprite URL proxy: /s/{id}[/...] (#2711). Real spritzer routes this to
|
|
401
|
+
// whatever listens on the `http_port` service's port; the fake has no real
|
|
402
|
+
// process, so it answers 200 when such a service is `running` and 503
|
|
403
|
+
// ("nothing is answering", matching spritzer's real error) otherwise —
|
|
404
|
+
// enough for `spriteUrl`'s wait-for-200 and the stop/start round trip.
|
|
405
|
+
const sm = path.match(/^\/s\/([^/]+)(?:\/.*)?$/);
|
|
406
|
+
if (sm) {
|
|
407
|
+
const id = decodeURIComponent(sm[1]);
|
|
408
|
+
const sprite = sprites.get(id);
|
|
409
|
+
if (!sprite || sprite.status === "destroyed") return send(404, { error: `no sprite ${id}` });
|
|
410
|
+
const serving = Object.values(sprite.services).find((s) => s.http_port !== undefined && s.state.status === "running");
|
|
411
|
+
if (serving) return send(200, { ok: true, service: serving.name });
|
|
412
|
+
res.writeHead(503, { "content-type": "application/json", "retry-after": "2" });
|
|
413
|
+
res.end(JSON.stringify({ error: `sprite ${id}: nothing is answering on its url port` }));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
368
417
|
// Filesystem API: /v1/sprites/{id}/fs/{read|write|list|delete}. read/write
|
|
369
418
|
// move raw bytes; list/delete use query params + JSON/empty responses.
|
|
370
419
|
const fsm = path.match(/^\/v1\/sprites\/([^/]+)\/fs\/(read|write|list|delete)\/?$/);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
resolveSpritesEndpoint,
|
|
4
|
+
resolveSpritesToken,
|
|
4
5
|
DEFAULT_SPRITES_BASE_URL,
|
|
5
6
|
accumulateExecFrames,
|
|
6
7
|
parseCheckpointNdjson,
|
|
@@ -13,6 +14,8 @@ import {
|
|
|
13
14
|
spriteRestore,
|
|
14
15
|
listCheckpoints,
|
|
15
16
|
spriteDestroy,
|
|
17
|
+
spriteDelete,
|
|
18
|
+
spriteUrl,
|
|
16
19
|
type SpritesHttp,
|
|
17
20
|
type Checkpoint,
|
|
18
21
|
} from "./sprites";
|
|
@@ -141,6 +144,50 @@ describe("resolveSpritesEndpoint (S3)", () => {
|
|
|
141
144
|
test("default is real Sprites", () => {
|
|
142
145
|
expect(resolveSpritesEndpoint({}, {} as NodeJS.ProcessEnv)).toBe(DEFAULT_SPRITES_BASE_URL);
|
|
143
146
|
});
|
|
147
|
+
|
|
148
|
+
// #2711 — the studio and wisp call it SPRITES_API_URL.
|
|
149
|
+
test("SPRITES_API_URL alias when SPRITES_BASE_URL is unset", () => {
|
|
150
|
+
expect(resolveSpritesEndpoint({}, { SPRITES_API_URL: "http://alias:9000" } as NodeJS.ProcessEnv)).toBe(
|
|
151
|
+
"http://alias:9000",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("SPRITES_BASE_URL wins over SPRITES_API_URL", () => {
|
|
156
|
+
expect(
|
|
157
|
+
resolveSpritesEndpoint(
|
|
158
|
+
{},
|
|
159
|
+
{ SPRITES_BASE_URL: "http://base:1", SPRITES_API_URL: "http://alias:2" } as NodeJS.ProcessEnv,
|
|
160
|
+
),
|
|
161
|
+
).toBe("http://base:1");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("resolveSpritesToken (#2711)", () => {
|
|
166
|
+
test("explicit arg wins", () => {
|
|
167
|
+
expect(resolveSpritesToken("arg-token", { SPRITES_API_TOKEN: "env" } as NodeJS.ProcessEnv)).toBe("arg-token");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("SPRITES_API_TOKEN env when no arg", () => {
|
|
171
|
+
expect(resolveSpritesToken(undefined, { SPRITES_API_TOKEN: "env-token" } as NodeJS.ProcessEnv)).toBe(
|
|
172
|
+
"env-token",
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("SPRITE_TOKEN alias when SPRITES_API_TOKEN is unset", () => {
|
|
177
|
+
expect(resolveSpritesToken(undefined, { SPRITE_TOKEN: "alias-token" } as NodeJS.ProcessEnv)).toBe(
|
|
178
|
+
"alias-token",
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("SPRITES_API_TOKEN wins over SPRITE_TOKEN", () => {
|
|
183
|
+
expect(
|
|
184
|
+
resolveSpritesToken(undefined, { SPRITES_API_TOKEN: "orig", SPRITE_TOKEN: "alias" } as NodeJS.ProcessEnv),
|
|
185
|
+
).toBe("orig");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("undefined when nothing is set", () => {
|
|
189
|
+
expect(resolveSpritesToken(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined();
|
|
190
|
+
});
|
|
144
191
|
});
|
|
145
192
|
|
|
146
193
|
// ── Command tokenizing + exec WS url ──────────────────────────────────────────
|
|
@@ -263,6 +310,61 @@ describe("spriteDestroy", () => {
|
|
|
263
310
|
});
|
|
264
311
|
});
|
|
265
312
|
|
|
313
|
+
describe("spriteDelete (#2711)", () => {
|
|
314
|
+
test("is spriteDestroy under another name — same DELETE, same idempotence", async () => {
|
|
315
|
+
expect(spriteDelete).toBe(spriteDestroy);
|
|
316
|
+
const { http, calls } = recorder(() => ({ status: 200, text: "{}" }));
|
|
317
|
+
await spriteDelete({ id: "task-1", endpoint: "http://x" }, undefined, http);
|
|
318
|
+
expect(calls[0].method).toBe("DELETE");
|
|
319
|
+
expect(calls[0].url).toBe("http://x/v1/sprites/task-1");
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("spriteUrl (#2711)", () => {
|
|
324
|
+
test("GETs /v1/sprites/{id} and returns its url", async () => {
|
|
325
|
+
const { http, calls } = recorder(() => ({ status: 200, text: JSON.stringify({ url: "http://h/s/task-1" }) }));
|
|
326
|
+
const res = await spriteUrl({ id: "task-1", endpoint: "http://x" }, undefined, http);
|
|
327
|
+
expect(res).toEqual({ url: "http://h/s/task-1" });
|
|
328
|
+
expect(calls[0].method).toBe("GET");
|
|
329
|
+
expect(calls[0].url).toBe("http://x/v1/sprites/task-1");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("throws when the sprite has no url", async () => {
|
|
333
|
+
const { http } = recorder(() => ({ status: 200, text: "{}" }));
|
|
334
|
+
await expect(spriteUrl({ id: "task-1", endpoint: "http://x" }, undefined, http)).rejects.toThrow(/no url/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("with a path: returns immediately once the check answers 2xx", async () => {
|
|
338
|
+
let checks = 0;
|
|
339
|
+
const { http, calls } = recorder((method, url) => {
|
|
340
|
+
if (method === "GET" && url === "http://x/v1/sprites/task-1") {
|
|
341
|
+
return { status: 200, text: JSON.stringify({ url: "http://h/s/task-1" }) };
|
|
342
|
+
}
|
|
343
|
+
checks += 1;
|
|
344
|
+
return { status: checks < 2 ? 503 : 200, text: "" };
|
|
345
|
+
});
|
|
346
|
+
const res = await spriteUrl(
|
|
347
|
+
{ id: "task-1", endpoint: "http://x", path: "/", intervalMs: 1 },
|
|
348
|
+
undefined,
|
|
349
|
+
http,
|
|
350
|
+
);
|
|
351
|
+
expect(res).toEqual({ url: "http://h/s/task-1" });
|
|
352
|
+
expect(checks).toBe(2); // one 503, then 200
|
|
353
|
+
expect(calls.at(-1)?.url).toBe("http://h/s/task-1/");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("with a path: throws once timeoutMs elapses without a match", async () => {
|
|
357
|
+
const { http } = recorder((method, url) =>
|
|
358
|
+
url === "http://x/v1/sprites/task-1"
|
|
359
|
+
? { status: 200, text: JSON.stringify({ url: "http://h/s/task-1" }) }
|
|
360
|
+
: { status: 503, text: "nothing is answering" },
|
|
361
|
+
);
|
|
362
|
+
await expect(
|
|
363
|
+
spriteUrl({ id: "task-1", endpoint: "http://x", path: "/", timeoutMs: 5, intervalMs: 1 }, undefined, http),
|
|
364
|
+
).rejects.toThrow(/did not answer/);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
266
368
|
// ── Bearer header on the default client ───────────────────────────────────────
|
|
267
369
|
|
|
268
370
|
describe("defaultSpritesHttp bearer header", () => {
|
|
@@ -20,6 +20,13 @@
|
|
|
20
20
|
* S3: endpoint override via `SPRITES_BASE_URL` (an explicit `endpoint` arg wins,
|
|
21
21
|
* then the env, then the real Sprites base), so the same Op targets real Sprites
|
|
22
22
|
* or the in-process fake with no code change.
|
|
23
|
+
*
|
|
24
|
+
* #2711: the studio and wisp's own docs call these `SPRITES_API_URL` and
|
|
25
|
+
* `SPRITE_TOKEN` rather than `SPRITES_BASE_URL`/`SPRITES_API_TOKEN`. Both
|
|
26
|
+
* pairs are accepted — `resolveSpritesEndpoint`/`resolveSpritesToken` read the
|
|
27
|
+
* alias when the original name is unset, with the original always winning
|
|
28
|
+
* when both are present, so an existing deployment is never surprised by the
|
|
29
|
+
* alias taking over.
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
32
|
// `ws` is loaded inside spriteExec, not at the top. It is a CommonJS package
|
|
@@ -27,6 +34,8 @@
|
|
|
27
34
|
// ESM, where that require throws. The top-level import put it on the path of
|
|
28
35
|
// every project that imports the lexicon and needs the run fallback (#2613).
|
|
29
36
|
|
|
37
|
+
import { sleep } from "@intentius/chant/op";
|
|
38
|
+
|
|
30
39
|
export const DEFAULT_SPRITES_BASE_URL = "https://api.sprites.dev";
|
|
31
40
|
|
|
32
41
|
// ── Pure helpers (unit-testable without http/ws) ──────────────────────────────
|
|
@@ -41,15 +50,27 @@ export function resolveSpritesEndpoint(
|
|
|
41
50
|
args: { endpoint?: string } = {},
|
|
42
51
|
env: NodeJS.ProcessEnv = process.env,
|
|
43
52
|
): string {
|
|
44
|
-
const base = args.endpoint || env.SPRITES_BASE_URL || DEFAULT_SPRITES_BASE_URL;
|
|
53
|
+
const base = args.endpoint || env.SPRITES_BASE_URL || env.SPRITES_API_URL || DEFAULT_SPRITES_BASE_URL;
|
|
45
54
|
return base.replace(/\/$/, "");
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the bearer token (#2711): an explicit `token` arg wins, then
|
|
59
|
+
* `SPRITES_API_TOKEN`, then its alias `SPRITE_TOKEN`. Pure — mirrors
|
|
60
|
+
* `resolveSpritesEndpoint`.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveSpritesToken(
|
|
63
|
+
token: string | undefined = undefined,
|
|
64
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
65
|
+
): string | undefined {
|
|
66
|
+
return token ?? env.SPRITES_API_TOKEN ?? env.SPRITE_TOKEN;
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
const spritesUrl = (base: string): string => `${base}/v1/sprites`;
|
|
49
|
-
const
|
|
70
|
+
const spriteResourceUrl = (base: string, id: string): string => `${spritesUrl(base)}/${encodeURIComponent(id)}`;
|
|
50
71
|
// Create uses REST JSON; exec is the control WebSocket below; checkpoints are NDJSON.
|
|
51
|
-
const spriteCheckpointUrl = (base: string, id: string): string => `${
|
|
52
|
-
const spriteCheckpointsUrl = (base: string, id: string): string => `${
|
|
72
|
+
const spriteCheckpointUrl = (base: string, id: string): string => `${spriteResourceUrl(base, id)}/checkpoint`;
|
|
73
|
+
const spriteCheckpointsUrl = (base: string, id: string): string => `${spriteResourceUrl(base, id)}/checkpoints`;
|
|
53
74
|
const spriteCheckpointRestoreUrl = (base: string, id: string, cp: string): string =>
|
|
54
75
|
`${spriteCheckpointsUrl(base, id)}/${encodeURIComponent(cp)}/restore`;
|
|
55
76
|
|
|
@@ -354,7 +375,7 @@ export function defaultSpritesHttp(token?: string, fetchImpl: typeof fetch = fet
|
|
|
354
375
|
return async (method, url, body, headers, signal) => {
|
|
355
376
|
const h: Record<string, string> = { ...headers };
|
|
356
377
|
if (body !== undefined) h["content-type"] = "application/json";
|
|
357
|
-
const tok = token
|
|
378
|
+
const tok = resolveSpritesToken(token);
|
|
358
379
|
if (tok) h["authorization"] = `Bearer ${tok}`;
|
|
359
380
|
const res = await fetchImpl(url, {
|
|
360
381
|
method,
|
|
@@ -393,7 +414,7 @@ export async function spriteCreate(
|
|
|
393
414
|
export async function spriteExec(args: SpriteExecArgs, signal?: AbortSignal): Promise<SpriteExecResult> {
|
|
394
415
|
const base = resolveSpritesEndpoint(args);
|
|
395
416
|
const url = spriteExecWsUrl(base, args.id, args.cmd);
|
|
396
|
-
const token = args.token
|
|
417
|
+
const token = resolveSpritesToken(args.token);
|
|
397
418
|
const headers: Record<string, string> = {};
|
|
398
419
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
399
420
|
const { default: WebSocket } = await import("ws");
|
|
@@ -522,10 +543,90 @@ export async function spriteDestroy(
|
|
|
522
543
|
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
523
544
|
): Promise<Record<string, never>> {
|
|
524
545
|
const base = resolveSpritesEndpoint(args);
|
|
525
|
-
const res = await http("DELETE",
|
|
546
|
+
const res = await http("DELETE", spriteResourceUrl(base, args.id), undefined, undefined, signal);
|
|
526
547
|
if (res.status >= 300 && res.status !== 404) {
|
|
527
548
|
throw new Error(`sprite ${args.id} destroy failed (${res.status}): ${res.text}`);
|
|
528
549
|
}
|
|
529
550
|
console.log(`destroyed: sprite/${args.id} (${base})`);
|
|
530
551
|
return {};
|
|
531
552
|
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Delete the sprite (#2711) — the same idempotent `DELETE /v1/sprites/{id}` as
|
|
556
|
+
* `spriteDestroy`, under the name spritzer, wisp and the other lexicons'
|
|
557
|
+
* delete activities (`awsDelete`, `gcpDelete`, `azDelete`) use. A literal
|
|
558
|
+
* alias, not a reimplementation, so the two names can never drift: an Op
|
|
559
|
+
* authored against either resolves to the same call. `spriteDestroy` stays —
|
|
560
|
+
* it is the name every existing Op, example and the `op-verb-class` registry
|
|
561
|
+
* already depend on.
|
|
562
|
+
*/
|
|
563
|
+
export const spriteDelete = spriteDestroy;
|
|
564
|
+
export type SpriteDeleteArgs = SpriteDestroyArgs;
|
|
565
|
+
|
|
566
|
+
export interface SpriteUrlArgs {
|
|
567
|
+
/** Target sprite id. */
|
|
568
|
+
id: string;
|
|
569
|
+
/**
|
|
570
|
+
* Wait until this path on the sprite's URL answers, instead of returning
|
|
571
|
+
* the URL immediately. Unset (default): no wait, just resolve the URL.
|
|
572
|
+
*/
|
|
573
|
+
path?: string;
|
|
574
|
+
/** Expected status while waiting. Default: any 2xx. */
|
|
575
|
+
status?: number;
|
|
576
|
+
/** Max time to wait for `path` to answer, ms. Default: `10000`. */
|
|
577
|
+
timeoutMs?: number;
|
|
578
|
+
/** Delay between polls, ms. Default: `500`. */
|
|
579
|
+
intervalMs?: number;
|
|
580
|
+
endpoint?: string;
|
|
581
|
+
token?: string;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export interface SpriteUrlResult {
|
|
585
|
+
url: string;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Resolve the sprite's URL (#2711) — `spriteCreate` returns it too, but
|
|
590
|
+
* nothing until now exposed it on its own (for a later phase that only has
|
|
591
|
+
* the sprite `id`) or waited on it. `GET /v1/sprites/{id}` for the URL; with
|
|
592
|
+
* `path` set, polls `GET {url}{path}` until `status` (default any 2xx) or
|
|
593
|
+
* `timeoutMs` runs out — the box's door/hud/chud services take a moment to
|
|
594
|
+
* bind their `http_port` after `spriteServiceStart`.
|
|
595
|
+
*/
|
|
596
|
+
export async function spriteUrl(
|
|
597
|
+
args: SpriteUrlArgs,
|
|
598
|
+
signal?: AbortSignal,
|
|
599
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
600
|
+
): Promise<SpriteUrlResult> {
|
|
601
|
+
const base = resolveSpritesEndpoint(args);
|
|
602
|
+
const res = await http("GET", spriteResourceUrl(base, args.id), undefined, undefined, signal);
|
|
603
|
+
if (res.status >= 300) throw new Error(`sprite ${args.id} url lookup failed (${res.status}): ${res.text}`);
|
|
604
|
+
const url = (safeJson(res.text) as { url?: string } | undefined)?.url;
|
|
605
|
+
if (!url) throw new Error(`sprite ${args.id} has no url`);
|
|
606
|
+
|
|
607
|
+
if (args.path !== undefined) {
|
|
608
|
+
const timeoutMs = args.timeoutMs ?? 10_000;
|
|
609
|
+
const intervalMs = args.intervalMs ?? 500;
|
|
610
|
+
const target = `${url}${args.path}`;
|
|
611
|
+
const deadline = Date.now() + timeoutMs;
|
|
612
|
+
let lastErr = "";
|
|
613
|
+
for (;;) {
|
|
614
|
+
if (signal?.aborted) throw new Error(`sprite ${args.id} url wait aborted`);
|
|
615
|
+
try {
|
|
616
|
+
const check = await http("GET", target, undefined, undefined, signal);
|
|
617
|
+
const ok = args.status !== undefined ? check.status === args.status : check.status >= 200 && check.status < 300;
|
|
618
|
+
if (ok) {
|
|
619
|
+
console.log(`url: sprite/${args.id} answers on ${target} (${check.status})`);
|
|
620
|
+
return { url };
|
|
621
|
+
}
|
|
622
|
+
lastErr = `status ${check.status} (want ${args.status ?? "2xx"})`;
|
|
623
|
+
} catch (e) {
|
|
624
|
+
lastErr = e instanceof Error ? e.message : String(e);
|
|
625
|
+
}
|
|
626
|
+
if (Date.now() >= deadline) throw new Error(`sprite ${args.id} url ${target} did not answer: ${lastErr}`);
|
|
627
|
+
await sleep(intervalMs, signal);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return { url };
|
|
632
|
+
}
|