@intentius/chant 0.105.0 → 0.106.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.
Files changed (95) hide show
  1. package/dist/cli/commands/lint.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.d.ts.map +1 -1
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/pr.d.ts +16 -2
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/promote.d.ts.map +1 -1
  8. package/dist/cli/handlers/run.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/registry.d.ts +7 -0
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/components/changed-files.d.ts +8 -2
  13. package/dist/components/changed-files.d.ts.map +1 -1
  14. package/dist/components/fan-out-record.d.ts +24 -0
  15. package/dist/components/fan-out-record.d.ts.map +1 -1
  16. package/dist/components/pr-pipeline.d.ts +60 -4
  17. package/dist/components/pr-pipeline.d.ts.map +1 -1
  18. package/dist/components/pr-run.d.ts +92 -3
  19. package/dist/components/pr-run.d.ts.map +1 -1
  20. package/dist/gated-waves.d.ts +4 -1
  21. package/dist/gated-waves.d.ts.map +1 -1
  22. package/dist/lexicon.d.ts +13 -2
  23. package/dist/lexicon.d.ts.map +1 -1
  24. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  25. package/dist/op/activities/factory.d.ts.map +1 -1
  26. package/dist/op/gate-name.d.ts +7 -0
  27. package/dist/op/gate-name.d.ts.map +1 -1
  28. package/dist/op/gate-summary.d.ts +2 -0
  29. package/dist/op/gate-summary.d.ts.map +1 -1
  30. package/dist/op/gate.d.ts +10 -2
  31. package/dist/op/gate.d.ts.map +1 -1
  32. package/dist/op/index.d.ts +2 -2
  33. package/dist/op/index.d.ts.map +1 -1
  34. package/dist/op/local-executor.d.ts.map +1 -1
  35. package/dist/op/local-output.d.ts +2 -2
  36. package/dist/op/local-output.d.ts.map +1 -1
  37. package/dist/op/runtime.d.ts +19 -2
  38. package/dist/op/runtime.d.ts.map +1 -1
  39. package/dist/op/runtimes/local.d.ts.map +1 -1
  40. package/dist/pr-loop.d.ts +29 -4
  41. package/dist/pr-loop.d.ts.map +1 -1
  42. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  43. package/dist/workspace/member-pipeline.d.ts +14 -1
  44. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
  47. package/src/__fixtures__/change-set/warden.plan.json +47 -40
  48. package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
  49. package/src/change-set.test.ts +7 -8
  50. package/src/cli/commands/lint.ts +34 -1
  51. package/src/cli/handlers/build-lexicon-output.test.ts +94 -0
  52. package/src/cli/handlers/build.ts +65 -0
  53. package/src/cli/handlers/fan-out.test.ts +12 -1
  54. package/src/cli/handlers/fan-out.ts +7 -3
  55. package/src/cli/handlers/operator.ts +1 -2
  56. package/src/cli/handlers/pr.ts +220 -35
  57. package/src/cli/handlers/promote.test.ts +15 -1
  58. package/src/cli/handlers/promote.ts +5 -3
  59. package/src/cli/handlers/run.test.ts +164 -1
  60. package/src/cli/handlers/run.ts +22 -11
  61. package/src/cli/main.ts +13 -1
  62. package/src/cli/mcp/never-over-mcp.test.ts +74 -0
  63. package/src/cli/registry.ts +7 -0
  64. package/src/components/changed-files.test.ts +58 -0
  65. package/src/components/changed-files.ts +19 -4
  66. package/src/components/fan-out-record.test.ts +47 -2
  67. package/src/components/fan-out-record.ts +53 -0
  68. package/src/components/pr-pipeline.ts +89 -6
  69. package/src/components/pr-run.test.ts +300 -3
  70. package/src/components/pr-run.ts +196 -5
  71. package/src/gated-waves.test.ts +11 -0
  72. package/src/gated-waves.ts +7 -2
  73. package/src/lexicon.ts +13 -1
  74. package/src/lifecycle/gate-ledger.ts +4 -3
  75. package/src/op/activities/factory.ts +9 -1
  76. package/src/op/gate-name.ts +17 -0
  77. package/src/op/gate-summary.test.ts +12 -0
  78. package/src/op/gate-summary.ts +3 -1
  79. package/src/op/gate.test.ts +15 -1
  80. package/src/op/gate.ts +21 -4
  81. package/src/op/index.ts +2 -2
  82. package/src/op/local-executor.test.ts +35 -0
  83. package/src/op/local-executor.ts +2 -2
  84. package/src/op/local-output.test.ts +20 -0
  85. package/src/op/local-output.ts +4 -4
  86. package/src/op/runtime.ts +20 -2
  87. package/src/op/runtimes/local.test.ts +15 -0
  88. package/src/op/runtimes/local.ts +3 -1
  89. package/src/pr-loop.test.ts +41 -1
  90. package/src/pr-loop.ts +38 -8
  91. package/src/workspace/lineage-upgrade-cli.ts +4 -3
  92. package/src/workspace/lineage-upgrade.test.ts +40 -0
  93. package/src/workspace/member-pipeline.test.ts +28 -0
  94. package/src/workspace/member-pipeline.ts +20 -3
  95. package/src/workspace/pr-report.schema.json +14 -1
