@intentius/chant 0.70.1 → 0.71.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/cli/handlers/fan-out.d.ts +45 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle.d.ts +13 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +35 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +23 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/deploy-units.d.ts +12 -2
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/fan-out-output.d.ts +70 -0
- package/dist/components/fan-out-output.d.ts.map +1 -0
- package/dist/components/fan-out-run.d.ts +80 -0
- package/dist/components/fan-out-run.d.ts.map +1 -0
- package/dist/components/fan-out-support.d.ts +65 -0
- package/dist/components/fan-out-support.d.ts.map +1 -0
- package/dist/components/fan-out.d.ts +194 -0
- package/dist/components/fan-out.d.ts.map +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +36 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +22 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lifecycle/affected.d.ts +26 -0
- package/dist/lifecycle/affected.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +16 -1
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/shell.d.ts +31 -2
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +1 -1
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-profiles.d.ts +19 -0
- package/dist/op/activity-profiles.d.ts.map +1 -1
- package/dist/op/builders.d.ts +21 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +10 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/step-output-ref.d.ts +25 -8
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/fan-out.test.ts +394 -0
- package/src/cli/handlers/fan-out.ts +336 -0
- package/src/cli/handlers/lifecycle.test.ts +74 -1
- package/src/cli/handlers/lifecycle.ts +29 -1
- package/src/cli/handlers/operator.test.ts +22 -0
- package/src/cli/handlers/operator.ts +11 -3
- package/src/cli/handlers/run.ts +7 -1
- package/src/cli/main.ts +25 -3
- package/src/cli/registry.ts +23 -0
- package/src/components/deploy-units.ts +14 -4
- package/src/components/fan-out-output.test.ts +216 -0
- package/src/components/fan-out-output.ts +162 -0
- package/src/components/fan-out-run.test.ts +194 -0
- package/src/components/fan-out-run.ts +221 -0
- package/src/components/fan-out-support.test.ts +125 -0
- package/src/components/fan-out-support.ts +95 -0
- package/src/components/fan-out.test.ts +284 -0
- package/src/components/fan-out.ts +421 -0
- package/src/components/index.ts +33 -0
- package/src/discovery/fold-import.ts +81 -3
- package/src/fold/subset-public-export.test.ts +31 -0
- package/src/fold/subset.ts +23 -0
- package/src/index.ts +6 -0
- package/src/lifecycle/affected.test.ts +118 -0
- package/src/lifecycle/affected.ts +118 -14
- package/src/op/activities/activity-contracts.ts +17 -1
- package/src/op/activities/index.ts +1 -1
- package/src/op/activities/shell.test.ts +156 -0
- package/src/op/activities/shell.ts +84 -9
- package/src/op/activity-profiles.test.ts +16 -2
- package/src/op/activity-profiles.ts +18 -0
- package/src/op/builders.ts +22 -4
- package/src/op/gate-name.ts +11 -0
- package/src/op/op-ir.test.ts +4 -1
- package/src/op/op.test.ts +7 -2
- package/src/op/step-output-ref.ts +29 -8
|
@@ -138,6 +138,124 @@ describe("affectedStacks — baseDir (caller-supplied)", () => {
|
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
// A second lexicon, so a stack that serializes through two of them can be shown
|
|
142
|
+
// folding into one artifact.
|
|
143
|
+
const fakeSerializer2: Serializer = {
|
|
144
|
+
name: "fake2",
|
|
145
|
+
rulePrefix: "FAKE2",
|
|
146
|
+
serialize: (entities) =>
|
|
147
|
+
JSON.stringify([...entities.keys()].sort().map((k) => ({ k, t: entities.get(k)!.entityType }))),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function otherWidget(type: string): string {
|
|
151
|
+
return `export const bar = { lexicon: "fake2", entityType: "${type}", [Symbol.for("chant.declarable")]: true };\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function deployTimeParam(): string {
|
|
155
|
+
return `export const p = { lexicon: "fake", entityType: "Param", parameterType: "String", [Symbol.for("chant.declarable")]: true };\n`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
describe("affectedStacks — per-stack mode (#2420)", () => {
|
|
159
|
+
let root: string;
|
|
160
|
+
beforeEach(() => {
|
|
161
|
+
root = mkdtempSync(join(tmpdir(), "chant-affected-stacks-"));
|
|
162
|
+
});
|
|
163
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
164
|
+
|
|
165
|
+
// A project root holding one source directory per stack, as ChantConfig.stacks
|
|
166
|
+
// describes it: { api: <infra.ts contents>, worker: ... }.
|
|
167
|
+
const project = (name: string, sources: Record<string, string>): string => {
|
|
168
|
+
const dir = join(root, name);
|
|
169
|
+
for (const [stack, content] of Object.entries(sources)) {
|
|
170
|
+
mkdirSync(join(dir, stack), { recursive: true });
|
|
171
|
+
writeFileSync(join(dir, stack, "infra.ts"), content);
|
|
172
|
+
}
|
|
173
|
+
return dir;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const stacks = [
|
|
177
|
+
{ name: "api-stack", src: "api" },
|
|
178
|
+
{ name: "worker-stack", src: "worker" },
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
test("a stacks[] entry whose src is not there is refused by name at head", async () => {
|
|
182
|
+
const base = project("base", { api: widget("Widget"), worker: widget("Queue") });
|
|
183
|
+
const head = project("head", { api: widget("Gadget") });
|
|
184
|
+
await expect(
|
|
185
|
+
affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks }),
|
|
186
|
+
).rejects.toThrow(/stack "worker-stack" declares src "worker", which does not exist/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a stack added since base is changed, rather than refused for having no base source", async () => {
|
|
190
|
+
const base = project("base", { api: widget("Widget") });
|
|
191
|
+
const head = project("head", { api: widget("Widget"), worker: widget("Queue") });
|
|
192
|
+
const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks });
|
|
193
|
+
expect(r.changed).toEqual(["worker-stack"]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("names the changed stack, not its lexicon — and leaves the untouched stack out", async () => {
|
|
197
|
+
const base = project("base", { api: widget("Widget"), worker: widget("Queue") });
|
|
198
|
+
const head = project("head", { api: widget("Gadget"), worker: widget("Queue") });
|
|
199
|
+
const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks });
|
|
200
|
+
expect(r.changed).toEqual(["api-stack"]);
|
|
201
|
+
expect(r.changed).not.toContain("fake"); // the lexicon name is not an answer
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("a no-output-change refactor in the changed stack's own directory is NOT affected", async () => {
|
|
205
|
+
const base = project("base", { api: widget("Widget"), worker: widget("Queue") });
|
|
206
|
+
const head = project("head", { api: "// a harmless refactor\n" + widget("Widget"), worker: widget("Queue") });
|
|
207
|
+
const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks });
|
|
208
|
+
expect(r.changed).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a stack spanning two lexicons folds into one artifact keyed by the stack", async () => {
|
|
212
|
+
const both = (type: string) => widget("Widget") + otherWidget(type);
|
|
213
|
+
const base = project("base", { api: both("Topic"), worker: widget("Queue") });
|
|
214
|
+
// Only the second lexicon's partition moves; the stack is still what changed.
|
|
215
|
+
const head = project("head", { api: both("Bus"), worker: widget("Queue") });
|
|
216
|
+
const r = await affectedStacks({
|
|
217
|
+
projectPath: head,
|
|
218
|
+
baseDir: base,
|
|
219
|
+
serializers: [fakeSerializer, fakeSerializer2],
|
|
220
|
+
stacks,
|
|
221
|
+
});
|
|
222
|
+
expect(r.changed).toEqual(["api-stack"]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("a deploy-time Parameter is reported as indeterminate under the stack name", async () => {
|
|
226
|
+
const base = project("base", { api: deployTimeParam(), worker: widget("Queue") });
|
|
227
|
+
const head = project("head", { api: deployTimeParam(), worker: widget("Queue") });
|
|
228
|
+
const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks });
|
|
229
|
+
expect(r.indeterminate).toEqual(["api-stack"]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("dependents stay empty — the stack-to-stack relation is not in the build", async () => {
|
|
233
|
+
const base = project("base", { api: widget("Widget"), worker: widget("Queue") });
|
|
234
|
+
const head = project("head", { api: widget("Gadget"), worker: widget("Queue") });
|
|
235
|
+
const r = await affectedStacks({
|
|
236
|
+
projectPath: head,
|
|
237
|
+
baseDir: base,
|
|
238
|
+
serializers: [fakeSerializer],
|
|
239
|
+
stacks,
|
|
240
|
+
includeDependents: true,
|
|
241
|
+
});
|
|
242
|
+
expect(r.changed).toEqual(["api-stack"]);
|
|
243
|
+
expect(r.dependents).toEqual([]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("an empty stacks list keeps the single-root, lexicon-keyed answer", async () => {
|
|
247
|
+
const base = project("base", { api: widget("Widget") });
|
|
248
|
+
const head = project("head", { api: widget("Gadget") });
|
|
249
|
+
const r = await affectedStacks({
|
|
250
|
+
projectPath: join(head, "api"),
|
|
251
|
+
baseDir: join(base, "api"),
|
|
252
|
+
serializers: [fakeSerializer],
|
|
253
|
+
stacks: [],
|
|
254
|
+
});
|
|
255
|
+
expect(r.changed).toEqual(["fake"]);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
141
259
|
describe("affectedStacks — baseRef (git worktree)", () => {
|
|
142
260
|
let repo: string;
|
|
143
261
|
beforeEach(async () => {
|
|
@@ -15,8 +15,19 @@
|
|
|
15
15
|
* be judged from a source diff — reported as **indeterminate**, not silently
|
|
16
16
|
* included or excluded.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* ## What "stack" means here depends on the project
|
|
19
|
+
*
|
|
20
|
+
* A project that declares `stacks` in `chant.config.ts` gets each declared
|
|
21
|
+
* stack's own source built, and the answer names stacks. A single-root project
|
|
22
|
+
* has no such partition to build, so its answer names lexicon partitions, which
|
|
23
|
+
* is the only granularity it has. The difference matters downstream: a
|
|
24
|
+
* component's deploy step names a stack, so only the first kind of answer can
|
|
25
|
+
* be joined to components (#2420).
|
|
26
|
+
*
|
|
27
|
+
* This returns the set; it does not act. `chant components fan-out`
|
|
28
|
+
* (../cli/handlers/fan-out.ts) is the command that acts on it, and fanning
|
|
29
|
+
* `lifecycle plan` / `ApplyOp` over it by hand is still an Op the user
|
|
30
|
+
* composes.
|
|
20
31
|
*/
|
|
21
32
|
import { execFile } from "node:child_process";
|
|
22
33
|
import { promisify } from "node:util";
|
|
@@ -140,10 +151,92 @@ async function withWorktree<T>(repoRoot: string, ref: string, fn: (dir: string)
|
|
|
140
151
|
}
|
|
141
152
|
}
|
|
142
153
|
|
|
154
|
+
/** One independently-deployed stack, as `ChantConfig.stacks` declares it. */
|
|
155
|
+
export interface StackSource {
|
|
156
|
+
/** The deployed stack name — what a component's deploy step names. */
|
|
157
|
+
name: string;
|
|
158
|
+
/** Source directory to build for this stack, relative to the project root. */
|
|
159
|
+
src: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build each declared stack's own source directory and key the result by
|
|
164
|
+
* **stack name**.
|
|
165
|
+
*
|
|
166
|
+
* Without this, one build of the project root produces a map keyed by lexicon
|
|
167
|
+
* partition, so an aws-only estate of fifteen stacks answers "aws changed" no
|
|
168
|
+
* matter which one moved. That answer cannot be joined to anything: a
|
|
169
|
+
* component's deploy step names a stack, not a lexicon, so
|
|
170
|
+
* `componentsForUnits` (../components/fan-out.ts) would claim none of it and
|
|
171
|
+
* a fan-out derived from it would be empty. The two halves have to speak the
|
|
172
|
+
* same names for either to be worth having.
|
|
173
|
+
*
|
|
174
|
+
* A stack that serializes through more than one lexicon folds its partitions
|
|
175
|
+
* into one artifact, because the unit being deployed is the stack.
|
|
176
|
+
*/
|
|
177
|
+
async function stackArtifacts(
|
|
178
|
+
root: string,
|
|
179
|
+
serializers: Serializer[],
|
|
180
|
+
stacks: readonly StackSource[],
|
|
181
|
+
/**
|
|
182
|
+
* Whether a missing `src` is an error. True for the head side, where every
|
|
183
|
+
* declared stack must exist; false for the base side, where a stack added
|
|
184
|
+
* since then legitimately has no source yet and reads as an empty artifact,
|
|
185
|
+
* which is what makes it `changed`.
|
|
186
|
+
*/
|
|
187
|
+
requireSrc: boolean,
|
|
188
|
+
): Promise<{ artifacts: Map<string, string>; externalInput: string[] }> {
|
|
189
|
+
const artifacts = new Map<string, string>();
|
|
190
|
+
const externalInput: string[] = [];
|
|
191
|
+
for (const stack of stacks) {
|
|
192
|
+
const src = resolve(root, stack.src);
|
|
193
|
+
// `build()` on a directory that is not there returns empty outputs rather
|
|
194
|
+
// than throwing, so a typo in `src` would make both sides build nothing,
|
|
195
|
+
// match, and report the stack as unchanged forever. A silent answer of
|
|
196
|
+
// exactly that shape is what this whole path exists to prevent.
|
|
197
|
+
if (requireSrc && !existsSync(src)) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`stack "${stack.name}" declares src "${stack.src}", which does not exist under ${root}. ` +
|
|
200
|
+
`Fix chant.config.ts's stacks[] entry: a source directory that is not there builds to nothing, ` +
|
|
201
|
+
`and a stack that builds to nothing can never be reported as changed.`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const built = await build(src, serializers);
|
|
205
|
+
artifacts.set(
|
|
206
|
+
stack.name,
|
|
207
|
+
[...built.outputs.entries()]
|
|
208
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
209
|
+
.map(([lexicon, output]) => `${lexicon}\n${getPrimaryOutput(output as string)}`)
|
|
210
|
+
.join("\n"),
|
|
211
|
+
);
|
|
212
|
+
if (externalInputStacks(built.entities).length > 0) externalInput.push(stack.name);
|
|
213
|
+
}
|
|
214
|
+
return { artifacts, externalInput: externalInput.sort() };
|
|
215
|
+
}
|
|
216
|
+
|
|
143
217
|
export interface AffectedStacksOptions {
|
|
144
218
|
/** Project source directory to scope (the head/working-tree build root). */
|
|
145
219
|
projectPath: string;
|
|
146
220
|
serializers: Serializer[];
|
|
221
|
+
/**
|
|
222
|
+
* The project's independently-deployed stacks (`ChantConfig.stacks`). With
|
|
223
|
+
* them, each stack's own source is built and the answer names stacks; without
|
|
224
|
+
* them, one build of `projectPath` answers by lexicon partition, which is the
|
|
225
|
+
* only granularity a single-root project has.
|
|
226
|
+
*
|
|
227
|
+
* `dependents` is always empty in this mode, and that is not an omission: the
|
|
228
|
+
* build holds cross-*lexicon* edges, and the relation between two deployed
|
|
229
|
+
* stacks is stated by a component's `dependsOn` and `stackOutput`, which
|
|
230
|
+
* `chant components fan-out` walks for itself. Inventing stack edges from
|
|
231
|
+
* lexicon ones would be a guess dressed as a graph.
|
|
232
|
+
*
|
|
233
|
+
* Each `src` resolves against `projectPath`, so in this mode `projectPath`
|
|
234
|
+
* has to be the project root that `ChantConfig.stacks` is written relative
|
|
235
|
+
* to. A `sourceDir` that narrows the build root does not apply here: the
|
|
236
|
+
* stacks already say which source belongs to which of them, which is the
|
|
237
|
+
* narrowing, and applying both would look for `<sourceDir>/<stack.src>`.
|
|
238
|
+
*/
|
|
239
|
+
stacks?: readonly StackSource[];
|
|
147
240
|
/** Base git ref to diff against (built in a throwaway worktree). */
|
|
148
241
|
baseRef?: string;
|
|
149
242
|
/** Head git ref. Defaults to the working tree (built in place — no worktree). */
|
|
@@ -175,28 +268,39 @@ export async function affectedStacks(opts: AffectedStacksOptions): Promise<Affec
|
|
|
175
268
|
// Head: the in-place build of the working tree, unless an explicit headRef is
|
|
176
269
|
// given (then a throwaway worktree — removed before the base worktree, so at
|
|
177
270
|
// most one exists at a time).
|
|
271
|
+
const perStack = opts.stacks && opts.stacks.length > 0 ? opts.stacks : undefined;
|
|
272
|
+
// No cross-stack graph in per-stack mode, by construction: see `stacks` above.
|
|
273
|
+
const EMPTY_GRAPH: StackGraph = { nodes: [], edges: [], order: [], waves: [], cycles: [] };
|
|
274
|
+
/** One build root in, its artifact map and external-input set out, at whichever granularity applies. */
|
|
275
|
+
const readArtifacts = async (
|
|
276
|
+
root: string,
|
|
277
|
+
isHead: boolean,
|
|
278
|
+
): Promise<{ artifacts: Map<string, string>; externalInput: string[]; graph: StackGraph }> => {
|
|
279
|
+
if (perStack) return { ...(await stackArtifacts(root, opts.serializers, perStack, isHead)), graph: EMPTY_GRAPH };
|
|
280
|
+
const built = await build(root, opts.serializers);
|
|
281
|
+
return {
|
|
282
|
+
artifacts: artifactMap(built.outputs),
|
|
283
|
+
externalInput: externalInputStacks(built.entities),
|
|
284
|
+
graph: built.manifest.stackGraph,
|
|
285
|
+
};
|
|
286
|
+
};
|
|
287
|
+
|
|
178
288
|
const head = opts.headRef
|
|
179
|
-
? await withWorktree(repoRoot, opts.headRef, (dir) =>
|
|
180
|
-
: await
|
|
181
|
-
const headMap = artifactMap(head.outputs);
|
|
182
|
-
const externalInput = externalInputStacks(head.entities);
|
|
289
|
+
? await withWorktree(repoRoot, opts.headRef, (dir) => readArtifacts(join(dir, relProject), true))
|
|
290
|
+
: await readArtifacts(projectPath, true);
|
|
183
291
|
|
|
184
292
|
// Base: caller-supplied dir (cheapest), else a single worktree at baseRef.
|
|
185
293
|
let baseMap: Map<string, string>;
|
|
186
294
|
if (opts.baseDir) {
|
|
187
|
-
|
|
188
|
-
baseMap = artifactMap(baseBuild.outputs);
|
|
295
|
+
baseMap = (await readArtifacts(resolve(opts.baseDir), false)).artifacts;
|
|
189
296
|
} else if (opts.baseRef) {
|
|
190
|
-
baseMap = await withWorktree(repoRoot, opts.baseRef, async (dir) =>
|
|
191
|
-
const baseBuild = await build(join(dir, relProject), opts.serializers);
|
|
192
|
-
return artifactMap(baseBuild.outputs);
|
|
193
|
-
});
|
|
297
|
+
baseMap = await withWorktree(repoRoot, opts.baseRef, async (dir) => (await readArtifacts(join(dir, relProject), false)).artifacts);
|
|
194
298
|
} else {
|
|
195
299
|
throw new Error("affectedStacks requires either baseDir or baseRef");
|
|
196
300
|
}
|
|
197
301
|
|
|
198
|
-
return computeAffected(baseMap,
|
|
302
|
+
return computeAffected(baseMap, head.artifacts, head.graph, {
|
|
199
303
|
includeDependents: opts.includeDependents,
|
|
200
|
-
externalInput,
|
|
304
|
+
externalInput: head.externalInput,
|
|
201
305
|
});
|
|
202
306
|
}
|
|
@@ -37,9 +37,25 @@ export const lifecycleDiffContract = activityContract(
|
|
|
37
37
|
z.object({ output: z.string(), exitCode: z.number(), drifted: z.boolean() }),
|
|
38
38
|
);
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The escape hatch (#2413). `returns` is what `shellCmd` captured and used to
|
|
42
|
+
* throw away: the trimmed stdout, the trimmed stderr, and the exit code —
|
|
43
|
+
* which only ever differs from `0` when the step named that code in `okExit`,
|
|
44
|
+
* since anything else still rejects.
|
|
45
|
+
*
|
|
46
|
+
* Running a command chant does not model in order to discard what it produced
|
|
47
|
+
* is the odd case, not the normal one, so the value a later step reads through
|
|
48
|
+
* `sh.out.stdout` is the point of the step rather than an extra.
|
|
49
|
+
*/
|
|
40
50
|
export const shellCmdContract = activityContract(
|
|
41
51
|
"shellCmd",
|
|
42
|
-
z.strictObject({
|
|
52
|
+
z.strictObject({
|
|
53
|
+
cmd: z.string(),
|
|
54
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
55
|
+
cwd: z.string().optional(),
|
|
56
|
+
okExit: z.array(z.number()).optional(),
|
|
57
|
+
}),
|
|
58
|
+
z.object({ stdout: z.string(), stderr: z.string(), exitCode: z.number() }),
|
|
43
59
|
);
|
|
44
60
|
|
|
45
61
|
export const httpCheckContract = activityContract(
|
|
@@ -14,7 +14,7 @@ export type { WaitForStackArgs } from "./wait";
|
|
|
14
14
|
// provides gitlabPipeline. The gitlabPipeline step builder stays in core.
|
|
15
15
|
|
|
16
16
|
export { shellCmd } from "./shell";
|
|
17
|
-
export type { ShellCmdArgs } from "./shell";
|
|
17
|
+
export type { ShellCmdArgs, ShellCmdResult } from "./shell";
|
|
18
18
|
|
|
19
19
|
export { httpCheck, statusOk } from "./http-check";
|
|
20
20
|
export type { HttpCheckArgs, HttpFetch } from "./http-check";
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The escape hatch's safety properties (#2411, #2412) and what it publishes
|
|
3
|
+
* (#2413, #2414).
|
|
4
|
+
*
|
|
5
|
+
* `shellCmd` runs a command chant did not write and cannot model, which is
|
|
6
|
+
* what makes these different from every other activity: nothing here can know
|
|
7
|
+
* whether repeating the command is safe, how much it will say, or what a
|
|
8
|
+
* non-zero exit means.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, test, expect } from "vitest";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { shellCmd } from "./shell";
|
|
14
|
+
import { shellCmdContract } from "./activity-contracts";
|
|
15
|
+
import { shell } from "../builders";
|
|
16
|
+
import { phase } from "../builders";
|
|
17
|
+
import { collectStepOutputRefs, stepOutput, validateStepOutputRefs } from "../step-output-ref";
|
|
18
|
+
import { ACTIVITY_PROFILES } from "../activity-profiles";
|
|
19
|
+
import { runOpLocally } from "../local-executor";
|
|
20
|
+
import { memoryGateLedgerPort } from "../gate";
|
|
21
|
+
import type { ActivityFn } from "../activity-registry";
|
|
22
|
+
import type { ShellCmdArgs, ShellCmdResult } from "./shell";
|
|
23
|
+
|
|
24
|
+
describe("the at-most-once default (#2411)", () => {
|
|
25
|
+
test("shell() asks for a profile that does not retry", () => {
|
|
26
|
+
const step = shell("echo hi");
|
|
27
|
+
expect(step.profile).toBe("atMostOnce");
|
|
28
|
+
expect(ACTIVITY_PROFILES.atMostOnce.retry.maximumAttempts).toBe(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("an author who knows the command is safe to repeat can still say so", () => {
|
|
32
|
+
// The direction that matters: retrying is opt-in, because it is the claim
|
|
33
|
+
// that needs evidence about the command.
|
|
34
|
+
expect(shell("echo hi", { profile: "fastIdempotent" }).profile).toBe("fastIdempotent");
|
|
35
|
+
expect(ACTIVITY_PROFILES.fastIdempotent.retry.maximumAttempts).toBeGreaterThan(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the profile allows a long command, since a shell step is often a build", () => {
|
|
39
|
+
expect(ACTIVITY_PROFILES.atMostOnce.timeout).toBe("20m");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("the stdout ceiling (#2412)", () => {
|
|
44
|
+
test("a command producing well over 1 MiB completes instead of rejecting", async () => {
|
|
45
|
+
// Node's default maxBuffer is 1 MiB and this activity was the only exec
|
|
46
|
+
// site leaving it unset. 4 MiB is comfortably past the old ceiling and
|
|
47
|
+
// far short of the new one.
|
|
48
|
+
const bytes = 4 * 1024 * 1024;
|
|
49
|
+
const out = await shellCmd({ cmd: `node -e "process.stdout.write('x'.repeat(${bytes}))"` });
|
|
50
|
+
expect(out.stdout.length).toBe(bytes);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("stdout is returned trimmed, as before", async () => {
|
|
54
|
+
expect((await shellCmd({ cmd: "echo hello" })).stdout).toBe("hello");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("cwd and env reach the command", async () => {
|
|
58
|
+
const out = await shellCmd({ cmd: "pwd && echo $CHANT_SHELL_TEST", cwd: "/tmp", env: { CHANT_SHELL_TEST: "set" } });
|
|
59
|
+
expect(out.stdout).toContain("set");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("what a shell step publishes (#2413)", () => {
|
|
64
|
+
test("the contract declares a return schema, so a later step may reference it", () => {
|
|
65
|
+
const returns = shellCmdContract.returns as z.ZodTypeAny | undefined;
|
|
66
|
+
expect(returns).toBeDefined();
|
|
67
|
+
expect(returns!.parse({ stdout: "a", stderr: "b", exitCode: 0 })).toEqual({ stdout: "a", stderr: "b", exitCode: 0 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a step reading a shell step's stdout passes OPS013", () => {
|
|
71
|
+
const host = shell("echo db.internal", { id: "host" });
|
|
72
|
+
const smoke = shell("./smoke.sh", { env: { HOST: host.out.stdout } });
|
|
73
|
+
const issues = validateStepOutputRefs(
|
|
74
|
+
{ name: "deploy", phases: [phase("Go", [host, smoke])] },
|
|
75
|
+
new Map([["shellCmd", shellCmdContract]]),
|
|
76
|
+
);
|
|
77
|
+
expect(issues).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a path the shell result does not declare is still flagged", () => {
|
|
81
|
+
const host = shell("echo db.internal", { id: "host" });
|
|
82
|
+
const smoke = shell("./smoke.sh", { env: { HOST: stepOutput(host, "stdoutt") } });
|
|
83
|
+
const issues = validateStepOutputRefs(
|
|
84
|
+
{ name: "deploy", phases: [phase("Go", [host, smoke])] },
|
|
85
|
+
new Map([["shellCmd", shellCmdContract]]),
|
|
86
|
+
);
|
|
87
|
+
expect(issues).toHaveLength(1);
|
|
88
|
+
expect(issues[0].message).toContain('output path "stdoutt"');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("stderr survives the call instead of being printed and dropped", async () => {
|
|
92
|
+
const out = await shellCmd({ cmd: `node -e "process.stderr.write('warned')"` });
|
|
93
|
+
expect(out).toMatchObject({ stdout: "", stderr: "warned", exitCode: 0 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a non-zero exit still rejects, and the code is in the message", async () => {
|
|
97
|
+
await expect(shellCmd({ cmd: "exit 3" })).rejects.toThrow(/exited 3 \(expected 0\)/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a code the author named resolves, carrying output and the code", async () => {
|
|
101
|
+
// `diff` exits 1 to report a difference. Nothing about that is a failure,
|
|
102
|
+
// and before this the whole step was one.
|
|
103
|
+
const out = await shellCmd({ cmd: `node -e "process.stdout.write('changed'); process.exit(1)"`, okExit: [0, 1] });
|
|
104
|
+
expect(out).toEqual({ stdout: "changed", stderr: "", exitCode: 1 });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a code the author did not name still rejects", async () => {
|
|
108
|
+
await expect(shellCmd({ cmd: "exit 2", okExit: [0, 1] })).rejects.toThrow(/exited 2 \(expected 0, 1\)/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a signal kill rejects even when its code is named", async () => {
|
|
112
|
+
// A timeout or Ctrl-C is not an exit status the author said anything
|
|
113
|
+
// about, so `okExit` must not swallow it.
|
|
114
|
+
const ac = new AbortController();
|
|
115
|
+
const running = shellCmd({ cmd: "sleep 5", okExit: [0, 1, 143] }, ac.signal);
|
|
116
|
+
ac.abort();
|
|
117
|
+
await expect(running).rejects.toThrow();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("a reference reaching the command (#2414)", () => {
|
|
122
|
+
test("a reference in env typechecks, which is the whole gap", () => {
|
|
123
|
+
const host = shell("echo db.internal", { id: "host" });
|
|
124
|
+
// No `as` cast: before #2414 `WithStepRefs` was shallow, so a reference
|
|
125
|
+
// inside `env`'s Record<string, string> was a type error even though the
|
|
126
|
+
// executor resolved it and the lint rule accepted it.
|
|
127
|
+
const smoke = shell("./smoke.sh", { env: { HOST: host.out.stdout, MODE: "ci" } });
|
|
128
|
+
expect(collectStepOutputRefs(smoke.args)).toEqual([
|
|
129
|
+
expect.objectContaining({ step: "host", path: "stdout" }),
|
|
130
|
+
]);
|
|
131
|
+
expect((smoke.args as { env: Record<string, string> }).env.MODE).toBe("ci");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("the executor carries it into the next command's environment, end to end", async () => {
|
|
135
|
+
const host = shell("echo db.internal", { id: "host" });
|
|
136
|
+
const smoke = shell("echo reached $HOST", { env: { HOST: host.out.stdout }, id: "smoke" });
|
|
137
|
+
|
|
138
|
+
const seen: ShellCmdResult[] = [];
|
|
139
|
+
const spy: ActivityFn = async (args, signal) => {
|
|
140
|
+
const out = await shellCmd(args as unknown as ShellCmdArgs, signal);
|
|
141
|
+
seen.push(out);
|
|
142
|
+
return out;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const result = await runOpLocally(
|
|
146
|
+
{ name: "deploy", overview: "", phases: [phase("Go", [host, smoke])] },
|
|
147
|
+
new Map([["shellCmd", spy]]),
|
|
148
|
+
ACTIVITY_PROFILES,
|
|
149
|
+
undefined,
|
|
150
|
+
{ gates: memoryGateLedgerPort() },
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(result.status).toBe("ok");
|
|
154
|
+
expect(seen.map((s) => s.stdout)).toEqual(["db.internal", "reached db.internal"]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -9,18 +9,93 @@ export interface ShellCmdArgs {
|
|
|
9
9
|
env?: Record<string, string>;
|
|
10
10
|
/** Working directory. Default: process.cwd(). */
|
|
11
11
|
cwd?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Exit codes that count as success. Default `[0]`.
|
|
14
|
+
*
|
|
15
|
+
* Without this, `exitCode` in the result could only ever be `0` (#2413):
|
|
16
|
+
* every other code rejects, so nothing downstream can read it. Naming the
|
|
17
|
+
* codes a command uses to report a result — `diff`'s 1, `grep`'s 1,
|
|
18
|
+
* a migration tool's "nothing to do" — turns them into a value a later
|
|
19
|
+
* step can branch on instead of a failed Op.
|
|
20
|
+
*/
|
|
21
|
+
okExit?: number[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How much stdout a command may produce, matching every other exec site in
|
|
26
|
+
* the tree (`../activities/apply.ts`, the terraform lexicon's activities,
|
|
27
|
+
* `../../components/verbs/process-runner.ts`).
|
|
28
|
+
*
|
|
29
|
+
* Node's default is 1 MiB, and this was the only exec call leaving it unset
|
|
30
|
+
* (#2412) — on the one activity whose output chant cannot predict, because
|
|
31
|
+
* the command is the author's. A verbose test run, a playbook or a wide plan
|
|
32
|
+
* passes 1 MiB without trying, and the overflow is a rejection rather than a
|
|
33
|
+
* truncation, so the step fails for a reason that has nothing to do with the
|
|
34
|
+
* command.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_STDOUT_BYTES = 64 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What a shell step publishes to the steps after it (#2413).
|
|
40
|
+
*
|
|
41
|
+
* `stdout` and `stderr` are trimmed, because the value an author wants from
|
|
42
|
+
* `echo $(terraform output -raw host)` is the host, not the host plus a
|
|
43
|
+
* newline, and a trailing newline in an `env` value or a gate argument is a
|
|
44
|
+
* bug that is very hard to see.
|
|
45
|
+
*
|
|
46
|
+
* `stderr` is here as well as on the console. It was captured and dropped
|
|
47
|
+
* before, so a command that reports on stderr — every tool that prints
|
|
48
|
+
* progress there — had no route to a later step at all.
|
|
49
|
+
*/
|
|
50
|
+
export interface ShellCmdResult {
|
|
51
|
+
stdout: string;
|
|
52
|
+
stderr: string;
|
|
53
|
+
exitCode: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Node hangs the exit status off the error as `code`, and a signal kill as `signal`. */
|
|
57
|
+
interface ExecFailure extends Error {
|
|
58
|
+
code?: number | string;
|
|
59
|
+
signal?: string;
|
|
60
|
+
killed?: boolean;
|
|
61
|
+
stdout?: string;
|
|
62
|
+
stderr?: string;
|
|
12
63
|
}
|
|
13
64
|
|
|
14
65
|
/**
|
|
15
66
|
* Run an arbitrary shell command.
|
|
16
|
-
*
|
|
67
|
+
*
|
|
68
|
+
* Runs under the `atMostOnce` profile by default (#2411): one attempt, since
|
|
69
|
+
* nothing here can know whether repeating the author's command is safe.
|
|
17
70
|
*/
|
|
18
|
-
export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promise<
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
71
|
+
export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promise<ShellCmdResult> {
|
|
72
|
+
const okExit = args.okExit ?? [0];
|
|
73
|
+
try {
|
|
74
|
+
const { stdout, stderr } = await execAsync(args.cmd, {
|
|
75
|
+
cwd: args.cwd,
|
|
76
|
+
env: { ...process.env, ...args.env },
|
|
77
|
+
maxBuffer: MAX_STDOUT_BYTES,
|
|
78
|
+
signal,
|
|
79
|
+
});
|
|
80
|
+
if (stderr) console.error(stderr);
|
|
81
|
+
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 };
|
|
82
|
+
} catch (err) {
|
|
83
|
+
const failure = err as ExecFailure;
|
|
84
|
+
const exitCode = typeof failure.code === "number" ? failure.code : undefined;
|
|
85
|
+
// A signal kill (Ctrl-C, the profile's timeout, a maxBuffer overflow) is
|
|
86
|
+
// not an exit status the author declared anything about, so it rethrows
|
|
87
|
+
// even if `okExit` happens to contain the code.
|
|
88
|
+
const died = failure.killed === true || typeof failure.signal === "string" || signal?.aborted === true;
|
|
89
|
+
if (exitCode !== undefined && !died && okExit.includes(exitCode)) {
|
|
90
|
+
const stderrText = (failure.stderr ?? "").trim();
|
|
91
|
+
if (stderrText) console.error(stderrText);
|
|
92
|
+
return { stdout: (failure.stdout ?? "").trim(), stderr: stderrText, exitCode };
|
|
93
|
+
}
|
|
94
|
+
if (exitCode !== undefined && !died) {
|
|
95
|
+
// `local-executor` records `error` as message text, so the code has to
|
|
96
|
+
// be in the message or it is gone (#2413).
|
|
97
|
+
failure.message = `command exited ${exitCode} (expected ${okExit.join(", ")}): ${failure.message}`;
|
|
98
|
+
}
|
|
99
|
+
throw failure;
|
|
100
|
+
}
|
|
26
101
|
}
|
|
@@ -10,12 +10,26 @@ import { activity } from "./builders";
|
|
|
10
10
|
* in an in-process step, so it did not come along.
|
|
11
11
|
*/
|
|
12
12
|
describe("ACTIVITY_PROFILES", () => {
|
|
13
|
-
test("carries the
|
|
13
|
+
test("carries the seven named profiles", () => {
|
|
14
14
|
expect(Object.keys(ACTIVITY_PROFILES).sort()).toEqual(
|
|
15
|
-
["argoSync", "fastIdempotent", "humanGate", "k8sWait", "longInfra", "policyCheck"],
|
|
15
|
+
["argoSync", "atMostOnce", "fastIdempotent", "humanGate", "k8sWait", "longInfra", "policyCheck"],
|
|
16
16
|
);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
test("atMostOnce runs once, and is the only non-gate profile that does (#2411)", () => {
|
|
20
|
+
expect(ACTIVITY_PROFILES.atMostOnce.retry.maximumAttempts).toBe(1);
|
|
21
|
+
// The two other single-attempt profiles carry semantics a shell step must
|
|
22
|
+
// not borrow: a gate's single attempt is about not re-asking a human, and
|
|
23
|
+
// a policy check's is about not re-running an evaluation. A run ledger
|
|
24
|
+
// that called a shell step either of those would be saying something
|
|
25
|
+
// false about what ran.
|
|
26
|
+
const singleAttempt = Object.entries(ACTIVITY_PROFILES)
|
|
27
|
+
.filter(([, p]) => p.retry.maximumAttempts === 1)
|
|
28
|
+
.map(([name]) => name)
|
|
29
|
+
.sort();
|
|
30
|
+
expect(singleAttempt).toEqual(["atMostOnce", "humanGate", "policyCheck"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
19
33
|
test("every profile has a timeout, and none has a worker-era field", () => {
|
|
20
34
|
for (const [name, profile] of Object.entries(ACTIVITY_PROFILES)) {
|
|
21
35
|
expect(typeof profile.timeout, `${name}.timeout`).toBe("string");
|
|
@@ -72,6 +72,24 @@ export const ACTIVITY_PROFILES = {
|
|
|
72
72
|
},
|
|
73
73
|
},
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* A command chant did not write and cannot know is safe to repeat (#2411).
|
|
77
|
+
*
|
|
78
|
+
* `shellCmd` is the escape hatch: its whole purpose is to run something
|
|
79
|
+
* outside the model, so nothing here can judge whether a second attempt is
|
|
80
|
+
* harmless or a second deployment. Every other activity carrying a retrying
|
|
81
|
+
* profile is one chant authored and knows the shape of.
|
|
82
|
+
*
|
|
83
|
+
* Twenty minutes, because a shell step is as likely to be a long build as a
|
|
84
|
+
* quick script, and one attempt, because retrying is the claim that needs
|
|
85
|
+
* evidence. An author who knows their command is idempotent names
|
|
86
|
+
* `fastIdempotent` or `longInfra` and gets retries back.
|
|
87
|
+
*/
|
|
88
|
+
atMostOnce: {
|
|
89
|
+
timeout: "20m",
|
|
90
|
+
retry: { maximumAttempts: 1 },
|
|
91
|
+
},
|
|
92
|
+
|
|
75
93
|
/**
|
|
76
94
|
* Human-gate steps: waiting for an operator action (DNS delegation, approval).
|
|
77
95
|
* Very long timeout, single attempt — no retry on human-gate timeouts.
|