@intentius/chant-lexicon-fly 0.94.0 → 0.95.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/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/op/activities/box-services.d.ts +40 -0
- package/dist/op/activities/box-services.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +5 -3
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/sprite-config.d.ts +54 -3
- package/dist/op/activities/sprite-config.d.ts.map +1 -1
- package/dist/op/activities/sprite-service-converge.d.ts +24 -1
- package/dist/op/activities/sprite-service-converge.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +33 -3
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/op/builders.d.ts +7 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/skills/chant-fly-sprites.md +2 -2
- package/package.json +2 -2
- package/src/op/activities/box-services.ts +78 -0
- package/src/op/activities/index.ts +9 -0
- package/src/op/activities/sprite-box-services.test.ts +318 -0
- package/src/op/activities/sprite-config.ts +214 -4
- package/src/op/activities/sprite-service-converge.ts +31 -3
- package/src/op/activity-contracts.test.ts +19 -2
- package/src/op/activity-contracts.ts +37 -3
- package/src/op/builders.ts +7 -1
- package/src/skills/chant-fly-sprites.md +2 -2
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2880 — a box's services declared in its box block, observed, restarted
|
|
3
|
+
* and applied through sprite-env with `box: true`.
|
|
4
|
+
*
|
|
5
|
+
* The workspace here has one box member, `box/`, whose block declares `app`,
|
|
6
|
+
* `hud` (needing `app`) and an optional `site`. `sprite-env` is a stand-in
|
|
7
|
+
* with a sprite's command line: `services list` prints JSON in the shape a
|
|
8
|
+
* sprite's does (`[{ name, cmd, needs, http_port, state: { status } }]`),
|
|
9
|
+
* `create` refuses a name it has and a `needs` it doesn't, and every call is
|
|
10
|
+
* appended to a log the tests read. A small HTTP server answers each
|
|
11
|
+
* service's health URL with 200 while the service runs.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { createServer, type Server } from "node:http";
|
|
17
|
+
import type { AddressInfo } from "node:net";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
21
|
+
import { ConvergeOp, eq, loadActivities, run, runOpLocally, when, type ActivityFn, type OpConfig, type ResourceSymptom } from "@intentius/chant/op";
|
|
22
|
+
import { convergeTick } from "@intentius/chant/op/activities";
|
|
23
|
+
import { readConvergeLedger } from "@intentius/chant/lifecycle/converge-ledger";
|
|
24
|
+
import { spriteApplyServices, listedServiceDiffers, parseServiceDefinitions, spriteEnvCreateArgs } from "./sprite-config";
|
|
25
|
+
import { spriteServiceRestart, spriteServicesObserve } from "./sprite-service-converge";
|
|
26
|
+
import { expandServiceCommand } from "./box-services";
|
|
27
|
+
import { spriteServicesObserve as observeStep, spriteServiceRestart as restartStep } from "../builders";
|
|
28
|
+
|
|
29
|
+
let root: string;
|
|
30
|
+
let boxDir: string;
|
|
31
|
+
let state: string;
|
|
32
|
+
let calls: 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, BOX_HOME: process.env.BOX_HOME };
|
|
36
|
+
|
|
37
|
+
const SPRITE_ENV = `#!/usr/bin/env node
|
|
38
|
+
const { appendFileSync, readdirSync, readFileSync, writeFileSync, existsSync, rmSync } = require("node:fs");
|
|
39
|
+
const { join } = require("node:path");
|
|
40
|
+
const dir = process.env.FAKE_SPRITE_STATE;
|
|
41
|
+
const argv = process.argv.slice(2);
|
|
42
|
+
appendFileSync(join(dir, "..", "calls"), argv.join(" ") + "\\n");
|
|
43
|
+
const [group, verb, name, ...rest] = argv;
|
|
44
|
+
if (group !== "services") process.exit(2);
|
|
45
|
+
const file = (n) => join(dir, n + ".json");
|
|
46
|
+
const read = (n) => JSON.parse(readFileSync(file(n), "utf8"));
|
|
47
|
+
const fail = (m) => { console.error("sprite-env: " + m); process.exit(1); };
|
|
48
|
+
if (verb === "list") {
|
|
49
|
+
const out = readdirSync(dir).filter((f) => f.endsWith(".json")).sort().map((f) => {
|
|
50
|
+
const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
51
|
+
return { name: s.name, cmd: s.cmd, args: [], needs: s.needs, http_port: s.httpPort, state: { name: s.name, status: s.status } };
|
|
52
|
+
});
|
|
53
|
+
console.log(JSON.stringify(out));
|
|
54
|
+
} else if (verb === "create") {
|
|
55
|
+
if (existsSync(file(name))) fail("service " + name + " already exists");
|
|
56
|
+
const s = { name, cmd: null, needs: [], httpPort: 0, status: "running" };
|
|
57
|
+
for (let i = 0; i < rest.length; i++) {
|
|
58
|
+
const a = rest[i];
|
|
59
|
+
if (a === "--no-stream") continue;
|
|
60
|
+
const v = rest[++i];
|
|
61
|
+
if (a === "--cmd") s.cmd = v;
|
|
62
|
+
else if (a === "--needs") s.needs = v.split(",");
|
|
63
|
+
else if (a === "--http-port") s.httpPort = Number(v);
|
|
64
|
+
else if (a === "--duration") s.duration = v;
|
|
65
|
+
else fail("unknown option " + a);
|
|
66
|
+
}
|
|
67
|
+
for (const n of s.needs) if (!existsSync(file(n))) fail("service " + name + " needs " + n + ", which does not exist");
|
|
68
|
+
writeFileSync(file(name), JSON.stringify(s));
|
|
69
|
+
} else if (verb === "delete") {
|
|
70
|
+
if (!existsSync(file(name))) fail("no service " + name);
|
|
71
|
+
rmSync(file(name));
|
|
72
|
+
} else if (["restart", "start", "stop"].includes(verb)) {
|
|
73
|
+
if (!existsSync(file(name))) fail("no service " + name);
|
|
74
|
+
const s = read(name);
|
|
75
|
+
s.status = verb === "stop" ? "stopped" : "running";
|
|
76
|
+
writeFileSync(file(name), JSON.stringify(s));
|
|
77
|
+
} else process.exit(2);
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
// chant run <op>, as far as a converge dispatch goes: the restart the dispatched Op runs.
|
|
81
|
+
const CHANT = `#!/bin/sh
|
|
82
|
+
echo "$2 $CHANT_CONVERGE_RESOURCE" >> "$FAKE_SPRITE_STATE/../dispatched"
|
|
83
|
+
exec sprite-env services restart "$CHANT_CONVERGE_RESOURCE"
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
const health = (name: string) => `http://127.0.0.1:${port}/${name}`;
|
|
87
|
+
|
|
88
|
+
function declare(services: unknown[]): void {
|
|
89
|
+
writeFileSync(
|
|
90
|
+
join(root, "chant.workspace.json"),
|
|
91
|
+
JSON.stringify(
|
|
92
|
+
{
|
|
93
|
+
name: "acme",
|
|
94
|
+
schema: 1,
|
|
95
|
+
members: [{ name: "box", dir: "box", kind: "other", because: "the box's steward and its Ops", box: { services } }],
|
|
96
|
+
},
|
|
97
|
+
null,
|
|
98
|
+
2,
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SERVICES = () => [
|
|
104
|
+
{ name: "app", cmd: "${BOX_HOME}/run-app.sh", duration: "3s", health: health("app") },
|
|
105
|
+
{ name: "hud", cmd: "${BOX_HOME}/run-daemon.sh", needs: ["app"], httpPort: 8080, duration: "3s", health: health("hud") },
|
|
106
|
+
{ name: "site", cmd: "${BOX_HOME}/run-site.sh", health: health("site"), optional: true },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
const listed = () => new Map(JSON.parse(spawnSync("sprite-env", ["services", "list"], { encoding: "utf8" }).stdout).map((s: { name: string }) => [s.name, s]));
|
|
110
|
+
const takeCalls = (): string[] => {
|
|
111
|
+
const text = existsSync(calls) ? readFileSync(calls, "utf8") : "";
|
|
112
|
+
rmSync(calls, { force: true });
|
|
113
|
+
return text.split("\n").filter((l) => l && l !== "services list");
|
|
114
|
+
};
|
|
115
|
+
const setStatus = (name: string, status: string) => {
|
|
116
|
+
const f = join(state, `${name}.json`);
|
|
117
|
+
writeFileSync(f, JSON.stringify({ ...JSON.parse(readFileSync(f, "utf8")), status }));
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function git(...args: string[]): void {
|
|
121
|
+
const r = spawnSync("git", args, { cwd: root, encoding: "utf8" });
|
|
122
|
+
if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
beforeAll(async () => {
|
|
126
|
+
root = mkdtempSync(join(tmpdir(), "chant-box-services-"));
|
|
127
|
+
boxDir = join(root, "box");
|
|
128
|
+
const bin = join(root, ".bin");
|
|
129
|
+
state = join(root, ".state", "services");
|
|
130
|
+
calls = join(root, ".state", "calls");
|
|
131
|
+
mkdirSync(bin, { recursive: true });
|
|
132
|
+
mkdirSync(state, { recursive: true });
|
|
133
|
+
mkdirSync(boxDir, { recursive: true });
|
|
134
|
+
writeFileSync(join(bin, "sprite-env"), SPRITE_ENV);
|
|
135
|
+
writeFileSync(join(bin, "chant"), CHANT);
|
|
136
|
+
chmodSync(join(bin, "sprite-env"), 0o755);
|
|
137
|
+
chmodSync(join(bin, "chant"), 0o755);
|
|
138
|
+
server = createServer((req, res) => {
|
|
139
|
+
const name = (req.url ?? "/").slice(1);
|
|
140
|
+
const f = join(state, `${name}.json`);
|
|
141
|
+
const up = existsSync(f) && JSON.parse(readFileSync(f, "utf8")).status === "running";
|
|
142
|
+
res.writeHead(up ? 200 : 503).end(up ? "ok" : "down");
|
|
143
|
+
});
|
|
144
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
145
|
+
port = (server.address() as AddressInfo).port;
|
|
146
|
+
process.env.PATH = `${bin}:${process.env.PATH}`;
|
|
147
|
+
process.env.FAKE_SPRITE_STATE = state;
|
|
148
|
+
process.env.BOX_HOME = "/home/sprite/box";
|
|
149
|
+
git("init", "-q", "-b", "main");
|
|
150
|
+
git("config", "user.email", "box@example.com");
|
|
151
|
+
git("config", "user.name", "Box Test");
|
|
152
|
+
git("config", "commit.gpgsign", "false");
|
|
153
|
+
writeFileSync(join(boxDir, "chant.config.json"), "{}\n");
|
|
154
|
+
writeFileSync(join(root, ".gitignore"), ".bin\n.state\n");
|
|
155
|
+
declare(SERVICES());
|
|
156
|
+
git("add", "-A");
|
|
157
|
+
git("commit", "-q", "-m", "c0");
|
|
158
|
+
process.chdir(boxDir);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterAll(async () => {
|
|
162
|
+
process.chdir(saved.cwd);
|
|
163
|
+
for (const [k, v] of [["PATH", saved.PATH], ["FAKE_SPRITE_STATE", saved.STATE], ["CHANT_CONVERGE_RESOURCE", saved.RESOURCE], ["BOX_HOME", saved.BOX_HOME]] as const) {
|
|
164
|
+
if (v === undefined) delete process.env[k];
|
|
165
|
+
else process.env[k] = v;
|
|
166
|
+
}
|
|
167
|
+
await new Promise<void>((r) => server.close(() => r()));
|
|
168
|
+
rmSync(root, { recursive: true, force: true });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
declare(SERVICES());
|
|
173
|
+
rmSync(calls, { force: true });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("the sprite-env listing and create line (#2880)", () => {
|
|
177
|
+
test("parses a sprite's JSON and the kit stand-in's table, with each definition", () => {
|
|
178
|
+
expect(parseServiceDefinitions(JSON.stringify([{ name: "door", cmd: "/x/run-door.sh", args: [], needs: ["hud"], http_port: 8080, state: { status: "running" } }]))).toEqual([
|
|
179
|
+
{ name: "door", status: "running", cmd: "/x/run-door.sh", needs: ["hud"], httpPort: 8080 },
|
|
180
|
+
]);
|
|
181
|
+
expect(parseServiceDefinitions(JSON.stringify([{ name: "a", cmd: "node", args: ["a.js"], needs: null, state: { status: "stopped" } }]))).toEqual([
|
|
182
|
+
{ name: "a", status: "stopped", cmd: "node a.js", needs: [] },
|
|
183
|
+
]);
|
|
184
|
+
expect(parseServiceDefinitions("app\trunning\t-\t-\t/x/run-app.sh\ndoor\tstarting\thud\t8080\t/x/run-door.sh\nold\tstopped\n")).toEqual([
|
|
185
|
+
{ name: "app", status: "running", needs: [], httpPort: null, cmd: "/x/run-app.sh" },
|
|
186
|
+
{ name: "door", status: "starting", needs: ["hud"], httpPort: 8080, cmd: "/x/run-door.sh" },
|
|
187
|
+
{ name: "old", status: "stopped" },
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("a difference in cmd, needs or httpPort is named, a field the listing lacks is not compared", () => {
|
|
192
|
+
const declared = { name: "door", cmd: "${H}/d.sh", needs: ["hud"], httpPort: 8080, duration: null, health: null, optional: false };
|
|
193
|
+
expect(listedServiceDiffers({ name: "door", status: "running", cmd: "/h/d.sh", needs: ["hud"], httpPort: 8080 }, declared, "/h/d.sh")).toBeNull();
|
|
194
|
+
expect(listedServiceDiffers({ name: "door", status: "running", cmd: "/h/old.sh", needs: ["hud"], httpPort: 8080 }, declared, "/h/d.sh")).toMatch(/^cmd/);
|
|
195
|
+
expect(listedServiceDiffers({ name: "door", status: "running", cmd: "/h/d.sh", needs: [], httpPort: 8080 }, declared, "/h/d.sh")).toMatch(/^needs/);
|
|
196
|
+
expect(listedServiceDiffers({ name: "door", status: "running", cmd: "/h/d.sh", needs: ["hud"], httpPort: null }, declared, "/h/d.sh")).toMatch(/^httpPort/);
|
|
197
|
+
expect(listedServiceDiffers({ name: "door", status: "running" }, declared, "/h/d.sh")).toBeNull();
|
|
198
|
+
expect(spriteEnvCreateArgs({ ...declared, duration: "2s" }, "/h/d.sh")).toEqual(["services", "create", "door", "--cmd", "/h/d.sh", "--needs", "hud", "--http-port", "8080", "--duration", "2s", "--no-stream"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("${VAR} expands from the environment, and an unset one fails naming it", () => {
|
|
202
|
+
expect(expandServiceCommand("app", "${HOME}/box/run.sh --port ${PORT}", { HOME: "/home/sprite", PORT: "5173" })).toBe("/home/sprite/box/run.sh --port 5173");
|
|
203
|
+
expect(() => expandServiceCommand("app", "${NOPE}/run.sh", {})).toThrow("service app's cmd names ${NOPE}, which is not set in this process's environment");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("spriteApplyServices({ box: true }) through sprite-env (#2880)", () => {
|
|
208
|
+
test("creates the declared services in dependency order, a second run changes nothing, and a changed cmd replaces that one service", async () => {
|
|
209
|
+
// Declared hud first, so the order is the needs', not the file's.
|
|
210
|
+
declare([SERVICES()[1], SERVICES()[0], SERVICES()[2]]);
|
|
211
|
+
const first = await spriteApplyServices({ box: true, start: true });
|
|
212
|
+
expect(first.services).toEqual([{ name: "app", action: "created" }, { name: "hud", action: "created" }]);
|
|
213
|
+
expect(first).toMatchObject({ applied: ["app", "hud"], started: [] });
|
|
214
|
+
expect(takeCalls()).toEqual([
|
|
215
|
+
"services create app --cmd /home/sprite/box/run-app.sh --duration 3s --no-stream",
|
|
216
|
+
"services create hud --cmd /home/sprite/box/run-daemon.sh --needs app --http-port 8080 --duration 3s --no-stream",
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
const second = await spriteApplyServices({ box: true, start: true });
|
|
220
|
+
expect(second.services).toEqual([{ name: "app", action: "left" }, { name: "hud", action: "left" }]);
|
|
221
|
+
expect(second.applied).toEqual([]);
|
|
222
|
+
expect(takeCalls()).toEqual([]);
|
|
223
|
+
|
|
224
|
+
declare([SERVICES()[0], { ...SERVICES()[1], cmd: "${BOX_HOME}/run-hud.sh" }, SERVICES()[2]]);
|
|
225
|
+
const third = await spriteApplyServices({ box: true, start: true });
|
|
226
|
+
expect(third.services).toEqual([{ name: "app", action: "left" }, { name: "hud", action: "replaced" }]);
|
|
227
|
+
expect(takeCalls()).toEqual(["services delete hud", "services create hud --cmd /home/sprite/box/run-hud.sh --needs app --http-port 8080 --duration 3s --no-stream"]);
|
|
228
|
+
expect((listed().get("hud") as { cmd: string }).cmd).toBe("/home/sprite/box/run-hud.sh");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("start starts a defined service that is stopped; without start it is left", async () => {
|
|
232
|
+
await spriteApplyServices({ box: true });
|
|
233
|
+
setStatus("app", "stopped");
|
|
234
|
+
takeCalls();
|
|
235
|
+
expect((await spriteApplyServices({ box: true })).services).toEqual([{ name: "app", action: "left" }, { name: "hud", action: "left" }]);
|
|
236
|
+
const started = await spriteApplyServices({ box: true, start: true });
|
|
237
|
+
expect(started.services).toEqual([{ name: "app", action: "started" }, { name: "hud", action: "left" }]);
|
|
238
|
+
expect(started.started).toEqual(["app"]);
|
|
239
|
+
expect(takeCalls()).toEqual(["services start app"]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("an optional service is applied only when only names it, and restart restarts the named ones already converged", async () => {
|
|
243
|
+
await spriteApplyServices({ box: true });
|
|
244
|
+
expect(listed().has("site")).toBe(false);
|
|
245
|
+
takeCalls();
|
|
246
|
+
expect((await spriteApplyServices({ box: true, only: ["site"], start: true, restart: true })).services).toEqual([{ name: "site", action: "created" }]);
|
|
247
|
+
expect((await spriteApplyServices({ box: true, only: ["site"], start: true, restart: true })).services).toEqual([{ name: "site", action: "restarted" }]);
|
|
248
|
+
expect(takeCalls()).toEqual(["services create site --cmd /home/sprite/box/run-site.sh --no-stream", "services restart site"]);
|
|
249
|
+
await expect(spriteApplyServices({ box: true, only: ["api"] })).rejects.toThrow("only names api, which the box block does not declare; declared: app, hud, site");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("an unset variable fails before anything changes, and the forms it refuses", async () => {
|
|
253
|
+
rmSync(join(state, "app.json"), { force: true });
|
|
254
|
+
delete process.env.BOX_HOME;
|
|
255
|
+
try {
|
|
256
|
+
await expect(spriteApplyServices({ box: true })).rejects.toThrow("${BOX_HOME}, which is not set");
|
|
257
|
+
} finally {
|
|
258
|
+
process.env.BOX_HOME = "/home/sprite/box";
|
|
259
|
+
}
|
|
260
|
+
expect(takeCalls()).toEqual([]);
|
|
261
|
+
await expect(spriteApplyServices({})).rejects.toThrow(/pass box: true/);
|
|
262
|
+
await expect(spriteApplyServices({ id: "box-1", box: true })).rejects.toThrow(/drop the id/);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("a ConvergeOp over the box block's services (#2880)", () => {
|
|
267
|
+
test("observe with box: true records a stopped declared service drifted, and the restart Op with box: true restarts it and waits for its health", async () => {
|
|
268
|
+
for (const f of ["app", "hud", "site"]) rmSync(join(state, `${f}.json`), { force: true });
|
|
269
|
+
await spriteApplyServices({ box: true });
|
|
270
|
+
const activities = await loadActivities(["fly"]);
|
|
271
|
+
const run$ = new Map<string, ActivityFn>([...activities, ["convergeTick", convergeTick as unknown as ActivityFn]]);
|
|
272
|
+
|
|
273
|
+
// The observer reads the block: site is optional and not defined, so it is skipped.
|
|
274
|
+
expect(await spriteServicesObserve({ box: true, probes: 1 })).toEqual({
|
|
275
|
+
resources: [
|
|
276
|
+
{ name: "app", status: "in-sync", detail: expect.stringContaining("answers 200") },
|
|
277
|
+
{ name: "hud", status: "in-sync", detail: expect.stringContaining("answers 200") },
|
|
278
|
+
],
|
|
279
|
+
skipped: ["site"],
|
|
280
|
+
});
|
|
281
|
+
await expect(spriteServicesObserve({ box: true, services: [{ name: "app" }] })).rejects.toThrow(/takes no services or servicesFile beside it/);
|
|
282
|
+
|
|
283
|
+
const { op } = ConvergeOp({
|
|
284
|
+
name: "box-converge",
|
|
285
|
+
env: "box",
|
|
286
|
+
dial: "apply",
|
|
287
|
+
observe: observeStep({ box: true, probes: 1 }),
|
|
288
|
+
rules: [
|
|
289
|
+
when<ResourceSymptom>(eq("status", "drifted"), run("restart-service"), { id: "restart-drifted", why: "A declared service that stopped is restarted." }),
|
|
290
|
+
],
|
|
291
|
+
});
|
|
292
|
+
const config = (op as unknown as { props: OpConfig }).props;
|
|
293
|
+
setStatus("app", "stopped");
|
|
294
|
+
const tick = await runOpLocally(config, run$, {}, undefined, { cwd: boxDir, ledger: { cwd: boxDir } });
|
|
295
|
+
expect(tick.status).toBe("ok");
|
|
296
|
+
const ticks = (await readConvergeLedger("box", { cwd: boxDir })).records;
|
|
297
|
+
expect(ticks.at(-1)).toMatchObject({
|
|
298
|
+
firedRuleIds: ["restart-drifted"],
|
|
299
|
+
outcomes: [{ ruleId: "restart-drifted", action: "ran", op: "restart-service", resource: "app" }],
|
|
300
|
+
resources: [{ name: "app", status: "drifted", detail: "stopped" }, { name: "hud", status: "in-sync" }],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// The Op the rule dispatches: spriteServiceRestart reads app's health URL from the block.
|
|
304
|
+
setStatus("app", "stopped");
|
|
305
|
+
takeCalls();
|
|
306
|
+
const restart: OpConfig = { name: "restart-service", phases: [{ name: "Restart", steps: [restartStep({ box: true, waitMs: 2000 })] }] } as unknown as OpConfig;
|
|
307
|
+
process.env.CHANT_CONVERGE_RESOURCE = "app";
|
|
308
|
+
try {
|
|
309
|
+
const ran = await runOpLocally(restart, run$, {}, undefined, { cwd: boxDir, ledger: { cwd: boxDir } });
|
|
310
|
+
expect(ran.status).toBe("ok");
|
|
311
|
+
await expect(spriteServiceRestart({ box: true, waitMs: 2000 })).resolves.toEqual({ name: "app", healthy: true });
|
|
312
|
+
} finally {
|
|
313
|
+
delete process.env.CHANT_CONVERGE_RESOURCE;
|
|
314
|
+
}
|
|
315
|
+
expect(takeCalls()).toEqual(["services restart app", "services restart app"]);
|
|
316
|
+
expect((listed().get("app") as { state: { status: string } }).state.status).toBe("running");
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -15,10 +15,21 @@
|
|
|
15
15
|
* construction); services reconcile is additive + update (create-or-update each
|
|
16
16
|
* desired service, optionally start). Owned-only prune of stale services is out
|
|
17
17
|
* of scope — the documented Services REST surface exposes no delete.
|
|
18
|
+
*
|
|
19
|
+
* `spriteApplyServices` with no `id` runs inside the sprite instead (#2880):
|
|
20
|
+
* it applies the box block's services (`box: true`) through `sprite-env
|
|
21
|
+
* services`, which has no update, so a changed service is deleted and created.
|
|
22
|
+
* It deletes nothing the box block does not declare.
|
|
18
23
|
*/
|
|
19
24
|
|
|
25
|
+
import { execFile } from "node:child_process";
|
|
26
|
+
import { promisify } from "node:util";
|
|
27
|
+
import { expandServiceCommand, inStartOrder, boxServices, type BoxServiceDeclaration } from "./box-services";
|
|
28
|
+
import { findSpriteEnv } from "./sprite-service-converge";
|
|
20
29
|
import { resolveSpritesEndpoint, defaultSpritesHttp, type SpritesHttp } from "./sprites";
|
|
21
30
|
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
|
|
22
33
|
function safeJson(text: string): unknown {
|
|
23
34
|
try {
|
|
24
35
|
return JSON.parse(text);
|
|
@@ -116,19 +127,42 @@ export interface ServiceSpec {
|
|
|
116
127
|
}
|
|
117
128
|
|
|
118
129
|
export interface SpriteApplyServicesArgs {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
/**
|
|
130
|
+
/** The sprite, for the Sprites API. Without it, the step runs inside the sprite and applies the box block's services through `sprite-env` (#2880). */
|
|
131
|
+
id?: string;
|
|
132
|
+
/** The services, for the Sprites API. Required with `id`. */
|
|
133
|
+
services?: ServiceSpec[];
|
|
134
|
+
/** Without `id`: apply the services of the box block of the workspace member whose directory holds the working directory (#2880). Required without `id`. */
|
|
135
|
+
box?: boolean;
|
|
136
|
+
/** Without `id`: apply only these declared services, in dependency order. An `optional` service is applied only when named here. */
|
|
137
|
+
only?: string[];
|
|
138
|
+
/**
|
|
139
|
+
* Start services after applying, in dependency order. With `id`, every
|
|
140
|
+
* service; without, every applied one that is not running (a created or
|
|
141
|
+
* replaced one is started by its create). Default: false.
|
|
142
|
+
*/
|
|
122
143
|
start?: boolean;
|
|
144
|
+
/** Without `id`: restart each applied service that was already converged. Default: false. */
|
|
145
|
+
restart?: boolean;
|
|
146
|
+
/** Without `id`: the `sprite-env` binary. Default: the one on PATH, else /.sprite/bin/sprite-env. */
|
|
147
|
+
spriteEnv?: string;
|
|
123
148
|
endpoint?: string;
|
|
124
149
|
token?: string;
|
|
125
150
|
}
|
|
126
151
|
|
|
152
|
+
/** What an apply through sprite-env did to one service (#2880). */
|
|
153
|
+
export type ServiceApplyAction = "created" | "replaced" | "restarted" | "started" | "left";
|
|
154
|
+
|
|
127
155
|
export interface SpriteApplyServicesResult {
|
|
128
156
|
/** Names that were created or updated (converged services are skipped). */
|
|
129
157
|
applied: string[];
|
|
130
158
|
/** Names that were started (empty unless `start`). */
|
|
131
159
|
started: string[];
|
|
160
|
+
/**
|
|
161
|
+
* Without `id`: each service applied, in the order it was applied, with
|
|
162
|
+
* what the apply did to it (#2880). `left` is a converged service that was
|
|
163
|
+
* not restarted or started.
|
|
164
|
+
*/
|
|
165
|
+
services?: { name: string; action: ServiceApplyAction }[];
|
|
132
166
|
}
|
|
133
167
|
|
|
134
168
|
/**
|
|
@@ -204,7 +238,20 @@ function serviceBody(s: ServiceSpec): Record<string, unknown> {
|
|
|
204
238
|
export async function spriteApplyServices(
|
|
205
239
|
args: SpriteApplyServicesArgs,
|
|
206
240
|
signal?: AbortSignal,
|
|
207
|
-
http
|
|
241
|
+
http?: SpritesHttp,
|
|
242
|
+
): Promise<SpriteApplyServicesResult> {
|
|
243
|
+
if (!args.id) return applyThroughSpriteEnv(args, signal);
|
|
244
|
+
if (args.box || args.only || args.restart) {
|
|
245
|
+
throw new Error("spriteApplyServices: box, only and restart apply a box's services through sprite-env inside the sprite; drop the id to use them");
|
|
246
|
+
}
|
|
247
|
+
if (!args.services) throw new Error("spriteApplyServices: an id needs its services");
|
|
248
|
+
return applyThroughApi({ ...args, id: args.id, services: args.services }, signal, http ?? defaultSpritesHttp(args.token));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function applyThroughApi(
|
|
252
|
+
args: SpriteApplyServicesArgs & { id: string; services: ServiceSpec[] },
|
|
253
|
+
signal: AbortSignal | undefined,
|
|
254
|
+
http: SpritesHttp,
|
|
208
255
|
): Promise<SpriteApplyServicesResult> {
|
|
209
256
|
const startOrder = validateServices(args.services);
|
|
210
257
|
const base = resolveSpritesEndpoint(args);
|
|
@@ -244,3 +291,166 @@ export async function spriteApplyServices(
|
|
|
244
291
|
|
|
245
292
|
return { applied, started };
|
|
246
293
|
}
|
|
294
|
+
|
|
295
|
+
// ── Services through sprite-env (#2880) ─────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/** A service as `sprite-env services list` reports it. A field the listing does not carry is undefined. */
|
|
298
|
+
export interface ListedService {
|
|
299
|
+
name: string;
|
|
300
|
+
status: string | null;
|
|
301
|
+
cmd?: string;
|
|
302
|
+
needs?: string[];
|
|
303
|
+
httpPort?: number | null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* `sprite-env services list` with each service's definition. A sprite's
|
|
308
|
+
* sprite-env prints JSON (`[{ name, cmd, args, needs, http_port, state: {
|
|
309
|
+
* status } }]`); the studio kit's stand-in has printed a table of name,
|
|
310
|
+
* state, needs (`-` for none), http port (`-`) and cmd, tab-separated. A
|
|
311
|
+
* table with fewer columns gives the name and state only. Pure.
|
|
312
|
+
*/
|
|
313
|
+
export function parseServiceDefinitions(text: string): ListedService[] {
|
|
314
|
+
const trimmed = text.trim();
|
|
315
|
+
if (!trimmed) return [];
|
|
316
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
317
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
318
|
+
const list = Array.isArray(parsed) ? parsed : ((parsed as { services?: unknown }).services ?? []);
|
|
319
|
+
const out: ListedService[] = [];
|
|
320
|
+
for (const s of list as Record<string, unknown>[]) {
|
|
321
|
+
if (typeof s?.name !== "string") continue;
|
|
322
|
+
const state = s.state as { status?: unknown } | undefined;
|
|
323
|
+
const status = typeof state?.status === "string" ? state.status : typeof s.status === "string" ? s.status : null;
|
|
324
|
+
const args = Array.isArray(s.args) ? (s.args as unknown[]).map(String) : [];
|
|
325
|
+
out.push({
|
|
326
|
+
name: s.name,
|
|
327
|
+
status,
|
|
328
|
+
...(typeof s.cmd === "string" ? { cmd: args.length > 0 ? [s.cmd, ...args].join(" ") : s.cmd } : {}),
|
|
329
|
+
...(Array.isArray(s.needs) ? { needs: (s.needs as unknown[]).map(String) } : s.needs === null ? { needs: [] } : {}),
|
|
330
|
+
...("http_port" in s ? { httpPort: typeof s.http_port === "number" && s.http_port > 0 ? s.http_port : null } : {}),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
const out: ListedService[] = [];
|
|
336
|
+
for (const line of trimmed.split("\n")) {
|
|
337
|
+
const cols = line.split("\t");
|
|
338
|
+
const name = cols[0]?.trim();
|
|
339
|
+
if (!name) continue;
|
|
340
|
+
const listed: ListedService = { name, status: cols[1]?.trim() || null };
|
|
341
|
+
if (cols.length >= 5) {
|
|
342
|
+
const needs = cols[2].trim();
|
|
343
|
+
const port = cols[3].trim();
|
|
344
|
+
listed.needs = needs === "-" || needs === "" ? [] : needs.split(",");
|
|
345
|
+
listed.httpPort = port === "-" || port === "" ? null : Number(port);
|
|
346
|
+
listed.cmd = cols.slice(4).join("\t");
|
|
347
|
+
}
|
|
348
|
+
out.push(listed);
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Whether a listed service differs from the declared one in `cmd`, `needs` or `httpPort`. A field the listing does not carry is not compared. Pure. */
|
|
354
|
+
export function listedServiceDiffers(listed: ListedService, declared: BoxServiceDeclaration, cmd: string): string | null {
|
|
355
|
+
if (listed.cmd !== undefined && listed.cmd !== cmd) return `cmd ${JSON.stringify(listed.cmd)} is now ${JSON.stringify(cmd)}`;
|
|
356
|
+
if (listed.needs !== undefined) {
|
|
357
|
+
const a = [...listed.needs].sort().join(",");
|
|
358
|
+
const b = [...declared.needs].sort().join(",");
|
|
359
|
+
if (a !== b) return `needs [${a}] is now [${b}]`;
|
|
360
|
+
}
|
|
361
|
+
if (listed.httpPort !== undefined && (listed.httpPort ?? null) !== declared.httpPort) return `httpPort ${listed.httpPort ?? "none"} is now ${declared.httpPort ?? "none"}`;
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The arguments of `sprite-env services create` for a declared service. Pure. */
|
|
366
|
+
export function spriteEnvCreateArgs(s: BoxServiceDeclaration, cmd: string): string[] {
|
|
367
|
+
return [
|
|
368
|
+
"services",
|
|
369
|
+
"create",
|
|
370
|
+
s.name,
|
|
371
|
+
"--cmd",
|
|
372
|
+
cmd,
|
|
373
|
+
...(s.needs.length > 0 ? ["--needs", s.needs.join(",")] : []),
|
|
374
|
+
...(s.httpPort !== null ? ["--http-port", String(s.httpPort)] : []),
|
|
375
|
+
...(s.duration !== null ? ["--duration", s.duration] : []),
|
|
376
|
+
"--no-stream",
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The states a service is in once started: `start` leaves it be. */
|
|
381
|
+
const UP = new Set(["running", "starting"]);
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Apply the box block's services through sprite-env, in dependency order:
|
|
385
|
+
* create a declared service the supervisor doesn't have, delete and create
|
|
386
|
+
* one whose `cmd`, `needs` or `httpPort` differ from what the list reports,
|
|
387
|
+
* and leave a converged one running (restart it with `restart`). Then, with
|
|
388
|
+
* `start`, start each applied service that is not running.
|
|
389
|
+
*/
|
|
390
|
+
async function applyThroughSpriteEnv(args: SpriteApplyServicesArgs, signal?: AbortSignal): Promise<SpriteApplyServicesResult> {
|
|
391
|
+
if (!args.box) throw new Error("spriteApplyServices: without an id it applies the box block's services through sprite-env; pass box: true (or an id and services for the Sprites API)");
|
|
392
|
+
if (args.services) throw new Error("spriteApplyServices: box: true reads the services from the box block, so it takes no services beside it");
|
|
393
|
+
const declared = await boxServices();
|
|
394
|
+
const names = new Set(declared.map((s) => s.name));
|
|
395
|
+
const unknown = (args.only ?? []).filter((n) => !names.has(n));
|
|
396
|
+
if (unknown.length > 0) {
|
|
397
|
+
throw new Error(`spriteApplyServices: only names ${unknown.join(", ")}, which the box block does not declare; declared: ${[...names].join(", ") || "none"}`);
|
|
398
|
+
}
|
|
399
|
+
const only = args.only ? new Set(args.only) : null;
|
|
400
|
+
const targets = inStartOrder(declared).filter((s) => (only ? only.has(s.name) : !s.optional));
|
|
401
|
+
// Expand every command before changing anything, so a missing variable fails the step with nothing half-applied.
|
|
402
|
+
const commands = new Map(targets.map((s) => [s.name, expandServiceCommand(s.name, s.cmd)]));
|
|
403
|
+
|
|
404
|
+
const bin = findSpriteEnv(args.spriteEnv);
|
|
405
|
+
if (!bin) throw new Error("spriteApplyServices: no sprite-env on PATH or in /.sprite/bin, and no sprite id to reach the Sprites API with");
|
|
406
|
+
const spriteEnv = async (argv: string[]): Promise<string> => {
|
|
407
|
+
try {
|
|
408
|
+
return (await execFileAsync(bin, argv, { signal, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 })).stdout;
|
|
409
|
+
} catch (err) {
|
|
410
|
+
const e = err as { stderr?: string; message?: string };
|
|
411
|
+
throw new Error(`sprite-env ${argv.slice(0, 3).join(" ")} failed: ${(e.stderr || e.message || String(err)).trim()}`);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
const live = new Map(parseServiceDefinitions(await spriteEnv(["services", "list"])).map((s) => [s.name, s]));
|
|
415
|
+
|
|
416
|
+
const services: { name: string; action: ServiceApplyAction }[] = [];
|
|
417
|
+
for (const s of targets) {
|
|
418
|
+
const cmd = commands.get(s.name)!;
|
|
419
|
+
const cur = live.get(s.name);
|
|
420
|
+
if (!cur) {
|
|
421
|
+
await spriteEnv(spriteEnvCreateArgs(s, cmd));
|
|
422
|
+
console.log(`services: created ${s.name}`);
|
|
423
|
+
services.push({ name: s.name, action: "created" });
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const why = listedServiceDiffers(cur, s, cmd);
|
|
427
|
+
if (why) {
|
|
428
|
+
await spriteEnv(["services", "delete", s.name]);
|
|
429
|
+
await spriteEnv(spriteEnvCreateArgs(s, cmd));
|
|
430
|
+
console.log(`services: replaced ${s.name} (${why})`);
|
|
431
|
+
services.push({ name: s.name, action: "replaced" });
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (args.restart) {
|
|
435
|
+
await spriteEnv(["services", "restart", s.name]);
|
|
436
|
+
console.log(`services: restarted ${s.name}`);
|
|
437
|
+
services.push({ name: s.name, action: "restarted" });
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
services.push({ name: s.name, action: "left" });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const started: string[] = [];
|
|
444
|
+
if (args.start) {
|
|
445
|
+
for (const entry of services) {
|
|
446
|
+
if (entry.action !== "left" || UP.has(live.get(entry.name)?.status ?? "")) continue;
|
|
447
|
+
await spriteEnv(["services", "start", entry.name]);
|
|
448
|
+
console.log(`services: started ${entry.name}`);
|
|
449
|
+
entry.action = "started";
|
|
450
|
+
started.push(entry.name);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const applied = services.filter((e) => e.action === "created" || e.action === "replaced").map((e) => e.name);
|
|
454
|
+
console.log(`services: applied ${applied.length}/${targets.length} through sprite-env, started ${started.length}`);
|
|
455
|
+
return { applied, started, services };
|
|
456
|
+
}
|
|
@@ -20,12 +20,18 @@
|
|
|
20
20
|
* sprite and call `sprite-env services` (found on PATH or in /.sprite/bin,
|
|
21
21
|
* or at `spriteEnv`). With an `id`, they go through the Sprites API
|
|
22
22
|
* (`spriteServiceList`, `spriteServiceStop` and `spriteServiceStart`).
|
|
23
|
+
*
|
|
24
|
+
* The declared services come from `services`, from a `servicesFile`, or,
|
|
25
|
+
* with `box: true`, from the `services` of the box block of the workspace
|
|
26
|
+
* member whose directory holds the working directory (#2880), the list
|
|
27
|
+
* `chant workspace status --json` prints as `members[].box.services`.
|
|
23
28
|
*/
|
|
24
29
|
|
|
25
30
|
import { execFile } from "node:child_process";
|
|
26
31
|
import { existsSync, readFileSync } from "node:fs";
|
|
27
32
|
import { join, resolve } from "node:path";
|
|
28
33
|
import { promisify } from "node:util";
|
|
34
|
+
import { boxServices } from "./box-services";
|
|
29
35
|
import { spriteServiceList, spriteServiceStart, spriteServiceStop } from "./sprite-services";
|
|
30
36
|
import type { SpritesHttp } from "./sprites";
|
|
31
37
|
import { defaultSpritesHttp } from "./sprites";
|
|
@@ -62,6 +68,12 @@ export interface SpriteServicesObserveArgs extends Via {
|
|
|
62
68
|
services?: DeclaredSpriteService[];
|
|
63
69
|
/** A JSON file of `{ services: [...] }`, relative to the working directory, read on each tick. */
|
|
64
70
|
servicesFile?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Read the services from the box block of the workspace member whose
|
|
73
|
+
* directory holds the working directory, on each tick (#2880), in place of
|
|
74
|
+
* `services` and `servicesFile`.
|
|
75
|
+
*/
|
|
76
|
+
box?: boolean;
|
|
65
77
|
/** Health probes before a service counts as drifted. Default 3. */
|
|
66
78
|
probes?: number;
|
|
67
79
|
/** Time between probes, ms. Default 1000. */
|
|
@@ -77,10 +89,12 @@ export interface SpriteServicesObserveResult {
|
|
|
77
89
|
export interface SpriteServiceRestartArgs extends Via {
|
|
78
90
|
/** The service. Default: the resource a ConvergeOp dispatched this run for (`CHANT_CONVERGE_RESOURCE`). */
|
|
79
91
|
name?: string;
|
|
80
|
-
/** Its health URL. Default: the one `services` or `
|
|
92
|
+
/** Its health URL. Default: the one `services`, `servicesFile` or the box block (`box: true`) declares for it. */
|
|
81
93
|
health?: string;
|
|
82
94
|
services?: DeclaredSpriteService[];
|
|
83
95
|
servicesFile?: string;
|
|
96
|
+
/** Read the declared services from the box block of the member the step runs in (#2880), in place of `services` and `servicesFile`. */
|
|
97
|
+
box?: boolean;
|
|
84
98
|
/** How long to wait for the health URL after the restart, ms. Default 60000. */
|
|
85
99
|
waitMs?: number;
|
|
86
100
|
}
|
|
@@ -107,6 +121,20 @@ export function declaredServices(args: { services?: DeclaredSpriteService[]; ser
|
|
|
107
121
|
return out;
|
|
108
122
|
}
|
|
109
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The declared services: with `box: true`, the box block's (#2880), and
|
|
126
|
+
* otherwise those of {@link declaredServices}. `box` in place of the other
|
|
127
|
+
* two, never beside them.
|
|
128
|
+
*/
|
|
129
|
+
export async function resolveDeclaredServices(
|
|
130
|
+
args: { services?: DeclaredSpriteService[]; servicesFile?: string; box?: boolean },
|
|
131
|
+
cwd = process.cwd(),
|
|
132
|
+
): Promise<DeclaredSpriteService[]> {
|
|
133
|
+
if (!args.box) return declaredServices(args, cwd);
|
|
134
|
+
if (args.services || args.servicesFile) throw new Error("box: true reads the services from the box block, so it takes no services or servicesFile beside it");
|
|
135
|
+
return (await boxServices(cwd)).map((s) => ({ name: s.name, ...(s.health ? { health: s.health } : {}), ...(s.optional ? { optional: true } : {}) }));
|
|
136
|
+
}
|
|
137
|
+
|
|
110
138
|
/** The sprite-env binary, or null when there is none. */
|
|
111
139
|
export function findSpriteEnv(explicit?: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
112
140
|
if (explicit) return explicit;
|
|
@@ -181,7 +209,7 @@ export async function spriteServicesObserve(
|
|
|
181
209
|
signal?: AbortSignal,
|
|
182
210
|
http?: SpritesHttp,
|
|
183
211
|
): Promise<SpriteServicesObserveResult> {
|
|
184
|
-
const declared =
|
|
212
|
+
const declared = await resolveDeclaredServices(args);
|
|
185
213
|
const probes = Math.max(1, args.probes ?? 3);
|
|
186
214
|
const interval = args.probeIntervalMs ?? 1000;
|
|
187
215
|
let states: Map<string, string | null>;
|
|
@@ -245,7 +273,7 @@ export async function spriteServiceRestart(
|
|
|
245
273
|
if (!name) {
|
|
246
274
|
throw new Error(`spriteServiceRestart: no service named: pass \`name\`, or run the Op from a ConvergeOp rule, which sets ${CONVERGE_RESOURCE_ENV}`);
|
|
247
275
|
}
|
|
248
|
-
const health = args.health ?? (args.services || args.servicesFile ?
|
|
276
|
+
const health = args.health ?? (args.services || args.servicesFile || args.box ? (await resolveDeclaredServices(args)).find((s) => s.name === name)?.health : undefined);
|
|
249
277
|
|
|
250
278
|
if (args.id) {
|
|
251
279
|
const client = http ?? defaultSpritesHttp(args.token);
|