@@ -30,6 +30,29 @@
30
30
  import { dirname } from "node:path";
31
31
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
32
32
  import { readWaveRecords, type WaveRecord } from "../gated-waves";
33
+ import type { ChangeSetDocument } from "../change-set";
34
+ import type { PrMember } from "../pr-loop";
35
+
36
+ /** What a pull request's apply approved, kept so a re-run can finish it (#3464). */
37
+ export interface PrApplyRecord {
38
+ /** The gate's op and name: `pr-<n>` and `pr-apply` unless the pipeline names another. */
39
+ op: string;
40
+ gate: string;
41
+ /** The commit the apply ran on. A record from another commit does not carry. */
42
+ head: string;
43
+ /** The change-set digest the approval stands for. */
44
+ digest: string;
45
+ approvedBy: string[];
46
+ /** Every member of the approved set, as planned. */
47
+ members: PrMember[];
48
+ /** The approved change-set document. Its entries are what a re-planned member is checked against. */
49
+ changeSet: ChangeSetDocument;
50
+ /**
51
+ * The outputs the approved plans read. The members left are planned
52
+ * against them again, so an unchanged member plans to the same digest.
53
+ */
54
+ planOutputs: Record<string, Record<string, unknown>>;
55
+ }
33
56
 
34
57
  export interface FanOutAttempt {
35
58
  digest: string;
@@ -44,11 +67,39 @@ export interface FanOutAttempt {
44
67
  * root, choudoufu's wave resume file.
45
68
  */
46
69
  carried?: Record<string, unknown>;
70
+ /** A pull request's apply only (#3464). */
71
+ prApply?: PrApplyRecord;
47
72
  }
48
73
 
49
74
  const isRecord = (value: unknown): value is Record<string, unknown> =>
50
75
  typeof value === "object" && value !== null && !Array.isArray(value);
51
76
 
77
+ const isStringArray = (value: unknown): value is string[] =>
78
+ Array.isArray(value) && value.every((v) => typeof v === "string");
79
+
80
+ /** The `prApply` entry, when it has every field a resume needs. Anything else reads as no entry. */
81
+ function readPrApplyRecord(value: unknown): PrApplyRecord | undefined {
82
+ if (!isRecord(value)) return undefined;
83
+ const { op, gate, head, digest, approvedBy, members, changeSet, planOutputs } = value;
84
+ if (typeof op !== "string" || typeof gate !== "string" || typeof head !== "string" || typeof digest !== "string") return undefined;
85
+ if (!isStringArray(approvedBy) || !Array.isArray(members) || !members.every(isRecord)) return undefined;
86
+ if (!isRecord(changeSet) || changeSet.digest !== digest || !Array.isArray(changeSet.members) || !Array.isArray(changeSet.entries)) return undefined;
87
+ const outputs: Record<string, Record<string, unknown>> = {};
88
+ if (isRecord(planOutputs)) {
89
+ for (const [name, v] of Object.entries(planOutputs)) if (isRecord(v)) outputs[name] = v;
90
+ }
91
+ return {
92
+ op,
93
+ gate,
94
+ head,
95
+ digest,
96
+ approvedBy,
97
+ members: members as unknown as PrMember[],
98
+ changeSet: changeSet as unknown as ChangeSetDocument,
99
+ planOutputs: outputs,
100
+ };
101
+ }
102
+
52
103
  /** Read an attempt record, or `undefined` when there is none yet. Throws on a file that is not JSON. */
53
104
  export function readFanOutAttempt(path: string): FanOutAttempt | undefined {
54
105
  if (!existsSync(path)) return undefined;
@@ -58,6 +109,7 @@ export function readFanOutAttempt(path: string): FanOutAttempt | undefined {
58
109
  for (const [name, value] of Object.entries(parsed.outputs)) if (isRecord(value)) outputs[name] = value;
59
110
  }
60
111
  const waves = readWaveRecords(parsed.waves);
112
+ const prApply = readPrApplyRecord(parsed.prApply);
61
113
  return {
62
114
  digest: typeof parsed.digest === "string" ? parsed.digest : "",
63
115
  completed: Array.isArray(parsed.completed) ? parsed.completed.filter((n): n is string => typeof n === "string") : [],
@@ -65,6 +117,7 @@ export function readFanOutAttempt(path: string): FanOutAttempt | undefined {
65
117
  outputs,
66
118
  ...(waves.length > 0 ? { waves } : {}),
67
119
  ...(isRecord(parsed.carried) && Object.keys(parsed.carried).length > 0 ? { carried: parsed.carried } : {}),
120
+ ...(prApply ? { prApply } : {}),
68
121
  };
69
122
  }
70
123
 
@@ -17,9 +17,23 @@
17
17
  * to `chant/lifecycle`, to record the pending fact when the gate stops it.
18
18
  * One apply runs at a time per environment.
19
19
  *
20
+ * The apply job passes `--resume` (#3543) with a record path outside the
21
+ * report directory, and each forge keeps that file in its CI cache under a
22
+ * key that names the environment, the member and the pushed commit
23
+ * (`prApplyRecordKey`). A re-run of a failed apply job restores it and
24
+ * finishes the approved set under the same approval; the record itself is
25
+ * checked against the pull request's gate, the commit, the selection and
26
+ * the ledger before it counts (`pr-apply --resume`, #3464).
27
+ *
20
28
  * Measuring the push from the commit it replaced works for merge commits,
21
29
  * squash merges and rebase merges alike, and every job measures from the
22
30
  * merge base of that commit and its own head (`resolveMergeBase`).
31
+ *
32
+ * In a workspace member (#3465) both commands run in the member's directory
33
+ * and pass `--member <name>`, so the member's gate (`pr-<number>-<member>`),
34
+ * note and statuses stay apart from every other member's on the same pull
35
+ * request. They still measure the whole change: a file outside the member
36
+ * that one of its units reaches selects that unit.
23
37
  */
24
38
 
25
39
  import type { ForgeKind } from "../pr-forge";
@@ -28,6 +42,31 @@ import { PR_APPLY_GATE } from "../pr-loop";
28
42
  /** Where both jobs write their reports, kept as a CI artifact. */
29
43
  export const PR_LOOP_REPORT_DIR = ".chant/pr";
30
44
 
45
+ /**
46
+ * The attempt record the apply job resumes from (#3543), relative to the
47
+ * directory the job runs in. It sits outside `PR_LOOP_REPORT_DIR`, so the
48
+ * report artifact never carries it: it holds the outputs the members read.
49
+ */
50
+ export const PR_APPLY_RECORD = ".chant/pr-resume/pr-apply.json";
51
+
52
+ /**
53
+ * The image the pull-request pipeline runs in when the caller sets none. The
54
+ * full `node` image has git, which both jobs measure the change with;
55
+ * `PR_LOOP_SETUP` adds OpenTofu on top of it. A caller who sets `image`
56
+ * brings both and gets no setup lines.
57
+ */
58
+ export const PR_LOOP_IMAGE = "node:22";
59
+
60
+ /** Installs OpenTofu into `PR_LOOP_IMAGE`. */
61
+ export const PR_LOOP_SETUP = [
62
+ "command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)",
63
+ ];
64
+
65
+ /** The setup lines for a pipeline running in `image`: the install when it is the default image, none otherwise. */
66
+ export function prLoopSetup(image: string): string[] {
67
+ return image === PR_LOOP_IMAGE ? PR_LOOP_SETUP : [];
68
+ }
69
+
31
70
  /** The `prLoop` generator option (`ComponentPipelineOptions.prLoop`). */
32
71
  export interface PrLoopPipelineOptions {
33
72
  /** The gate the apply waits on. Default `pr-apply`. */
@@ -58,12 +97,21 @@ export interface PrLoopJob {
58
97
  * variables on GitLab.
59
98
  */
60
99
  env: Record<string, string>;
100
+ /** Shell lines to run before the command, to settle values the forge may leave unusable. */
101
+ setup?: string[];
61
102
  }
62
103
 
63
- /** The two jobs for `forge`. */
64
- export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelineOptions = {}): PrLoopJob[] {
104
+ /**
105
+ * The two jobs for `forge`. `member` is the workspace member the pipeline is
106
+ * generated for; the commands then run in its directory, so `--output` is
107
+ * relative to it.
108
+ */
109
+ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelineOptions = {}, member?: string): PrLoopJob[] {
65
110
  const gate = options.gate ?? PR_APPLY_GATE;
66
- const common = ["--env", env, "--gate", gate, "--output", PR_LOOP_REPORT_DIR, "--forge", forge];
111
+ const common = [
112
+ "--env", env, "--gate", gate, "--output", PR_LOOP_REPORT_DIR, "--forge", forge,
113
+ ...(member ? ["--member", member] : []),
114
+ ];
67
115
  const github = forge !== "gitlab";
68
116
  const token: Record<string, string> = github ? { GITHUB_TOKEN: "${{ github.token }}" } : {};
69
117
  return [
@@ -79,13 +127,48 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
79
127
  command: [
80
128
  "chant", "components", "pr-apply", "--base", '"$BASE_SHA"', ...common,
81
129
  ...(options.requireReview === false ? [] : ["--require-review"]),
130
+ "--resume", PR_APPLY_RECORD,
82
131
  ].join(" "),
83
132
  env: github ? { BASE_SHA: "${{ github.event.before }}", ...token } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA" },
133
+ ...(github ? {} : { setup: [gitlabBaseFallback(options.branch)] }),
84
134
  },
85
135
  ];
86
136
  }
87
137
 
88
- /** The concurrency group (GitHub, Forgejo) or resource group (GitLab) that keeps one apply at a time per environment. */
89
- export function prApplyGroup(env: string): string {
90
- return `chant-apply-${env}`;
138
+ /**
139
+ * The concurrency group (GitHub, Forgejo) or resource group (GitLab) that
140
+ * keeps one apply at a time per environment, and per member in a workspace.
141
+ */
142
+ export function prApplyGroup(env: string, member?: string): string {
143
+ return member ? `chant-apply-${member}-${env}` : `chant-apply-${env}`;
144
+ }
145
+
146
+ /**
147
+ * The cache key the apply job keeps its attempt record under (#3543), with
148
+ * `commit` the forge's expression for the pushed commit. A push of another
149
+ * commit never restores a record, and the environment and member keep each
150
+ * pipeline's record apart. The pull request and the gate digest are not in
151
+ * the key, since neither is known before the job runs; `pr-apply --resume`
152
+ * refuses a record made for another pull request, gate or digest.
153
+ */
154
+ export function prApplyRecordKey(env: string, commit: string, member?: string): string {
155
+ return `${prApplyGroup(env, member)}-${commit}`;
156
+ }
157
+
158
+ /**
159
+ * GitLab sets `CI_COMMIT_BEFORE_SHA` to forty zeros on a branch's first push.
160
+ * The line replaces it with the merge request's diff base when the pipeline
161
+ * has one, and otherwise with the merge base of the target branch and the
162
+ * pushed commit, or the pushed commit's parent when that is the commit itself.
163
+ */
164
+ export function gitlabBaseFallback(branch?: string): string {
165
+ const target = branch ? JSON.stringify(branch) : "$CI_DEFAULT_BRANCH";
166
+ return (
167
+ 'if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then ' +
168
+ 'BASE_SHA="${CI_MERGE_REQUEST_DIFF_BASE_SHA:-}"; ' +
169
+ 'if [ -z "$BASE_SHA" ]; then ' +
170
+ `git fetch origin ${target} && BASE_SHA="$(git merge-base FETCH_HEAD "$CI_COMMIT_SHA")"; ` +
171
+ 'if [ "$BASE_SHA" = "$CI_COMMIT_SHA" ]; then BASE_SHA="$(git rev-parse "$CI_COMMIT_SHA^")"; fi; ' +
172
+ "fi; fi; export BASE_SHA"
173
+ );
91
174
  }
@@ -12,8 +12,21 @@ import { describe, expect, test } from "vitest";
12
12
  import { CapabilityRegistry, type DeployContext } from "./capability";
13
13
  import { memoryGateLedgerPort } from "../op/gate";
14
14
  import { planFanOut } from "./fan-out";
15
- import { applyPrSet, approvalOf, approversWithoutReview, decidePrGate, planPrSet, readOnlyLedger } from "./pr-run";
16
- import { changeSetDigest } from "../change-set";
15
+ import {
16
+ applyPrSet,
17
+ approvalOf,
18
+ approversWithoutReview,
19
+ decidePrGate,
20
+ planCoveredBy,
21
+ planPrSet,
22
+ prReport,
23
+ readOnlyLedger,
24
+ resumePrSet,
25
+ type PrPlanSet,
26
+ } from "./pr-run";
27
+ import { changeSetDigest, composeChangeSet, type ChangeSetEntry, type ChangeSetPart } from "../change-set";
28
+ import type { PrApplyRecord } from "./fan-out-record";
29
+ import type { FanOutPlan } from "./fan-out";
17
30
  import { computePlanDigest } from "../lifecycle/plan-digest";
18
31
  import type { DriverComponent } from "./driver";
19
32
 
@@ -66,7 +79,17 @@ function world() {
66
79
  changeSet: {
67
80
  member: { member: input.root, lexicon: "terraform", planner: "tofu", status: "planned", planDigest, holes: [] },
68
81
  entries: changes
69
- ? [{ member: input.root, lexicon: "terraform", planner: "tofu", address: "terraform_data.x", type: "terraform_data", action: "update", attributes: [] }]
82
+ ? [
83
+ {
84
+ member: input.root,
85
+ lexicon: "terraform",
86
+ planner: "tofu",
87
+ address: "terraform_data.x",
88
+ type: "terraform_data",
89
+ action: "update",
90
+ attributes: [{ path: "version", before: "v1", after: w.versions[input.root] }],
91
+ },
92
+ ]
70
93
  : [],
71
94
  },
72
95
  };
@@ -203,3 +226,277 @@ describe("applyPrSet", () => {
203
226
  expect(members.find((m) => m.member === "a")?.error).toBe("a failed");
204
227
  });
205
228
  });
229
+
230
+ /** The record a first attempt leaves (`pr-apply --resume`), from the set it applied. */
231
+ function recordOf(set: PrPlanSet, approvedBy = "github:alice"): PrApplyRecord {
232
+ return {
233
+ op: "pr-12",
234
+ gate: "pr-apply",
235
+ head: "2".repeat(40),
236
+ digest: set.doc.digest,
237
+ approvedBy: [approvedBy],
238
+ members: set.members,
239
+ changeSet: set.doc,
240
+ planOutputs: set.outputs,
241
+ };
242
+ }
243
+
244
+ /** Plan and apply once, with `failing` failing to apply. */
245
+ async function firstAttempt(w: ReturnType<typeof world>, plan: FanOutPlan, failing: string[]) {
246
+ for (const f of failing) w.failing.add(f);
247
+ const set = await planPrSet({ components: ESTATE, plan, registry: w.registry, env: "prod" });
248
+ const first = await applyPrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", set });
249
+ for (const f of failing) w.failing.delete(f);
250
+ return { set, first, record: recordOf(set) };
251
+ }
252
+
253
+ describe("resuming an apply that failed partway (#3464)", () => {
254
+ test("plans and applies only what did not apply, and the set keeps the approved digest", async () => {
255
+ const w = world();
256
+ const plan = planFanOut({ components: ESTATE, changed: ["net", "dns"] });
257
+ const { set, first, record } = await firstAttempt(w, plan, ["a"]);
258
+ expect(first.status).toBe("failed");
259
+ expect(first.run.completed).toEqual(["b", "dns", "net"]);
260
+
261
+ w.planned.length = 0;
262
+ w.applied.length = 0;
263
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
264
+ if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
265
+
266
+ expect(resumed.plan.order).toEqual(["a", "app"]);
267
+ // Planned against the outputs the approved plans read, so each member plans as it did.
268
+ expect(w.planned).toEqual([
269
+ { root: "a", vars: { cidr: "10.0.0.0/16" } },
270
+ { root: "app", vars: { subnet: "a-v1" } },
271
+ ]);
272
+ expect(resumed.set.doc.digest).toBe(set.doc.digest);
273
+ expect(resumed.fresh.members.map((m) => m.planDigest)).toEqual(
274
+ set.members.filter((m) => m.member === "a" || m.member === "app").map((m) => m.planDigest),
275
+ );
276
+
277
+ // The gate is decided against the digest that was approved, and it still stands.
278
+ const ledger = memoryGateLedgerPort({ resolutions: [approval(set.doc.digest)] });
279
+ const check = await decidePrGate(ledger, { op: "pr-12", gate: "pr-apply", digest: resumed.set.doc.digest, now: NOW });
280
+ expect(check.satisfied).toBe(true);
281
+ expect(ledger.appended).toEqual([]);
282
+
283
+ const second = await applyPrSet({
284
+ components: ESTATE,
285
+ plan: resumed.plan,
286
+ registry: w.registry,
287
+ env: "prod",
288
+ set: resumed.set,
289
+ resumed: { completed: first.run.completed, outputs: {} },
290
+ });
291
+ expect(second.status).toBe("applied");
292
+ expect(w.applied).toEqual(["a", "app"]);
293
+ expect(w.fromPlan.a).toBe(true);
294
+ expect(Object.fromEntries(second.members.map((m) => [m.member, m.status]))).toEqual({
295
+ a: "applied",
296
+ app: "applied",
297
+ b: "applied",
298
+ dns: "applied",
299
+ net: "applied",
300
+ });
301
+ });
302
+
303
+ test("the resumed report carries the approved digest and names what the earlier attempt applied", async () => {
304
+ const w = world();
305
+ const plan = planFanOut({ components: ESTATE, changed: ["net", "dns"] });
306
+ const { set, first, record } = await firstAttempt(w, plan, ["a"]);
307
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
308
+ if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
309
+ const second = await applyPrSet({
310
+ components: ESTATE,
311
+ plan: resumed.plan,
312
+ registry: w.registry,
313
+ env: "prod",
314
+ set: resumed.set,
315
+ resumed: { completed: first.run.completed, outputs: {} },
316
+ });
317
+ const report = prReport({
318
+ stage: "apply",
319
+ pr: 12,
320
+ env: "prod",
321
+ base: "1".repeat(40),
322
+ head: "2".repeat(40),
323
+ op: "pr-12",
324
+ gate: "pr-apply",
325
+ plan,
326
+ signal: { changed: ["net", "dns"], unclaimed: [] } as never,
327
+ set: resumed.set,
328
+ approval: { status: "approved", approvedBy: ["github:alice"] },
329
+ status: second.status,
330
+ members: second.members,
331
+ resumed: first.run.completed,
332
+ });
333
+ expect(report.digest).toBe(set.doc.digest);
334
+ expect(report.changeSet).toBe(set.doc);
335
+ expect(report.resumed).toEqual({ applied: ["b", "dns", "net"] });
336
+ });
337
+
338
+ test("a dependent whose dependency applied in the earlier attempt applies its approved plan and says its inputs moved", async () => {
339
+ const w = world();
340
+ w.versions.a = "v2";
341
+ const plan = planFanOut({ components: ESTATE, changed: ["a"] });
342
+ const { first, record } = await firstAttempt(w, plan, ["app"]);
343
+ expect(first.run.completed).toEqual(["a"]);
344
+ const outputs = { a: first.run.componentOutputs.a };
345
+ expect(outputs.a).toEqual({ id: "a-v2" });
346
+
347
+ w.planned.length = 0;
348
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: ["a"] });
349
+ if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
350
+ // app is planned against the value it was approved with, not the one a wrote since.
351
+ expect(w.planned).toEqual([{ root: "app", vars: { subnet: "a-v1" } }]);
352
+
353
+ const second = await applyPrSet({
354
+ components: ESTATE,
355
+ plan: resumed.plan,
356
+ registry: w.registry,
357
+ env: "prod",
358
+ set: resumed.set,
359
+ resumed: { completed: ["a"], outputs },
360
+ });
361
+ expect(second.members.map((m) => [m.member, m.status, m.inputsMoved])).toEqual([
362
+ ["a", "applied", undefined],
363
+ ["app", "applied", ["a"]],
364
+ ]);
365
+ });
366
+
367
+ test("a member left over whose plan moved is not covered, and says why", async () => {
368
+ const w = world();
369
+ w.versions.a = "v2";
370
+ const plan = planFanOut({ components: ESTATE, changed: ["net", "a"] });
371
+ const { first, record } = await firstAttempt(w, plan, ["a"]);
372
+ expect(first.run.completed).toEqual(["b", "net"]);
373
+
374
+ w.versions.a = "v3";
375
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
376
+ expect(resumed.ok).toBe(false);
377
+ if (resumed.ok) return;
378
+ expect(resumed.reasons).toEqual(["a now writes terraform_data.x.version to a different value than the approved plan"]);
379
+ });
380
+
381
+ test("a member left over that fails to plan is not covered", async () => {
382
+ const w = world();
383
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
384
+ const { first, record } = await firstAttempt(w, plan, ["a"]);
385
+ w.planFailing.add("a");
386
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
387
+ expect(resumed.ok).toBe(false);
388
+ if (resumed.ok) return;
389
+ expect(resumed.reasons[0]).toMatch(/^a failed to plan/);
390
+ });
391
+
392
+ test("a record whose change set is not the one its digest names does not resume (#3543)", async () => {
393
+ const w = world();
394
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
395
+ const { set, first, record } = await firstAttempt(w, plan, ["a"]);
396
+ const other = { ...set.doc, digest: "jcs1-sha256:" + "0".repeat(64) };
397
+ for (const forged of [
398
+ { ...record, changeSet: other },
399
+ { ...record, digest: other.digest, changeSet: other },
400
+ { ...record, changeSet: { ...set.doc, members: set.doc.members.map((m) => (m.member === "a" ? { ...m, planDigest: "jcs1-sha256:" + "1".repeat(64) } : m)) } },
401
+ ]) {
402
+ w.planned.length = 0;
403
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record: forged, completed: first.run.completed });
404
+ expect(resumed.ok).toBe(false);
405
+ if (resumed.ok) return;
406
+ expect(resumed.reasons[0]).toMatch(/^the record's change set is not the one its digest/);
407
+ expect(w.planned).toEqual([]);
408
+ }
409
+ });
410
+
411
+ test("with every component applied, nothing is left to plan or apply", async () => {
412
+ const w = world();
413
+ const plan = planFanOut({ components: ESTATE, changed: ["dns"] });
414
+ const { first, record } = await firstAttempt(w, plan, []);
415
+ expect(first.status).toBe("applied");
416
+ w.planned.length = 0;
417
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
418
+ if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
419
+ expect(resumed.plan.order).toEqual([]);
420
+ expect(w.planned).toEqual([]);
421
+ });
422
+ });
423
+
424
+ describe("planCoveredBy", () => {
425
+ const entry = (address: string, action: ChangeSetEntry["action"], attributes: ChangeSetEntry["attributes"] = [], extra: Partial<ChangeSetEntry> = {}): ChangeSetEntry => ({
426
+ member: "net",
427
+ lexicon: "terraform",
428
+ planner: "tofu",
429
+ address,
430
+ type: address.split(".")[0],
431
+ action,
432
+ attributes,
433
+ ...extra,
434
+ });
435
+ const doc = (digest: string, entries: ChangeSetEntry[], extra: Partial<ChangeSetPart["member"]> = {}) =>
436
+ composeChangeSet([
437
+ {
438
+ member: { member: "net", lexicon: "terraform", planner: "tofu", status: "planned", planDigest: `jcs1-sha256:${digest.repeat(64)}`, holes: [], ...extra },
439
+ entries,
440
+ },
441
+ ]);
442
+
443
+ const approved = doc("1", [
444
+ entry("aws_vpc.main", "update", [{ path: "tags", before: { a: "1" }, after: { a: "2" } }]),
445
+ entry("aws_subnet.a", "create", [{ path: "vpc_id", unknown: true }, { path: "cidr_block", after: "10.0.1.0/24" }]),
446
+ entry("aws_instance.web", "replace", [{ path: "ami", before: "ami-1", after: "ami-2", forcesReplacement: true }]),
447
+ entry("aws_db_instance.db", "update", [{ path: "password", sensitive: true }]),
448
+ ]);
449
+
450
+ test("the same plan digest is covered", () => {
451
+ expect(planCoveredBy(approved, doc("1", []), "net")).toBeUndefined();
452
+ });
453
+
454
+ test("what is left of the approved changes is covered", () => {
455
+ // The VPC applied, the subnet's vpc_id is known now, and the instance's
456
+ // old object was destroyed before the create failed.
457
+ const left = doc("2", [
458
+ entry("aws_vpc.main", "no-op"),
459
+ entry("aws_subnet.a", "create", [{ path: "vpc_id", after: "vpc-123" }, { path: "cidr_block", after: "10.0.1.0/24" }]),
460
+ entry("aws_instance.web", "create", [{ path: "ami", after: "ami-2" }, { path: "instance_type", after: "t3.micro" }]),
461
+ entry("aws_db_instance.db", "update", [{ path: "password", sensitive: true }]),
462
+ ]);
463
+ expect(planCoveredBy(approved, left, "net")).toBeUndefined();
464
+ });
465
+
466
+ test("a create-before-destroy replace left with its deposed object to delete is covered", () => {
467
+ const left = doc("2", [entry("aws_instance.web", "delete", [], { deposed: "00abc" })]);
468
+ expect(planCoveredBy(approved, left, "net")).toBeUndefined();
469
+ });
470
+
471
+ test("a change the approved plan did not make is not covered", () => {
472
+ expect(planCoveredBy(approved, doc("2", [entry("aws_s3_bucket.logs", "create")]), "net")).toBe(
473
+ "net now plans to create aws_s3_bucket.logs, which the approved plan did not",
474
+ );
475
+ expect(planCoveredBy(approved, doc("2", [entry("aws_vpc.main", "delete")]), "net")).toBe(
476
+ "net now plans to delete aws_vpc.main, where the approved plan would update it",
477
+ );
478
+ expect(planCoveredBy(approved, doc("2", [entry("aws_vpc.main", "update", [{ path: "tags", before: { a: "1" }, after: { a: "3" } }])]), "net")).toBe(
479
+ "net now writes aws_vpc.main.tags to a different value than the approved plan",
480
+ );
481
+ expect(planCoveredBy(approved, doc("2", [entry("aws_vpc.main", "update", [{ path: "cidr_block", before: "a", after: "b" }])]), "net")).toBe(
482
+ "net now writes aws_vpc.main.cidr_block, which the approved plan did not",
483
+ );
484
+ });
485
+
486
+ test("a sensitive value is covered only where the approved plan wrote it as sensitive", () => {
487
+ expect(planCoveredBy(approved, doc("2", [entry("aws_vpc.main", "update", [{ path: "tags", sensitive: true }])]), "net")).toMatch(/as sensitive/);
488
+ expect(planCoveredBy(approved, doc("2", [entry("aws_db_instance.db", "update", [{ path: "password", after: "x" }])]), "net")).toMatch(/wrote as sensitive/);
489
+ });
490
+
491
+ test("a hole the approved plan did not have is not covered", () => {
492
+ const left = doc("2", [], { holes: [{ address: "data.aws_ami.latest", reason: "unknown" }] });
493
+ expect(planCoveredBy(approved, left, "net")).toMatch(/cannot read data.aws_ami.latest/);
494
+ });
495
+
496
+ test("a member the approved set did not have is not covered", () => {
497
+ const other = composeChangeSet([
498
+ { member: { member: "edge", lexicon: "terraform", planner: "tofu", status: "planned", planDigest: `jcs1-sha256:${"3".repeat(64)}`, holes: [] }, entries: [] },
499
+ ]);
500
+ expect(planCoveredBy(approved, other, "edge")).toBe("edge was not in the approved plan");
501
+ });
502
+ });