@intentius/chant-lexicon-fly 0.89.0 → 0.90.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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- 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/index.d.ts +4 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- 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 +20 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/skills/chant-fly-sprites.md +4 -2
- package/package.json +2 -2
- package/src/index.ts +9 -0
- package/src/op/activities/emulator-images.ts +14 -0
- package/src/op/activities/index.ts +34 -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 +28 -1
- package/src/skills/chant-fly-sprites.md +4 -2
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprite Services activities (#2711) — imperative create/get/list/start/stop/
|
|
3
|
+
* delete/logs over a Sprite's background services, the surface a box's door,
|
|
4
|
+
* hud and chud run as long-lived services through (`sprite-env services`
|
|
5
|
+
* inside the sprite, `/v1/sprites/{id}/services/...` outside). Shared by the
|
|
6
|
+
* Sprites API, wisp (arugula-salad/wisp) and spritzer 0.6.0's container mode
|
|
7
|
+
* (INTENTIUS/spritzer#22).
|
|
8
|
+
*
|
|
9
|
+
* This is the imperative twin of `sprite-config.ts`'s `spriteApplyServices` —
|
|
10
|
+
* that one reconciles a whole desired service *set* (additive create-or-update,
|
|
11
|
+
* optionally started, no delete). These activities are the single-service
|
|
12
|
+
* primitives underneath it: create-and-start one service and watch it come
|
|
13
|
+
* up, inspect or list what's running, stop/start one by name, delete it, and
|
|
14
|
+
* read its log tail. An Op that provisions a box one service at a time (or
|
|
15
|
+
* needs delete, which `spriteApplyServices` doesn't expose) uses these
|
|
16
|
+
* directly; `spriteApplyServices` stays the batch-reconcile convenience on
|
|
17
|
+
* top of the same wire shapes.
|
|
18
|
+
*
|
|
19
|
+
* `spriteServiceCreate` is a `PUT` that both defines the service and starts
|
|
20
|
+
* it, streaming NDJSON progress (`started`, `stdout`, `stderr`, `exit`,
|
|
21
|
+
* `complete`) for a bounded `?duration` window (default 500ms — enough to
|
|
22
|
+
* surface an immediate failure, e.g. a missing binary, without holding the
|
|
23
|
+
* step open for spritzer's own 5s default). The stream is read to completion
|
|
24
|
+
* either way; the definitive result comes from a follow-up `GET` of the
|
|
25
|
+
* service (`spriteServiceGet`'s own logic), which throws if its `state.status`
|
|
26
|
+
* is `"failed"`. `spriteServiceStart` follows the same create-then-GET shape
|
|
27
|
+
* (`PUT` already started it; `start` is for a stopped service).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { resolveSpritesEndpoint, defaultSpritesHttp, type SpritesHttp } from "./sprites";
|
|
31
|
+
|
|
32
|
+
// ── URL building (pure) ─────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const servicesUrl = (base: string, id: string): string => `${base}/v1/sprites/${encodeURIComponent(id)}/services`;
|
|
35
|
+
const serviceUrl = (base: string, id: string, name: string): string =>
|
|
36
|
+
`${servicesUrl(base, id)}/${encodeURIComponent(name)}`;
|
|
37
|
+
const serviceLogsUrl = (base: string, id: string, name: string): string => `${serviceUrl(base, id, name)}/logs`;
|
|
38
|
+
|
|
39
|
+
function safeJson(text: string): unknown {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(text);
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Shapes (wisp's guest API shapes, per spritzer's services.go) ───────────
|
|
48
|
+
|
|
49
|
+
/** A service's live half. */
|
|
50
|
+
export interface SpriteServiceState {
|
|
51
|
+
name: string;
|
|
52
|
+
/** `stopped` | `running` | `stopping` | `failed`. */
|
|
53
|
+
status: string;
|
|
54
|
+
pid?: number;
|
|
55
|
+
started_at?: string;
|
|
56
|
+
error?: string;
|
|
57
|
+
restart_count?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The `GET`/`PUT` response shape: the definition with its state beside it. */
|
|
61
|
+
export interface SpriteService {
|
|
62
|
+
name: string;
|
|
63
|
+
cmd: string;
|
|
64
|
+
args?: string[];
|
|
65
|
+
env?: Record<string, string>;
|
|
66
|
+
dir?: string;
|
|
67
|
+
/** Names of services that must be running first. */
|
|
68
|
+
needs?: string[];
|
|
69
|
+
/** Route the sprite's public URL to this port. Only one service may set it. */
|
|
70
|
+
http_port?: number;
|
|
71
|
+
state: SpriteServiceState;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SpriteServiceCreateArgs {
|
|
75
|
+
/** Target sprite id. */
|
|
76
|
+
id: string;
|
|
77
|
+
/** Service name — the key every later call addresses it by. */
|
|
78
|
+
name: string;
|
|
79
|
+
cmd: string;
|
|
80
|
+
args?: string[];
|
|
81
|
+
env?: Record<string, string>;
|
|
82
|
+
dir?: string;
|
|
83
|
+
needs?: string[];
|
|
84
|
+
http_port?: number;
|
|
85
|
+
/**
|
|
86
|
+
* How long spritzer keeps streaming NDJSON after the start action
|
|
87
|
+
* succeeds, ms (`?duration`). Default: `500`. A crash past this window is
|
|
88
|
+
* caught by a later `spriteServiceGet`/`spriteUrl`, not this call.
|
|
89
|
+
*/
|
|
90
|
+
durationMs?: number;
|
|
91
|
+
endpoint?: string;
|
|
92
|
+
token?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SpriteServiceGetArgs {
|
|
96
|
+
id: string;
|
|
97
|
+
name: string;
|
|
98
|
+
endpoint?: string;
|
|
99
|
+
token?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SpriteServiceListArgs {
|
|
103
|
+
id: string;
|
|
104
|
+
endpoint?: string;
|
|
105
|
+
token?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface SpriteServiceStartArgs {
|
|
109
|
+
id: string;
|
|
110
|
+
name: string;
|
|
111
|
+
/** Same as `spriteServiceCreate`'s `durationMs`. Default: `500`. */
|
|
112
|
+
durationMs?: number;
|
|
113
|
+
endpoint?: string;
|
|
114
|
+
token?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface SpriteServiceStopArgs {
|
|
118
|
+
id: string;
|
|
119
|
+
name: string;
|
|
120
|
+
/** Grace period before SIGKILL, ms (`?timeout`). Default: spritzer's own (10s). */
|
|
121
|
+
timeoutMs?: number;
|
|
122
|
+
endpoint?: string;
|
|
123
|
+
token?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface SpriteServiceDeleteArgs {
|
|
127
|
+
id: string;
|
|
128
|
+
name: string;
|
|
129
|
+
endpoint?: string;
|
|
130
|
+
token?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface SpriteServiceLogsArgs {
|
|
134
|
+
id: string;
|
|
135
|
+
name: string;
|
|
136
|
+
/** Tail length. Default: spritzer's own (100). */
|
|
137
|
+
lines?: number;
|
|
138
|
+
endpoint?: string;
|
|
139
|
+
token?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface SpriteServiceLogsResult {
|
|
143
|
+
/** The tail, one entry per log line, oldest first. */
|
|
144
|
+
lines: string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Build the `PUT` create-or-update body. Pure. */
|
|
148
|
+
export function spriteServiceCreateBody(args: SpriteServiceCreateArgs): Record<string, unknown> {
|
|
149
|
+
return {
|
|
150
|
+
cmd: args.cmd,
|
|
151
|
+
...(args.args !== undefined ? { args: args.args } : {}),
|
|
152
|
+
...(args.env !== undefined ? { env: args.env } : {}),
|
|
153
|
+
...(args.dir !== undefined ? { dir: args.dir } : {}),
|
|
154
|
+
...(args.needs !== undefined ? { needs: args.needs } : {}),
|
|
155
|
+
...(args.http_port !== undefined ? { http_port: args.http_port } : {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Parse a service log NDJSON body (`{type, data, timestamp, ...}` per line,
|
|
161
|
+
* terminated by a `complete` event) into a plain line tail — the `data` text
|
|
162
|
+
* of every non-`complete` event, in stream order. Pure.
|
|
163
|
+
*/
|
|
164
|
+
export function parseServiceLogNdjson(text: string): string[] {
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
for (const raw of text.split("\n")) {
|
|
167
|
+
const t = raw.trim();
|
|
168
|
+
if (!t) continue;
|
|
169
|
+
const obj = safeJson(t) as { type?: string; data?: string } | undefined;
|
|
170
|
+
if (!obj || obj.type === "complete") continue;
|
|
171
|
+
if (typeof obj.data === "string") lines.push(obj.data);
|
|
172
|
+
}
|
|
173
|
+
return lines;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Activities ───────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Create (or replace) and start a service. `PUT /v1/sprites/{id}/services/{name}`,
|
|
180
|
+
* streamed as NDJSON for `?duration` (mapped from `durationMs`). The stream is
|
|
181
|
+
* read to completion, then a `GET` fetches the definitive state; a `"failed"`
|
|
182
|
+
* status throws with the agent's own error text.
|
|
183
|
+
*/
|
|
184
|
+
export async function spriteServiceCreate(
|
|
185
|
+
args: SpriteServiceCreateArgs,
|
|
186
|
+
signal?: AbortSignal,
|
|
187
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
188
|
+
): Promise<SpriteService> {
|
|
189
|
+
const base = resolveSpritesEndpoint(args);
|
|
190
|
+
const durationMs = args.durationMs ?? 500;
|
|
191
|
+
const url = `${serviceUrl(base, args.id, args.name)}?duration=${durationMs}ms`;
|
|
192
|
+
const res = await http("PUT", url, spriteServiceCreateBody(args), undefined, signal);
|
|
193
|
+
if (res.status >= 300) {
|
|
194
|
+
throw new Error(`sprite ${args.id} service ${args.name} create failed (${res.status}): ${res.text}`);
|
|
195
|
+
}
|
|
196
|
+
const svc = await spriteServiceGet({ id: args.id, name: args.name, endpoint: args.endpoint, token: args.token }, signal, http);
|
|
197
|
+
if (svc.state.status === "failed") {
|
|
198
|
+
throw new Error(`sprite ${args.id} service ${args.name} failed to start: ${svc.state.error ?? "unknown error"}`);
|
|
199
|
+
}
|
|
200
|
+
console.log(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
|
|
201
|
+
return svc;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Get one service. `GET /v1/sprites/{id}/services/{name}`. */
|
|
205
|
+
export async function spriteServiceGet(
|
|
206
|
+
args: SpriteServiceGetArgs,
|
|
207
|
+
signal?: AbortSignal,
|
|
208
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
209
|
+
): Promise<SpriteService> {
|
|
210
|
+
const base = resolveSpritesEndpoint(args);
|
|
211
|
+
const res = await http("GET", serviceUrl(base, args.id, args.name), undefined, undefined, signal);
|
|
212
|
+
if (res.status >= 300) {
|
|
213
|
+
throw new Error(`sprite ${args.id} service ${args.name} get failed (${res.status}): ${res.text}`);
|
|
214
|
+
}
|
|
215
|
+
return (safeJson(res.text) as SpriteService | undefined) ?? ({ name: args.name, cmd: "", state: { name: args.name, status: "stopped" } } as SpriteService);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** List every service on the sprite. `GET /v1/sprites/{id}/services`. */
|
|
219
|
+
export async function spriteServiceList(
|
|
220
|
+
args: SpriteServiceListArgs,
|
|
221
|
+
signal?: AbortSignal,
|
|
222
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
223
|
+
): Promise<SpriteService[]> {
|
|
224
|
+
const base = resolveSpritesEndpoint(args);
|
|
225
|
+
const res = await http("GET", servicesUrl(base, args.id), undefined, undefined, signal);
|
|
226
|
+
if (res.status >= 300) throw new Error(`sprite ${args.id} service list failed (${res.status}): ${res.text}`);
|
|
227
|
+
const parsed = safeJson(res.text);
|
|
228
|
+
return Array.isArray(parsed) ? (parsed as SpriteService[]) : [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Start a stopped service. `POST /v1/sprites/{id}/services/{name}/start`,
|
|
233
|
+
* streamed as NDJSON for `?duration`, then a `GET` for the definitive state
|
|
234
|
+
* (same shape as `spriteServiceCreate`).
|
|
235
|
+
*/
|
|
236
|
+
export async function spriteServiceStart(
|
|
237
|
+
args: SpriteServiceStartArgs,
|
|
238
|
+
signal?: AbortSignal,
|
|
239
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
240
|
+
): Promise<SpriteService> {
|
|
241
|
+
const base = resolveSpritesEndpoint(args);
|
|
242
|
+
const durationMs = args.durationMs ?? 500;
|
|
243
|
+
const url = `${serviceUrl(base, args.id, args.name)}/start?duration=${durationMs}ms`;
|
|
244
|
+
const res = await http("POST", url, undefined, undefined, signal);
|
|
245
|
+
if (res.status >= 300) {
|
|
246
|
+
throw new Error(`sprite ${args.id} service ${args.name} start failed (${res.status}): ${res.text}`);
|
|
247
|
+
}
|
|
248
|
+
const svc = await spriteServiceGet({ id: args.id, name: args.name, endpoint: args.endpoint, token: args.token }, signal, http);
|
|
249
|
+
if (svc.state.status === "failed") {
|
|
250
|
+
throw new Error(`sprite ${args.id} service ${args.name} failed to start: ${svc.state.error ?? "unknown error"}`);
|
|
251
|
+
}
|
|
252
|
+
console.log(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
|
|
253
|
+
return svc;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Stop a running service (SIGTERM, then SIGKILL after the grace period).
|
|
258
|
+
* `POST /v1/sprites/{id}/services/{name}/stop`.
|
|
259
|
+
*/
|
|
260
|
+
export async function spriteServiceStop(
|
|
261
|
+
args: SpriteServiceStopArgs,
|
|
262
|
+
signal?: AbortSignal,
|
|
263
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
264
|
+
): Promise<SpriteService> {
|
|
265
|
+
const base = resolveSpritesEndpoint(args);
|
|
266
|
+
const qs = args.timeoutMs !== undefined ? `?timeout=${args.timeoutMs}ms` : "";
|
|
267
|
+
const url = `${serviceUrl(base, args.id, args.name)}/stop${qs}`;
|
|
268
|
+
const res = await http("POST", url, undefined, undefined, signal);
|
|
269
|
+
if (res.status >= 300) {
|
|
270
|
+
throw new Error(`sprite ${args.id} service ${args.name} stop failed (${res.status}): ${res.text}`);
|
|
271
|
+
}
|
|
272
|
+
const svc = await spriteServiceGet({ id: args.id, name: args.name, endpoint: args.endpoint, token: args.token }, signal, http);
|
|
273
|
+
console.log(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
|
|
274
|
+
return svc;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Delete a service, stopping it first (idempotent; a 404 means it is already
|
|
279
|
+
* gone). `DELETE /v1/sprites/{id}/services/{name}`.
|
|
280
|
+
*/
|
|
281
|
+
export async function spriteServiceDelete(
|
|
282
|
+
args: SpriteServiceDeleteArgs,
|
|
283
|
+
signal?: AbortSignal,
|
|
284
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
285
|
+
): Promise<Record<string, never>> {
|
|
286
|
+
const base = resolveSpritesEndpoint(args);
|
|
287
|
+
const res = await http("DELETE", serviceUrl(base, args.id, args.name), undefined, undefined, signal);
|
|
288
|
+
if (res.status >= 300 && res.status !== 404) {
|
|
289
|
+
throw new Error(`sprite ${args.id} service ${args.name} delete failed (${res.status}): ${res.text}`);
|
|
290
|
+
}
|
|
291
|
+
console.log(`deleted: sprite/${args.id}/${args.name}`);
|
|
292
|
+
return {};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Read a service's log tail. `GET /v1/sprites/{id}/services/{name}/logs`,
|
|
297
|
+
* parsed from NDJSON to a plain line array (`parseServiceLogNdjson`).
|
|
298
|
+
*/
|
|
299
|
+
export async function spriteServiceLogs(
|
|
300
|
+
args: SpriteServiceLogsArgs,
|
|
301
|
+
signal?: AbortSignal,
|
|
302
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
303
|
+
): Promise<SpriteServiceLogsResult> {
|
|
304
|
+
const base = resolveSpritesEndpoint(args);
|
|
305
|
+
const qs = args.lines !== undefined ? `?lines=${args.lines}` : "";
|
|
306
|
+
const res = await http("GET", `${serviceLogsUrl(base, args.id, args.name)}${qs}`, undefined, undefined, signal);
|
|
307
|
+
if (res.status >= 300) {
|
|
308
|
+
throw new Error(`sprite ${args.id} service ${args.name} logs failed (${res.status}): ${res.text}`);
|
|
309
|
+
}
|
|
310
|
+
return { lines: parseServiceLogNdjson(res.text) };
|
|
311
|
+
}
|
|
@@ -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)\/?$/);
|