@intentius/chant 0.70.1 → 0.71.1
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 +2 -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/meta/declared-imports.test.ts +141 -0
- 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
|
@@ -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.
|
package/src/op/builders.ts
CHANGED
|
@@ -306,16 +306,34 @@ export const lifecycleSnapshot = (env: string, opts?: { id?: string }): NamedAct
|
|
|
306
306
|
activity("lifecycleSnapshot", { env } satisfies LifecycleSnapshotArgs, opts?.id ? { id: opts.id } : undefined);
|
|
307
307
|
|
|
308
308
|
/**
|
|
309
|
-
* Run an arbitrary shell command.
|
|
310
|
-
*
|
|
311
|
-
*
|
|
309
|
+
* Run an arbitrary shell command.
|
|
310
|
+
*
|
|
311
|
+
* Defaults to the `atMostOnce` profile: twenty minutes, one attempt (#2411).
|
|
312
|
+
* This is the one activity chant cannot know is idempotent, because its
|
|
313
|
+
* purpose is to run something chant does not model, so a failed command is
|
|
314
|
+
* not repeated unless the author says it may be. Name `fastIdempotent` or
|
|
315
|
+
* `longInfra` to get retries back for a command that is safe to repeat.
|
|
316
|
+
*
|
|
317
|
+
* Give the step an `id` and later steps can read what it produced —
|
|
318
|
+
* `sh.out.stdout`, `sh.out.stderr`, `sh.out.exitCode` (#2413) — and a value
|
|
319
|
+
* from an earlier step reaches the command through `env` (#2414):
|
|
320
|
+
*
|
|
321
|
+
* ```ts
|
|
322
|
+
* const host = shell("terraform output -raw host", { id: "host" });
|
|
323
|
+
* shell("./smoke.sh", { env: { HOST: host.out.stdout } });
|
|
324
|
+
* ```
|
|
325
|
+
*
|
|
326
|
+
* `cmd` stays a plain `string` and takes no references. A value spliced into
|
|
327
|
+
* a command line is a quoting decision chant would then be making on the
|
|
328
|
+
* author's behalf, and `env` carries the same value into the same command
|
|
329
|
+
* with the shell's own rules intact.
|
|
312
330
|
*/
|
|
313
331
|
export const shell = (
|
|
314
332
|
cmd: string,
|
|
315
333
|
opts?: WithStepRefs<Omit<ShellCmdArgs, "cmd">> & StepOpts,
|
|
316
334
|
): NamedActivityStep => {
|
|
317
335
|
const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
|
|
318
|
-
return activity("shellCmd", { cmd, ...args }, {
|
|
336
|
+
return activity("shellCmd", { cmd, ...args }, { profile: profile ?? "atMostOnce", ...(id ? { id } : {}) });
|
|
319
337
|
};
|
|
320
338
|
|
|
321
339
|
/**
|
package/src/op/gate-name.ts
CHANGED
|
@@ -18,6 +18,17 @@
|
|
|
18
18
|
|
|
19
19
|
import type { OpConfig, PhaseDefinition, StepDefinition } from "./types";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The op name a `chant components fan-out` gate is recorded under (#2420).
|
|
23
|
+
*
|
|
24
|
+
* A fan-out is a command rather than an authored `*.op.ts`, so `chant approve`
|
|
25
|
+
* has nothing to discover for this name and must not report that as a problem.
|
|
26
|
+
* It lives here, next to the other thing that decides what a gate is called,
|
|
27
|
+
* so both the command that records the pending fact and the command that
|
|
28
|
+
* answers it read one constant instead of agreeing by hand.
|
|
29
|
+
*/
|
|
30
|
+
export const FAN_OUT_GATE_OP = "fan-out";
|
|
31
|
+
|
|
21
32
|
/** Either spelling of a gate step's name. `gate` since #2202; `signalName` through 0.59.0. */
|
|
22
33
|
export interface GateNamed {
|
|
23
34
|
gate?: string;
|
package/src/op/op-ir.test.ts
CHANGED
|
@@ -123,7 +123,10 @@ describe("op.json IR", () => {
|
|
|
123
123
|
|
|
124
124
|
expect(ir.name).toBe("full-deploy");
|
|
125
125
|
const [build, approve, deploy, seed, verify] = ir.phases;
|
|
126
|
-
|
|
126
|
+
// A shell step carries `atMostOnce` since #2411: the IR records the
|
|
127
|
+
// profile the builder named, and the builder names one precisely so a
|
|
128
|
+
// command chant cannot judge does not inherit a retrying default.
|
|
129
|
+
expect(build.steps[0]).toMatchObject({ kind: "activity", fn: "shellCmd", profile: "atMostOnce" });
|
|
127
130
|
expect(approve.steps[0]).toMatchObject({
|
|
128
131
|
kind: "gate",
|
|
129
132
|
gate: "approve-deploy",
|
package/src/op/op.test.ts
CHANGED
|
@@ -266,10 +266,15 @@ describe("profile routing (opts.profile sets the step profile, never leaks into
|
|
|
266
266
|
expect("profile" in (a.args ?? {})).toBe(false);
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
-
it("shell() without profile
|
|
269
|
+
it("shell() without profile names atMostOnce, so the retrying default cannot apply (#2411)", () => {
|
|
270
|
+
// It used to leave the step unprofiled and let the executor's default
|
|
271
|
+
// apply, which meant the one activity chant cannot know is idempotent
|
|
272
|
+
// inherited three attempts by omission. The builder names its profile now,
|
|
273
|
+
// the way terraformPlan and terraformApply already did.
|
|
270
274
|
const a = shell("echo hi", { env: { A: "1" } });
|
|
271
|
-
expect(
|
|
275
|
+
expect(a.profile).toBe("atMostOnce");
|
|
272
276
|
expect(a.args?.env).toEqual({ A: "1" });
|
|
277
|
+
expect("profile" in (a.args ?? {})).toBe(false);
|
|
273
278
|
});
|
|
274
279
|
|
|
275
280
|
it("build() accepts a profile override and does not leak it into args", () => {
|
|
@@ -112,15 +112,36 @@ export interface StepOutputRef {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
|
-
* `
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* `
|
|
121
|
-
* `
|
|
115
|
+
* `V`, or a {@link StepOutputRef} in its place, at every position inside it.
|
|
116
|
+
*
|
|
117
|
+
* Recurses through arrays and plain objects — including an index signature,
|
|
118
|
+
* which is the case that motivated it (#2414): `shell()`'s `env` is a
|
|
119
|
+
* `Record<string, string>`, and a reference in one of its values resolves at
|
|
120
|
+
* run time (`local-executor`'s `resolveStepOutputRefs` deep-walks `args`) and
|
|
121
|
+
* passes OPS012 (`activity-contract.ts` skips a reference at the issue path),
|
|
122
|
+
* so the compiler was the only one of the three layers rejecting it.
|
|
123
|
+
*
|
|
124
|
+
* Functions are left alone rather than mapped — nothing in an activity's args
|
|
125
|
+
* is callable, and mapping one would silently strip its call signature.
|
|
126
|
+
*/
|
|
127
|
+
type StepRefAt<V> =
|
|
128
|
+
| StepOutputRef
|
|
129
|
+
| (V extends Function ? V
|
|
130
|
+
: V extends readonly (infer E)[] ? (V extends unknown[] ? StepRefAt<E>[] : readonly StepRefAt<E>[])
|
|
131
|
+
: V extends object ? { [K in keyof V]: StepRefAt<V[K]> }
|
|
132
|
+
: V);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `T` with every property — at any depth — additionally accepting a {@link
|
|
136
|
+
* StepOutputRef} in its place (chant #1288 Stage 2, deepened in #2414) — the
|
|
137
|
+
* authoring-time counterpart of `args` accepting a reference anywhere in the
|
|
138
|
+
* structure (#1290): a typed step-builder wrapper whose opts type is
|
|
139
|
+
* `WithStepRefs<SomeActivityArgs>` lets an author pass
|
|
140
|
+
* `diff.out.driftedStacks` for any field without an `as` cast, while runtime
|
|
141
|
+
* validation of the reference itself is still `validateStepOutputRefs`' job,
|
|
142
|
+
* not this type's.
|
|
122
143
|
*/
|
|
123
|
-
export type WithStepRefs<T> = { [K in keyof T]: T[K]
|
|
144
|
+
export type WithStepRefs<T> = { [K in keyof T]: StepRefAt<T[K]> };
|
|
124
145
|
|
|
125
146
|
/** Structural guard for a value produced by {@link stepOutput} (or `activity()`'s `.out`). */
|
|
126
147
|
export function isStepOutputRef(value: unknown): value is StepOutputRef {
|