@intentius/chant-lexicon-fly 0.90.0 → 0.93.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/README.md +27 -0
- 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 +195 -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/composites/catalog.d.ts.map +1 -1
- package/dist/composites/fly-site.d.ts +86 -0
- package/dist/composites/fly-site.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +11 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/index.d.ts +5 -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/fly-apply.d.ts +8 -22
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/fly-release-step.d.ts +24 -0
- package/dist/op/activities/fly-release-step.d.ts.map +1 -0
- package/dist/op/activities/fly-rollback-step.d.ts +25 -0
- package/dist/op/activities/fly-rollback-step.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +8 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machine-release.d.ts +192 -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 +55 -0
- package/dist/op/activities/machines-fake.d.ts.map +1 -0
- package/dist/op/activities/machines-local-cli.d.ts +20 -0
- package/dist/op/activities/machines-local-cli.d.ts.map +1 -0
- package/dist/op/activities/machines-local.d.ts +107 -0
- package/dist/op/activities/machines-local.d.ts.map +1 -0
- package/dist/op/activities/sprite-fs.d.ts +12 -3
- package/dist/op/activities/sprite-fs.d.ts.map +1 -1
- package/dist/op/activities/sprite-service-converge.d.ts +107 -0
- package/dist/op/activities/sprite-service-converge.d.ts.map +1 -0
- package/dist/op/activities/sprites-fake.d.ts +18 -1
- package/dist/op/activities/sprites-fake.d.ts.map +1 -1
- package/dist/op/activities/sprites.d.ts +29 -9
- package/dist/op/activities/sprites.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +62 -0
- package/dist/op/activity-contracts.d.ts.map +1 -0
- package/dist/op/builders.d.ts +45 -0
- 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 +100 -0
- package/dist/skills/chant-fly-sprites.md +2 -1
- package/package.json +11 -4
- package/src/components/capability-plugin.ts +11 -3
- package/src/components/fly-release.test.ts +378 -0
- package/src/components/fly-release.ts +458 -0
- package/src/components/index.ts +16 -0
- package/src/composites/catalog.test.ts +2 -1
- package/src/composites/catalog.ts +98 -0
- package/src/composites/fly-site.test.ts +66 -0
- package/src/composites/fly-site.ts +140 -0
- package/src/describe-resources.test.ts +18 -3
- package/src/describe-resources.ts +69 -5
- package/src/index.ts +20 -0
- package/src/op/activities/fly-apply.ts +15 -3
- package/src/op/activities/fly-release-step.ts +32 -0
- package/src/op/activities/fly-rollback-step.ts +33 -0
- package/src/op/activities/index.ts +48 -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 +429 -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 +167 -0
- package/src/op/activities/machines-local-cli.ts +73 -0
- package/src/op/activities/machines-local.test.ts +225 -0
- package/src/op/activities/machines-local.ts +437 -0
- package/src/op/activities/sprite-exec-options.docker.integration.test.ts +70 -0
- package/src/op/activities/sprite-fs.test.ts +55 -0
- package/src/op/activities/sprite-fs.ts +14 -5
- package/src/op/activities/sprite-service-converge.test.ts +268 -0
- package/src/op/activities/sprite-service-converge.ts +268 -0
- package/src/op/activities/sprites-fake.ts +51 -8
- package/src/op/activities/sprites.integration.test.ts +30 -0
- package/src/op/activities/sprites.test.ts +20 -0
- package/src/op/activities/sprites.ts +50 -10
- package/src/op/activity-contracts.test.ts +71 -0
- package/src/op/activity-contracts.ts +67 -0
- package/src/op/builders.ts +57 -0
- package/src/release-metadata.ts +72 -0
- package/src/release-store.ts +70 -0
- package/src/skills/chant-fly-ops.md +100 -0
- package/src/skills/chant-fly-sprites.md +2 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2778 — a box's services as resources a ConvergeOp observes and converges.
|
|
3
|
+
*
|
|
4
|
+
* `sprite-env` here is a stand-in with the command line a sprite's has (and
|
|
5
|
+
* the studio kit's has): `services list` prints `name<TAB>state`, and
|
|
6
|
+
* `restart`/`stop`/`start` flip the state kept in a directory. A small HTTP
|
|
7
|
+
* server answers each service's health URL with 200 while it runs. `chant`
|
|
8
|
+
* on PATH stands in for `chant run <op>`: it runs what the dispatched
|
|
9
|
+
* `restart-service` Op would, `sprite-env services restart
|
|
10
|
+
* "$CHANT_CONVERGE_RESOURCE"`, and records the call.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { createServer, type Server } from "node:http";
|
|
16
|
+
import type { AddressInfo } from "node:net";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
20
|
+
import { ConvergeOp, declareSteward, eq, loadActivities, run, runOpLocally, when, type ActivityFn, type OpConfig, type ResourceSymptom } from "@intentius/chant/op";
|
|
21
|
+
import { convergeTick } from "@intentius/chant/op/activities";
|
|
22
|
+
import { readConvergeLedger } from "@intentius/chant/lifecycle/converge-ledger";
|
|
23
|
+
import { readMemberStewards } from "@intentius/chant/workspace/status-stewards";
|
|
24
|
+
import { createSpritesFake } from "./sprites-fake";
|
|
25
|
+
import { spriteCreate } from "./sprites";
|
|
26
|
+
import { spriteServiceCreate, spriteServiceStop } from "./sprite-services";
|
|
27
|
+
import { parseServicesList, spriteServiceRestart, spriteServicesObserve } from "./sprite-service-converge";
|
|
28
|
+
import { spriteServicesObserve as observeStep } from "../builders";
|
|
29
|
+
|
|
30
|
+
let dir: string;
|
|
31
|
+
let bin: string;
|
|
32
|
+
let state: string;
|
|
33
|
+
let server: Server;
|
|
34
|
+
let port = 0;
|
|
35
|
+
const saved = { PATH: process.env.PATH, cwd: process.cwd(), STATE: process.env.FAKE_SPRITE_STATE, RESOURCE: process.env.CHANT_CONVERGE_RESOURCE };
|
|
36
|
+
|
|
37
|
+
const SPRITE_ENV = `#!/usr/bin/env node
|
|
38
|
+
const { readdirSync, readFileSync, writeFileSync, existsSync } = require("node:fs");
|
|
39
|
+
const { join } = require("node:path");
|
|
40
|
+
const dir = process.env.FAKE_SPRITE_STATE;
|
|
41
|
+
const [group, verb, name] = process.argv.slice(2);
|
|
42
|
+
if (group !== "services") process.exit(2);
|
|
43
|
+
const file = (n) => join(dir, n);
|
|
44
|
+
if (verb === "list") {
|
|
45
|
+
for (const n of readdirSync(dir).sort()) console.log(n + "\\t" + readFileSync(file(n), "utf8").trim() + "\\t-\\t-\\tcmd");
|
|
46
|
+
} else if (["restart", "start", "stop"].includes(verb)) {
|
|
47
|
+
if (!existsSync(file(name))) { console.error("sprite-env: no service " + name); process.exit(1); }
|
|
48
|
+
writeFileSync(file(name), verb === "stop" ? "stopped" : "running");
|
|
49
|
+
} else process.exit(2);
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const CHANT = `#!/bin/sh
|
|
53
|
+
# chant run <op> --json, as far as a converge dispatch goes.
|
|
54
|
+
echo "$2 $CHANT_CONVERGE_RESOURCE" >> "$FAKE_SPRITE_STATE/../dispatched"
|
|
55
|
+
exec sprite-env services restart "$CHANT_CONVERGE_RESOURCE"
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
function git(...args: string[]): void {
|
|
59
|
+
const r = spawnSync("git", args, { cwd: dir, encoding: "utf8" });
|
|
60
|
+
if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const health = (name: string) => `http://127.0.0.1:${port}/${name}`;
|
|
64
|
+
const setState = (name: string, value: string) => writeFileSync(join(state, name), value);
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
dir = mkdtempSync(join(tmpdir(), "chant-sprite-converge-"));
|
|
68
|
+
bin = join(dir, ".bin");
|
|
69
|
+
state = join(dir, ".state", "services");
|
|
70
|
+
mkdirSync(bin, { recursive: true });
|
|
71
|
+
mkdirSync(state, { recursive: true });
|
|
72
|
+
writeFileSync(join(bin, "sprite-env"), SPRITE_ENV);
|
|
73
|
+
writeFileSync(join(bin, "chant"), CHANT);
|
|
74
|
+
chmodSync(join(bin, "sprite-env"), 0o755);
|
|
75
|
+
chmodSync(join(bin, "chant"), 0o755);
|
|
76
|
+
server = createServer((req, res) => {
|
|
77
|
+
const name = (req.url ?? "/").slice(1);
|
|
78
|
+
const up = existsSync(join(state, name)) && readFileSync(join(state, name), "utf8").trim() === "running";
|
|
79
|
+
res.writeHead(up ? 200 : 503).end(up ? "ok" : "down");
|
|
80
|
+
});
|
|
81
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
82
|
+
port = (server.address() as AddressInfo).port;
|
|
83
|
+
process.env.PATH = `${bin}:${process.env.PATH}`;
|
|
84
|
+
process.env.FAKE_SPRITE_STATE = state;
|
|
85
|
+
git("init", "-q", "-b", "main");
|
|
86
|
+
git("config", "user.email", "converge@example.com");
|
|
87
|
+
git("config", "user.name", "Converge Test");
|
|
88
|
+
git("config", "commit.gpgsign", "false");
|
|
89
|
+
writeFileSync(join(dir, "chant.config.json"), "{}\n");
|
|
90
|
+
writeFileSync(join(dir, ".gitignore"), ".bin\n.state\n");
|
|
91
|
+
git("add", "-A");
|
|
92
|
+
git("commit", "-q", "-m", "c0");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterAll(async () => {
|
|
96
|
+
process.chdir(saved.cwd);
|
|
97
|
+
process.env.PATH = saved.PATH;
|
|
98
|
+
if (saved.STATE === undefined) delete process.env.FAKE_SPRITE_STATE;
|
|
99
|
+
else process.env.FAKE_SPRITE_STATE = saved.STATE;
|
|
100
|
+
if (saved.RESOURCE === undefined) delete process.env.CHANT_CONVERGE_RESOURCE;
|
|
101
|
+
else process.env.CHANT_CONVERGE_RESOURCE = saved.RESOURCE;
|
|
102
|
+
await new Promise<void>((r) => server.close(() => r()));
|
|
103
|
+
rmSync(dir, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("parseServicesList", () => {
|
|
107
|
+
test("reads a sprite-env's JSON and a stand-in's table", () => {
|
|
108
|
+
expect(parseServicesList(JSON.stringify([{ name: "app", cmd: "x", state: { name: "app", status: "running" } }, { name: "hud", status: "stopped" }]))).toEqual(
|
|
109
|
+
new Map([["app", "running"], ["hud", "stopped"]]),
|
|
110
|
+
);
|
|
111
|
+
expect(parseServicesList("app\trunning\t-\t-\tnode a\nsite\tstarting\t-\t-\tnode s\n")).toEqual(new Map([["app", "running"], ["site", "starting"]]));
|
|
112
|
+
expect(parseServicesList("")).toEqual(new Map());
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("spriteServicesObserve through sprite-env (#2778)", () => {
|
|
117
|
+
test("in sync, stopped, not answering, not defined, optional, and a supervisor that can't be read", async () => {
|
|
118
|
+
setState("app", "running");
|
|
119
|
+
setState("hud", "stopped");
|
|
120
|
+
setState("door", "running");
|
|
121
|
+
const services = [
|
|
122
|
+
{ name: "app", health: health("app") },
|
|
123
|
+
{ name: "hud", health: health("hud") },
|
|
124
|
+
{ name: "door", health: `http://127.0.0.1:${port}/nothing-here` },
|
|
125
|
+
{ name: "extra" },
|
|
126
|
+
{ name: "site", health: health("site"), optional: true },
|
|
127
|
+
];
|
|
128
|
+
const seen = await spriteServicesObserve({ services, probes: 2, probeIntervalMs: 10 });
|
|
129
|
+
expect(seen.skipped).toEqual(["site"]);
|
|
130
|
+
expect(seen.resources.map((r) => [r.name, r.status])).toEqual([
|
|
131
|
+
["app", "in-sync"],
|
|
132
|
+
["hud", "drifted"],
|
|
133
|
+
["door", "drifted"],
|
|
134
|
+
["extra", "drifted"],
|
|
135
|
+
]);
|
|
136
|
+
expect(seen.resources[2].detail).toMatch(/does not answer 200 after 2 tries/);
|
|
137
|
+
expect(seen.resources[3].detail).toMatch(/no such service/);
|
|
138
|
+
|
|
139
|
+
const unread = await spriteServicesObserve({ services: [{ name: "app" }], spriteEnv: join(dir, "missing-sprite-env") });
|
|
140
|
+
expect(unread.resources).toEqual([{ name: "app", status: "unknown", detail: expect.stringMatching(/could not be read/) }]);
|
|
141
|
+
|
|
142
|
+
// The declared list can live in a file the box already has.
|
|
143
|
+
writeFileSync(join(dir, "services.json"), JSON.stringify({ services: [{ name: "app", health: health("app") }] }));
|
|
144
|
+
const fromFile = await spriteServicesObserve({ servicesFile: join(dir, "services.json"), probes: 1 });
|
|
145
|
+
expect(fromFile.resources).toEqual([{ name: "app", status: "in-sync", detail: expect.stringContaining("answers 200") }]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("spriteServiceRestart restarts the resource a converge rule dispatched it for, and waits for its health", async () => {
|
|
149
|
+
setState("hud", "stopped");
|
|
150
|
+
process.env.CHANT_CONVERGE_RESOURCE = "hud";
|
|
151
|
+
try {
|
|
152
|
+
await expect(spriteServiceRestart({ services: [{ name: "hud", health: health("hud") }], waitMs: 2000 })).resolves.toEqual({ name: "hud", healthy: true });
|
|
153
|
+
} finally {
|
|
154
|
+
delete process.env.CHANT_CONVERGE_RESOURCE;
|
|
155
|
+
}
|
|
156
|
+
expect(readFileSync(join(state, "hud"), "utf8")).toBe("running");
|
|
157
|
+
await expect(spriteServiceRestart({})).rejects.toThrow(/CHANT_CONVERGE_RESOURCE/);
|
|
158
|
+
await expect(spriteServiceRestart({ name: "nope" })).rejects.toThrow(/no service nope/);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("spriteServicesObserve through the Sprites API (#2778)", () => {
|
|
163
|
+
test("reads each service's state, and restarts one with stop and start", async () => {
|
|
164
|
+
const fake = await createSpritesFake();
|
|
165
|
+
try {
|
|
166
|
+
await spriteCreate({ name: "box-1", endpoint: fake.url });
|
|
167
|
+
await spriteServiceCreate({ id: "box-1", name: "app", cmd: "node app.js", endpoint: fake.url });
|
|
168
|
+
await spriteServiceCreate({ id: "box-1", name: "hud", cmd: "node hud.js", endpoint: fake.url });
|
|
169
|
+
await spriteServiceStop({ id: "box-1", name: "hud", endpoint: fake.url });
|
|
170
|
+
const seen = await spriteServicesObserve({ id: "box-1", endpoint: fake.url, services: [{ name: "app" }, { name: "hud" }] });
|
|
171
|
+
expect(seen.resources.map((r) => [r.name, r.status])).toEqual([["app", "in-sync"], ["hud", "drifted"]]);
|
|
172
|
+
await spriteServiceRestart({ id: "box-1", endpoint: fake.url, name: "hud" });
|
|
173
|
+
const after = await spriteServicesObserve({ id: "box-1", endpoint: fake.url, services: [{ name: "app" }, { name: "hud" }] });
|
|
174
|
+
expect(after.resources.every((r) => r.status === "in-sync")).toBe(true);
|
|
175
|
+
} finally {
|
|
176
|
+
await fake.close();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("a ConvergeOp over a box's services (#2778)", () => {
|
|
182
|
+
test("records the stopped service drifted, dispatches the restart its rule names, and records the next tick in sync", async () => {
|
|
183
|
+
const activities = await loadActivities(["fly"]);
|
|
184
|
+
expect(activities.has("spriteServicesObserve")).toBe(true);
|
|
185
|
+
expect(activities.has("spriteServiceRestart")).toBe(true);
|
|
186
|
+
const run$ = new Map<string, ActivityFn>([...activities, ["convergeTick", convergeTick as unknown as ActivityFn]]);
|
|
187
|
+
|
|
188
|
+
setState("app", "running");
|
|
189
|
+
setState("hud", "running");
|
|
190
|
+
rmSync(join(state, "door"), { force: true });
|
|
191
|
+
rmSync(join(state, "extra"), { force: true });
|
|
192
|
+
const services = [
|
|
193
|
+
{ name: "app", health: health("app") },
|
|
194
|
+
{ name: "hud", health: health("hud") },
|
|
195
|
+
];
|
|
196
|
+
const { op } = ConvergeOp({
|
|
197
|
+
name: "box-converge",
|
|
198
|
+
env: "box",
|
|
199
|
+
dial: "apply",
|
|
200
|
+
observe: observeStep({ services, probes: 1 }),
|
|
201
|
+
rules: [
|
|
202
|
+
when<ResourceSymptom>(eq("status", "drifted"), run("restart-service"), {
|
|
203
|
+
id: "restart-drifted",
|
|
204
|
+
why: "A declared service that stopped or stopped answering is restarted through its supervisor.",
|
|
205
|
+
}),
|
|
206
|
+
when<ResourceSymptom>(eq("status", "unknown"), { kind: "report", reason: "a service could not be observed" }, {
|
|
207
|
+
id: "report-unknown",
|
|
208
|
+
why: "unknown never remediates.",
|
|
209
|
+
}),
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
const config = (op as unknown as { props: OpConfig }).props;
|
|
213
|
+
|
|
214
|
+
// Stop one of the two.
|
|
215
|
+
spawnSync("sprite-env", ["services", "stop", "app"], { env: process.env });
|
|
216
|
+
const steward = declareSteward({ name: "box-steward", ops: [config] });
|
|
217
|
+
mkdirSync(join(dir, "ops"), { recursive: true });
|
|
218
|
+
writeFileSync(join(dir, "ops", "steward.op.ts"), `export const steward = ${JSON.stringify(steward)};\n`);
|
|
219
|
+
const lastTick = async () => {
|
|
220
|
+
const status = await readMemberStewards(dir, "local", new Date().toISOString());
|
|
221
|
+
expect(status.reasons).toEqual([]);
|
|
222
|
+
return status.stewards[0].ops[0];
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
process.chdir(dir);
|
|
226
|
+
try {
|
|
227
|
+
const first = await runOpLocally(config, run$, {}, undefined, { cwd: dir, ledger: { cwd: dir } });
|
|
228
|
+
expect(first.status).toBe("ok");
|
|
229
|
+
// workspace status shows the converge Op's last tick, with the rule that fired and for which service.
|
|
230
|
+
const afterFirst = await lastTick();
|
|
231
|
+
expect(afterFirst.lastRun).toMatchObject({ status: "ok" });
|
|
232
|
+
expect(afterFirst.lastTick).toMatchObject({
|
|
233
|
+
firedRuleIds: ["restart-drifted"],
|
|
234
|
+
outcomes: [{ ruleId: "restart-drifted", action: "ran", op: "restart-service", resource: "app", reason: null }],
|
|
235
|
+
resources: [{ name: "app", status: "drifted", detail: "stopped" }, { name: "hud", status: "in-sync" }],
|
|
236
|
+
});
|
|
237
|
+
const second = await runOpLocally(config, run$, {}, undefined, { cwd: dir, ledger: { cwd: dir } });
|
|
238
|
+
expect(second.status).toBe("ok");
|
|
239
|
+
} finally {
|
|
240
|
+
process.chdir(saved.cwd);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
expect(readFileSync(join(dir, ".state", "dispatched"), "utf8").trim().split("\n")).toEqual(["restart-service app"]);
|
|
244
|
+
const ticks = (await readConvergeLedger("box", { cwd: dir })).records;
|
|
245
|
+
expect(ticks).toHaveLength(2);
|
|
246
|
+
expect(ticks[0]).toMatchObject({
|
|
247
|
+
op: "box-converge",
|
|
248
|
+
firedRuleIds: ["restart-drifted"],
|
|
249
|
+
outcomes: [{ ruleId: "restart-drifted", action: "ran", op: "restart-service", resource: "app" }],
|
|
250
|
+
resources: [
|
|
251
|
+
{ name: "app", status: "drifted" },
|
|
252
|
+
{ name: "hud", status: "in-sync" },
|
|
253
|
+
],
|
|
254
|
+
summary: { drifted: 1, remediated: 1 },
|
|
255
|
+
});
|
|
256
|
+
expect(ticks[1]).toMatchObject({
|
|
257
|
+
firedRuleIds: [],
|
|
258
|
+
outcomes: [],
|
|
259
|
+
resources: [
|
|
260
|
+
{ name: "app", status: "in-sync" },
|
|
261
|
+
{ name: "hud", status: "in-sync" },
|
|
262
|
+
],
|
|
263
|
+
summary: { drifted: 0, remediated: 0 },
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect((await lastTick()).lastTick).toMatchObject({ firedRuleIds: [], outcomes: [], resources: [{ name: "app", status: "in-sync" }, { name: "hud", status: "in-sync" }] });
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A sprite's services as resources a `ConvergeOp` observes and converges
|
|
3
|
+
* (#2778).
|
|
4
|
+
*
|
|
5
|
+
* A box runs its app, hud, door and site as services a supervisor keeps
|
|
6
|
+
* running: sprite-env on a sprite, and a stand-in with the same command line
|
|
7
|
+
* elsewhere. No lexicon declares them, so a ConvergeOp can't observe them
|
|
8
|
+
* through an environment. It can through an observer step:
|
|
9
|
+
*
|
|
10
|
+
* - `spriteServicesObserve` reads the supervisor's list and probes each
|
|
11
|
+
* declared service's health URL, and returns one verdict per service,
|
|
12
|
+
* `in-sync`, `drifted` or `unknown`: the `ResourceObservation` a
|
|
13
|
+
* `ConvergeOp({ observe })` evaluates its rules against, once per service.
|
|
14
|
+
* - `spriteServiceRestart` restarts one service through the supervisor and
|
|
15
|
+
* waits for its health URL. It is the step of the Op a rule dispatches; the
|
|
16
|
+
* service is the one the rule fired for, which the dispatched run reads
|
|
17
|
+
* from `CHANT_CONVERGE_RESOURCE`.
|
|
18
|
+
*
|
|
19
|
+
* Both work in one of two ways. With no sprite `id`, they run inside the
|
|
20
|
+
* sprite and call `sprite-env services` (found on PATH or in /.sprite/bin,
|
|
21
|
+
* or at `spriteEnv`). With an `id`, they go through the Sprites API
|
|
22
|
+
* (`spriteServiceList`, `spriteServiceStop` and `spriteServiceStart`).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execFile } from "node:child_process";
|
|
26
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { join, resolve } from "node:path";
|
|
28
|
+
import { promisify } from "node:util";
|
|
29
|
+
import { spriteServiceList, spriteServiceStart, spriteServiceStop } from "./sprite-services";
|
|
30
|
+
import type { SpritesHttp } from "./sprites";
|
|
31
|
+
import { defaultSpritesHttp } from "./sprites";
|
|
32
|
+
|
|
33
|
+
const execFileAsync = promisify(execFile);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The variable a ConvergeOp's dispatched run reads its resource from
|
|
37
|
+
* (core's `CONVERGE_RESOURCE_ENV`). Spelled out here so the lexicon runs
|
|
38
|
+
* against a core that predates the export.
|
|
39
|
+
*/
|
|
40
|
+
const CONVERGE_RESOURCE_ENV = "CHANT_CONVERGE_RESOURCE";
|
|
41
|
+
|
|
42
|
+
/** One service a box declares. */
|
|
43
|
+
export interface DeclaredSpriteService {
|
|
44
|
+
name: string;
|
|
45
|
+
/** A URL that answers 200 while the service works. Without one, the supervisor's state decides. */
|
|
46
|
+
health?: string;
|
|
47
|
+
/** Not defined yet is not drift: the service is left out until it exists (a site before its first release). */
|
|
48
|
+
optional?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Via {
|
|
52
|
+
/** The sprite, for the Sprites API. Without it, `sprite-env` inside the sprite. */
|
|
53
|
+
id?: string;
|
|
54
|
+
/** The `sprite-env` binary. Default: the one on PATH, else /.sprite/bin/sprite-env. */
|
|
55
|
+
spriteEnv?: string;
|
|
56
|
+
endpoint?: string;
|
|
57
|
+
token?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SpriteServicesObserveArgs extends Via {
|
|
61
|
+
/** The declared services. */
|
|
62
|
+
services?: DeclaredSpriteService[];
|
|
63
|
+
/** A JSON file of `{ services: [...] }`, relative to the working directory, read on each tick. */
|
|
64
|
+
servicesFile?: string;
|
|
65
|
+
/** Health probes before a service counts as drifted. Default 3. */
|
|
66
|
+
probes?: number;
|
|
67
|
+
/** Time between probes, ms. Default 1000. */
|
|
68
|
+
probeIntervalMs?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SpriteServicesObserveResult {
|
|
72
|
+
resources: { name: string; status: "in-sync" | "drifted" | "unknown"; detail: string }[];
|
|
73
|
+
/** Optional services that aren't defined yet. */
|
|
74
|
+
skipped: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SpriteServiceRestartArgs extends Via {
|
|
78
|
+
/** The service. Default: the resource a ConvergeOp dispatched this run for (`CHANT_CONVERGE_RESOURCE`). */
|
|
79
|
+
name?: string;
|
|
80
|
+
/** Its health URL. Default: the one `services` or `servicesFile` declares for it. */
|
|
81
|
+
health?: string;
|
|
82
|
+
services?: DeclaredSpriteService[];
|
|
83
|
+
servicesFile?: string;
|
|
84
|
+
/** How long to wait for the health URL after the restart, ms. Default 60000. */
|
|
85
|
+
waitMs?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SpriteServiceRestartResult {
|
|
89
|
+
name: string;
|
|
90
|
+
/** Whether its health URL answered, or null when it declares none. */
|
|
91
|
+
healthy: boolean | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
95
|
+
|
|
96
|
+
/** The services declared inline and in `servicesFile`, inline first. */
|
|
97
|
+
export function declaredServices(args: { services?: DeclaredSpriteService[]; servicesFile?: string }, cwd = process.cwd()): DeclaredSpriteService[] {
|
|
98
|
+
const out = [...(args.services ?? [])];
|
|
99
|
+
if (args.servicesFile) {
|
|
100
|
+
const parsed = JSON.parse(readFileSync(resolve(cwd, args.servicesFile), "utf8")) as { services?: unknown };
|
|
101
|
+
if (!Array.isArray(parsed.services)) throw new Error(`${args.servicesFile} has no "services" array`);
|
|
102
|
+
for (const s of parsed.services as DeclaredSpriteService[]) {
|
|
103
|
+
if (!s || typeof s.name !== "string") throw new Error(`${args.servicesFile}: a service has no name`);
|
|
104
|
+
out.push(s);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The sprite-env binary, or null when there is none. */
|
|
111
|
+
export function findSpriteEnv(explicit?: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
112
|
+
if (explicit) return explicit;
|
|
113
|
+
for (const dir of (env.PATH ?? "").split(":").concat("/.sprite/bin")) {
|
|
114
|
+
if (dir && existsSync(join(dir, "sprite-env"))) return join(dir, "sprite-env");
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `sprite-env services list` as name to state. A sprite's sprite-env prints
|
|
121
|
+
* JSON (an array of services with `state.status`); a stand-in may print a
|
|
122
|
+
* tab-separated table whose second column is the state. Pure.
|
|
123
|
+
*/
|
|
124
|
+
export function parseServicesList(text: string): Map<string, string | null> {
|
|
125
|
+
const out = new Map<string, string | null>();
|
|
126
|
+
const trimmed = text.trim();
|
|
127
|
+
if (!trimmed) return out;
|
|
128
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
129
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
130
|
+
const list = Array.isArray(parsed) ? parsed : ((parsed as { services?: unknown }).services ?? []);
|
|
131
|
+
for (const s of list as { name?: unknown; status?: unknown; state?: { status?: unknown } }[]) {
|
|
132
|
+
if (typeof s?.name !== "string") continue;
|
|
133
|
+
const status = typeof s.state?.status === "string" ? s.state.status : typeof s.status === "string" ? s.status : null;
|
|
134
|
+
out.set(s.name, status);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
for (const line of trimmed.split("\n")) {
|
|
139
|
+
const cols = line.split("\t");
|
|
140
|
+
if (!cols[0]) continue;
|
|
141
|
+
out.set(cols[0].trim(), cols[1]?.trim() || null);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function listServices(via: Via, signal?: AbortSignal, http?: SpritesHttp): Promise<Map<string, string | null>> {
|
|
147
|
+
if (via.id) {
|
|
148
|
+
const list = await spriteServiceList({ id: via.id, endpoint: via.endpoint, token: via.token }, signal, http ?? defaultSpritesHttp(via.token));
|
|
149
|
+
return new Map(list.map((s) => [s.name, s.state?.status ?? null]));
|
|
150
|
+
}
|
|
151
|
+
const bin = findSpriteEnv(via.spriteEnv);
|
|
152
|
+
if (!bin) throw new Error("no sprite-env on PATH or in /.sprite/bin, and no sprite id to reach the Sprites API with");
|
|
153
|
+
const { stdout } = await execFileAsync(bin, ["services", "list"], { signal, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
|
|
154
|
+
return parseServicesList(stdout);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One GET of a health URL: true on 200. */
|
|
158
|
+
export async function probeHealth(url: string, signal?: AbortSignal): Promise<boolean> {
|
|
159
|
+
try {
|
|
160
|
+
const timeout = AbortSignal.timeout(3000);
|
|
161
|
+
const res = await fetch(url, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout, redirect: "manual" });
|
|
162
|
+
await res.arrayBuffer().catch(() => undefined);
|
|
163
|
+
return res.status === 200;
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const DOWN = new Set(["stopped", "failed", "stopping"]);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Observe the declared services: one verdict each, for a ConvergeOp's rules.
|
|
173
|
+
* A service the supervisor doesn't have, or has stopped, is drifted; one
|
|
174
|
+
* with a health URL is in sync when the URL answers 200 within `probes`
|
|
175
|
+
* tries and drifted when it doesn't; one without is in sync while it runs.
|
|
176
|
+
* When the supervisor can't be read, every service is unknown, and unknown
|
|
177
|
+
* never remediates.
|
|
178
|
+
*/
|
|
179
|
+
export async function spriteServicesObserve(
|
|
180
|
+
args: SpriteServicesObserveArgs,
|
|
181
|
+
signal?: AbortSignal,
|
|
182
|
+
http?: SpritesHttp,
|
|
183
|
+
): Promise<SpriteServicesObserveResult> {
|
|
184
|
+
const declared = declaredServices(args);
|
|
185
|
+
const probes = Math.max(1, args.probes ?? 3);
|
|
186
|
+
const interval = args.probeIntervalMs ?? 1000;
|
|
187
|
+
let states: Map<string, string | null>;
|
|
188
|
+
try {
|
|
189
|
+
states = await listServices(args, signal, http);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
const why = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
192
|
+
return {
|
|
193
|
+
resources: declared.filter((s) => !s.optional).map((s) => ({ name: s.name, status: "unknown" as const, detail: `the supervisor could not be read: ${why}` })),
|
|
194
|
+
skipped: declared.filter((s) => s.optional).map((s) => s.name),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const resources: SpriteServicesObserveResult["resources"] = [];
|
|
199
|
+
const skipped: string[] = [];
|
|
200
|
+
for (const service of declared) {
|
|
201
|
+
if (!states.has(service.name)) {
|
|
202
|
+
if (service.optional) skipped.push(service.name);
|
|
203
|
+
else resources.push({ name: service.name, status: "drifted", detail: "declared, but the supervisor has no such service" });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const state = states.get(service.name) ?? null;
|
|
207
|
+
if (state !== null && DOWN.has(state)) {
|
|
208
|
+
resources.push({ name: service.name, status: "drifted", detail: state });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (service.health) {
|
|
212
|
+
let ok = false;
|
|
213
|
+
for (let i = 0; i < probes && !ok; i++) {
|
|
214
|
+
if (i > 0) await sleep(interval);
|
|
215
|
+
ok = await probeHealth(service.health, signal);
|
|
216
|
+
}
|
|
217
|
+
resources.push(
|
|
218
|
+
ok
|
|
219
|
+
? { name: service.name, status: "in-sync", detail: `${state ?? "listed"}, ${service.health} answers 200` }
|
|
220
|
+
: { name: service.name, status: "drifted", detail: `${state ?? "listed"}, ${service.health} does not answer 200 after ${probes} tries` },
|
|
221
|
+
);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
resources.push(
|
|
225
|
+
state === "running"
|
|
226
|
+
? { name: service.name, status: "in-sync", detail: "running" }
|
|
227
|
+
: { name: service.name, status: "unknown", detail: `${state ?? "no state"}, and no health URL to probe` },
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return { resources, skipped };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Restart one service through its supervisor and wait for its health URL.
|
|
235
|
+
* The service is `name`, or the resource the ConvergeOp's rule fired for.
|
|
236
|
+
* Throws when neither names one, when the supervisor refuses, or when the
|
|
237
|
+
* service does not answer its health URL within `waitMs`.
|
|
238
|
+
*/
|
|
239
|
+
export async function spriteServiceRestart(
|
|
240
|
+
args: SpriteServiceRestartArgs,
|
|
241
|
+
signal?: AbortSignal,
|
|
242
|
+
http?: SpritesHttp,
|
|
243
|
+
): Promise<SpriteServiceRestartResult> {
|
|
244
|
+
const name = args.name ?? process.env[CONVERGE_RESOURCE_ENV];
|
|
245
|
+
if (!name) {
|
|
246
|
+
throw new Error(`spriteServiceRestart: no service named: pass \`name\`, or run the Op from a ConvergeOp rule, which sets ${CONVERGE_RESOURCE_ENV}`);
|
|
247
|
+
}
|
|
248
|
+
const health = args.health ?? (args.services || args.servicesFile ? declaredServices(args).find((s) => s.name === name)?.health : undefined);
|
|
249
|
+
|
|
250
|
+
if (args.id) {
|
|
251
|
+
const client = http ?? defaultSpritesHttp(args.token);
|
|
252
|
+
await spriteServiceStop({ id: args.id, name, endpoint: args.endpoint, token: args.token }, signal, client);
|
|
253
|
+
await spriteServiceStart({ id: args.id, name, endpoint: args.endpoint, token: args.token }, signal, client);
|
|
254
|
+
} else {
|
|
255
|
+
const bin = findSpriteEnv(args.spriteEnv);
|
|
256
|
+
if (!bin) throw new Error("no sprite-env on PATH or in /.sprite/bin, and no sprite id to reach the Sprites API with");
|
|
257
|
+
await execFileAsync(bin, ["services", "restart", name], { signal, timeout: 120_000 });
|
|
258
|
+
}
|
|
259
|
+
console.log(`restarted: ${name}`);
|
|
260
|
+
if (!health) return { name, healthy: null };
|
|
261
|
+
|
|
262
|
+
const until = Date.now() + (args.waitMs ?? 60_000);
|
|
263
|
+
do {
|
|
264
|
+
if (await probeHealth(health, signal)) return { name, healthy: true };
|
|
265
|
+
await sleep(500);
|
|
266
|
+
} while (Date.now() < until && !signal?.aborted);
|
|
267
|
+
throw new Error(`${name} was restarted and does not answer ${health}`);
|
|
268
|
+
}
|
|
@@ -70,6 +70,12 @@ interface ExecResult {
|
|
|
70
70
|
stdout: string;
|
|
71
71
|
stderr: string;
|
|
72
72
|
exitCode: number;
|
|
73
|
+
/**
|
|
74
|
+
* Fake-only timing hook (#2765): how long `runExec` should hold the frames
|
|
75
|
+
* before sending them, set by the `sleep` form below. Lets a test prove
|
|
76
|
+
* `spriteExec`'s `timeoutMs` fires without a slow real exec.
|
|
77
|
+
*/
|
|
78
|
+
delayMs?: number;
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
/**
|
|
@@ -77,11 +83,24 @@ interface ExecResult {
|
|
|
77
83
|
* small set of forms so a test (and the example `guarded-task` Op) can write a
|
|
78
84
|
* key, then overwrite/fail it, and prove restore rewinds. Segments split on
|
|
79
85
|
* `;` run in order; the exit code is the last segment's (shell `;` semantics).
|
|
86
|
+
*
|
|
87
|
+
* `opts.env`/`opts.dir` (#2765) are the `env`/`dir` exec options after they
|
|
88
|
+
* cross the wire (`env` as `KEY=VALUE` strings, mirroring what the query
|
|
89
|
+
* params carry) — `env` and `pwd` below echo them back so a test can prove
|
|
90
|
+
* they reached the command. Real spritzer only wires `env`/`dir` through in
|
|
91
|
+
* container exec mode (real processes); its interpreter mode — what
|
|
92
|
+
* `fakeExec` otherwise mirrors — has no notion of either, so this pair of
|
|
93
|
+
* forms is fake-only, proven separately from the container-mode docker test.
|
|
80
94
|
*/
|
|
81
|
-
export function fakeExec(
|
|
95
|
+
export function fakeExec(
|
|
96
|
+
sprite: SpriteState,
|
|
97
|
+
cmd: string,
|
|
98
|
+
opts: { env?: string[]; dir?: string } = {},
|
|
99
|
+
): ExecResult {
|
|
82
100
|
let stdout = "";
|
|
83
101
|
let stderr = "";
|
|
84
102
|
let exitCode = 0;
|
|
103
|
+
let delayMs: number | undefined;
|
|
85
104
|
|
|
86
105
|
for (const raw of cmd.split(";")) {
|
|
87
106
|
const seg = raw.trim();
|
|
@@ -109,6 +128,19 @@ export function fakeExec(sprite: SpriteState, cmd: string): ExecResult {
|
|
|
109
128
|
exitCode = 1;
|
|
110
129
|
} else if (seg === "true") {
|
|
111
130
|
exitCode = 0;
|
|
131
|
+
} else if (seg === "env") {
|
|
132
|
+
// Prints the passed env, sorted `KEY=VALUE` lines, like the real command.
|
|
133
|
+
stdout += [...(opts.env ?? [])].sort().map((kv) => `${kv}\n`).join("");
|
|
134
|
+
exitCode = 0;
|
|
135
|
+
} else if (seg === "pwd") {
|
|
136
|
+
stdout += `${opts.dir ?? "/"}\n`;
|
|
137
|
+
exitCode = 0;
|
|
138
|
+
} else if ((m = seg.match(/^sleep\s+(\d+)$/))) {
|
|
139
|
+
// Fake-only timing hook (#2765): the argument is milliseconds, not
|
|
140
|
+
// seconds like the real command — just enough to hold the exec open
|
|
141
|
+
// past a short `timeoutMs` without a slow test.
|
|
142
|
+
exitCode = 0;
|
|
143
|
+
delayMs = Number(m[1]);
|
|
112
144
|
} else if (seg === "./risky.sh") {
|
|
113
145
|
// A scripted failing job: mutates the workspace, then exits non-zero, so
|
|
114
146
|
// the example guarded-task Op demonstrates checkpoint-as-compensation.
|
|
@@ -122,7 +154,7 @@ export function fakeExec(sprite: SpriteState, cmd: string): ExecResult {
|
|
|
122
154
|
}
|
|
123
155
|
}
|
|
124
156
|
|
|
125
|
-
return { stdout, stderr, exitCode };
|
|
157
|
+
return { stdout, stderr, exitCode, ...(delayMs !== undefined ? { delayMs } : {}) };
|
|
126
158
|
}
|
|
127
159
|
|
|
128
160
|
function unquote(s: string): string {
|
|
@@ -228,14 +260,25 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
|
|
|
228
260
|
}
|
|
229
261
|
// argv arrives as repeated `cmd` params; reconstruct the script the small
|
|
230
262
|
// interpreter understands (the tokens round-trip for the space-separated
|
|
231
|
-
// command forms the example Ops use).
|
|
263
|
+
// command forms the example Ops use). `dir`/`env` (#2765) mirror
|
|
264
|
+
// spriteExecWsUrl's encoding: `dir` a single param, `env` repeated
|
|
265
|
+
// `KEY=VALUE` params.
|
|
232
266
|
const argv = url.searchParams.getAll("cmd");
|
|
233
267
|
const script = argv.join(" ");
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
268
|
+
const dir = url.searchParams.get("dir") ?? undefined;
|
|
269
|
+
const env = url.searchParams.getAll("env");
|
|
270
|
+
const result = fakeExec(sprite, script, { dir, env });
|
|
271
|
+
const send = (): void => {
|
|
272
|
+
// The client may already have aborted/timed out and closed its end;
|
|
273
|
+
// sending on a closed socket would throw, so this is a plain no-op.
|
|
274
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
275
|
+
if (result.stdout) ws.send(frame(STREAM_STDOUT, Buffer.from(result.stdout)));
|
|
276
|
+
if (result.stderr) ws.send(frame(STREAM_STDERR, Buffer.from(result.stderr)));
|
|
277
|
+
ws.send(frame(STREAM_EXIT, Buffer.of(result.exitCode & 0xff)));
|
|
278
|
+
ws.close();
|
|
279
|
+
};
|
|
280
|
+
if (result.delayMs) setTimeout(send, result.delayMs);
|
|
281
|
+
else send();
|
|
239
282
|
}
|
|
240
283
|
|
|
241
284
|
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type OpConfig,
|
|
15
15
|
} from "@intentius/chant/op";
|
|
16
16
|
import { createSpritesFake } from "./sprites-fake";
|
|
17
|
+
import { spriteCreate as createImpl, spriteExec as execImpl } from "./sprites";
|
|
17
18
|
|
|
18
19
|
// End-to-end against the in-process fake (S7) — no Docker, runs in CI. The
|
|
19
20
|
// activities resolve by name through `loadActivities(["fly"])` and reach
|
|
@@ -154,3 +155,32 @@ describe("guarded-task — checkpoint-as-compensation (S5)", () => {
|
|
|
154
155
|
expect(state.fs).toEqual({ "/state": "bad" });
|
|
155
156
|
});
|
|
156
157
|
});
|
|
158
|
+
|
|
159
|
+
describe("spriteExec: timeoutMs, env, dir (#2765)", () => {
|
|
160
|
+
test("timeoutMs aborts a hung exec and throws a clear timeout error", async () => {
|
|
161
|
+
await createImpl({ name: "to-1", endpoint: fake.url });
|
|
162
|
+
// The fake's fake-only `sleep <ms>` form holds the response open past the
|
|
163
|
+
// timeout, so this proves the abort — not a slow real command.
|
|
164
|
+
await expect(execImpl({ id: "to-1", cmd: "sleep 500", timeoutMs: 30, endpoint: fake.url })).rejects.toThrow(
|
|
165
|
+
/timed out after 30ms/,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("an exec that finishes under timeoutMs still succeeds", async () => {
|
|
170
|
+
await createImpl({ name: "to-2", endpoint: fake.url });
|
|
171
|
+
const res = await execImpl({ id: "to-2", cmd: "echo hi", timeoutMs: 5000, endpoint: fake.url });
|
|
172
|
+
expect(res.stdout).toBe("hi\n");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("env reaches the command", async () => {
|
|
176
|
+
await createImpl({ name: "env-1", endpoint: fake.url });
|
|
177
|
+
const res = await execImpl({ id: "env-1", cmd: "env", env: { FOO: "bar", BAZ: "qux" }, endpoint: fake.url });
|
|
178
|
+
expect(res.stdout.split("\n").filter(Boolean).sort()).toEqual(["BAZ=qux", "FOO=bar"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("dir reaches the command", async () => {
|
|
182
|
+
await createImpl({ name: "dir-1", endpoint: fake.url });
|
|
183
|
+
const res = await execImpl({ id: "dir-1", cmd: "pwd", dir: "/work/app", endpoint: fake.url });
|
|
184
|
+
expect(res.stdout).toBe("/work/app\n");
|
|
185
|
+
});
|
|
186
|
+
});
|