@intentius/chant-lexicon-terraform 0.61.0 → 0.63.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/composites/terraform-adopt-op.d.ts +18 -2
- package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
- package/dist/composites/terraform-apply-op.d.ts +15 -5
- package/dist/composites/terraform-apply-op.d.ts.map +1 -1
- package/dist/composites/terraform-watch-op.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/terraform.d.ts +57 -0
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +1 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__fixtures__/ACCEPTANCE.md +47 -6
- package/src/composites/terraform-adopt-op.acceptance.test.ts +65 -0
- package/src/composites/terraform-adopt-op.test.ts +33 -11
- package/src/composites/terraform-adopt-op.ts +20 -2
- package/src/composites/terraform-apply-op.test.ts +32 -0
- package/src/composites/terraform-apply-op.ts +20 -5
- package/src/composites/terraform-watch-op.test.ts +14 -0
- package/src/composites/terraform-watch-op.ts +10 -0
- package/src/op/activities/terraform.test.ts +85 -0
- package/src/op/activities/terraform.ts +103 -1
- package/src/op/activity-contracts.ts +1 -0
|
@@ -13,8 +13,18 @@
|
|
|
13
13
|
* the two values that would claim it. Cloud-to-code here is "claim what the
|
|
14
14
|
* configuration already describes" — a tag write, not a regeneration.
|
|
15
15
|
*
|
|
16
|
-
* Phases: Check, Ledger, Gate, Adopt.
|
|
16
|
+
* Phases: Init, Check, Ledger, Gate, Adopt.
|
|
17
17
|
*
|
|
18
|
+
* - **Init** runs `terraform init`, the same step `TerraformApplyOp` and
|
|
19
|
+
* `TerraformWatchOp` open with. The Ledger step's `live-plan` needs the
|
|
20
|
+
* provider schema to discover markers on the live resources it reads, and
|
|
21
|
+
* a checkout that has never run `init` has no `.terraform` directory to
|
|
22
|
+
* read one from — which is every fresh CI checkout, not a GitLab
|
|
23
|
+
* peculiarity (#2302). Without this phase `live-plan` fails outright:
|
|
24
|
+
* `Error: Provider unavailable for marker discovery`. This composite had
|
|
25
|
+
* no Init phase because nothing had ever run `live-adopt` before
|
|
26
|
+
* INTENTIUS/choudoufu#1026 did, so the gap survived every guard that only
|
|
27
|
+
* read the generated YAML.
|
|
18
28
|
* - **Check** runs `live-check`, which makes no cloud calls and says whether
|
|
19
29
|
* this configuration can move under markers at all. It runs first because
|
|
20
30
|
* a root that `live-check` refuses will produce an adoption ledger that
|
|
@@ -103,6 +113,12 @@ export interface TerraformAdoptOpConfig {
|
|
|
103
113
|
gateTimeout?: string;
|
|
104
114
|
/** Override the gate description shown to the approver. */
|
|
105
115
|
gateDescription?: string;
|
|
116
|
+
/**
|
|
117
|
+
* `-upgrade` on the Init step: re-resolve provider and module versions.
|
|
118
|
+
* Same field, same meaning, as `TerraformApplyOpConfig.upgrade` and
|
|
119
|
+
* `TerraformWatchOpConfig.upgrade`.
|
|
120
|
+
*/
|
|
121
|
+
upgrade?: boolean;
|
|
106
122
|
/**
|
|
107
123
|
* Directory each step starts the `chant.config.*` search from, which is what
|
|
108
124
|
* `terraform.roots` and the root's relative `dir` resolve against. Default:
|
|
@@ -124,7 +140,7 @@ export interface TerraformAdoptOpConfig {
|
|
|
124
140
|
};
|
|
125
141
|
}
|
|
126
142
|
export interface TerraformAdoptOpResources {
|
|
127
|
-
/** Op resource. Generates the Check/Ledger/Gate/Adopt workflow. */
|
|
143
|
+
/** Op resource. Generates the Init/Check/Ledger/Gate/Adopt workflow. */
|
|
128
144
|
op: InstanceType<typeof OpResource>;
|
|
129
145
|
}
|
|
130
146
|
export declare function TerraformAdoptOp(config: TerraformAdoptOpConfig): TerraformAdoptOpResources;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"terraform-adopt-op.d.ts","sourceRoot":"","sources":["../../src/composites/terraform-adopt-op.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"terraform-adopt-op.d.ts","sourceRoot":"","sources":["../../src/composites/terraform-adopt-op.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2FG;AAEH,OAAO,EAA6B,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAQ5E,MAAM,WAAW,sBAAsB;IACrC,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,wGAAwG;IACxG,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qGAAqG;IACrG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,OAAO,GAAG;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7C;AAED,MAAM,WAAW,yBAAyB;IACxC,wEAAwE;IACxE,EAAE,EAAE,YAAY,CAAC,OAAO,UAAU,CAAC,CAAC;CACrC;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,sBAAsB,GAAG,yBAAyB,CAkE1F"}
|
|
@@ -56,11 +56,21 @@
|
|
|
56
56
|
*
|
|
57
57
|
* A gate is a fact on the gate ledger, not a wait (#2119). Reaching one,
|
|
58
58
|
* `chant run` consults the ledger (`packages/core/src/op/gate.ts`): a
|
|
59
|
-
* resolution
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
59
|
+
* resolution recorded for this run's own plan lets the run walk through, and
|
|
60
|
+
* anything else records the pending fact and ends the run with status `gated`
|
|
61
|
+
* and exit 3. `chant approve <op> <gate>` writes the resolution, and the next
|
|
62
|
+
* run reads it. `gate: "never"` drops the Gate phase entirely, so a run never
|
|
63
|
+
* stops.
|
|
64
|
+
*
|
|
65
|
+
* **The gate binds the plan** (#2300). The Plan step publishes
|
|
66
|
+
* `planDigest` — its `terraform show -json` change set, hashed — and the Gate
|
|
67
|
+
* step references it, so the resolution names the plan it approves. That
|
|
68
|
+
* closes the window the exit-3 refusal above does not: exit 3 compares the
|
|
69
|
+
* Apply step's fresh plan against the *file* it was handed inside one run,
|
|
70
|
+
* and this compares a later run's fresh plan against what a human actually
|
|
71
|
+
* approved. INTENTIUS/choudoufu#1026 measured the gap by approving, renaming
|
|
72
|
+
* a resource, and re-running: the second run applied without complaint. It
|
|
73
|
+
* now stops, naming the approved digest and the planned one.
|
|
64
74
|
*
|
|
65
75
|
* @example
|
|
66
76
|
* ```typescript
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"terraform-apply-op.d.ts","sourceRoot":"","sources":["../../src/composites/terraform-apply-op.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"terraform-apply-op.d.ts","sourceRoot":"","sources":["../../src/composites/terraform-apply-op.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgFG;AAEH,OAAO,EAA6B,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAW5E;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,iBAAiB,GAAG,YAAY,GAAG,QAAQ,GAAG,OAAO,CAAC;AAElE,MAAM,WAAW,sBAAsB;IACrC,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,+GAA+G;IAC/G,IAAI,EAAE,MAAM,CAAC;IACb,0GAA0G;IAC1G,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,iBAAiB,CAAC;IACzB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qGAAqG;IACrG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;OAWG;IACH,UAAU,CAAC,EAAE,OAAO,GAAG;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7C;AAED,MAAM,WAAW,yBAAyB;IACxC,mEAAmE;IACnE,EAAE,EAAE,YAAY,CAAC,OAAO,UAAU,CAAC,CAAC;CACrC;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,sBAAsB,GAAG,yBAAyB,CAgH1F"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"terraform-watch-op.d.ts","sourceRoot":"","sources":["../../src/composites/terraform-watch-op.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6GG;AAEH,OAAO,EAAa,UAAU,EAAqB,MAAM,qBAAqB,CAAC;AAS/E;;;;;;;;;;GAUG;AACH,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,OAAO,GAAG,SAAS,GAAG,cAAc,CAAC;AAEnF,MAAM,WAAW,sBAAsB;IACrC,4BAA4B;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,+GAA+G;IAC/G,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,oBAAoB,CAAC;IACnC;;;;;;;;;;;;;;;;OAgBG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,yBAAyB;IACxC,4DAA4D;IAC5D,EAAE,EAAE,YAAY,CAAC,OAAO,UAAU,CAAC,CAAC;CACrC;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,sBAAsB,GAAG,yBAAyB,
|
|
1
|
+
{"version":3,"file":"terraform-watch-op.d.ts","sourceRoot":"","sources":["../../src/composites/terraform-watch-op.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6GG;AAEH,OAAO,EAAa,UAAU,EAAqB,MAAM,qBAAqB,CAAC;AAS/E;;;;;;;;;;GAUG;AACH,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,OAAO,GAAG,SAAS,GAAG,cAAc,CAAC;AAEnF,MAAM,WAAW,sBAAsB;IACrC,4BAA4B;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,+GAA+G;IAC/G,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,oBAAoB,CAAC;IACnC;;;;;;;;;;;;;;;;OAgBG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,yBAAyB;IACxC,4DAA4D;IAC5D,EAAE,EAAE,YAAY,CAAC,OAAO,UAAU,CAAC,CAAC;CACrC;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,sBAAsB,GAAG,yBAAyB,CAwH1F"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "795fbe628239306c60d4cf83fb07132d4dd1fef513e9f0e59feee71a5eb1427d",
|
|
5
5
|
"meta.json": "ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356",
|
|
6
6
|
"types/index.d.ts": "022927e6a7b351cc4cf52d5f973f60ccd140e5810e17b32fb8868965aa1db161",
|
|
7
7
|
"rules/plan-before-apply.ts": "31b1b3176799fd365b2583fe3947edd187c2a863e3dc0094ed9e91ece2464c99",
|
|
@@ -37,5 +37,5 @@
|
|
|
37
37
|
"rules/tf028.ts": "81ef95659c40ad1774c83e663473ed769143d894a4ba0517c3defa31f00ba723",
|
|
38
38
|
"skills/chant-terraform.md": "d9ca7b7c28aed0038ce775a3491150298492eed2858fd3651ed9bfca0e577ae8"
|
|
39
39
|
},
|
|
40
|
-
"composite": "
|
|
40
|
+
"composite": "9ecb4590f041f9f401794c438d67d6ce20c3f5b21df1796f1d58a6f0a0ee5814"
|
|
41
41
|
}
|
package/dist/manifest.json
CHANGED
|
@@ -203,6 +203,56 @@ export interface PlanChangeCounts {
|
|
|
203
203
|
/** Resources to destroy. This is the number an approval gate exists for. */
|
|
204
204
|
destroys: number;
|
|
205
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* One resource's proposed change, as a plan digest sees it (#2300) — the
|
|
208
|
+
* projection of a `terraform show -json` `resource_changes` entry that
|
|
209
|
+
* survives into {@link terraformPlanDigest}.
|
|
210
|
+
*/
|
|
211
|
+
export interface TerraformPlannedChange {
|
|
212
|
+
address: unknown;
|
|
213
|
+
mode: unknown;
|
|
214
|
+
type: unknown;
|
|
215
|
+
name: unknown;
|
|
216
|
+
index: unknown;
|
|
217
|
+
provider_name: unknown;
|
|
218
|
+
deposed: unknown;
|
|
219
|
+
actions: unknown;
|
|
220
|
+
before: unknown;
|
|
221
|
+
after: unknown;
|
|
222
|
+
after_unknown: unknown;
|
|
223
|
+
replace_paths: unknown;
|
|
224
|
+
importing: unknown;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The change set a `terraform show -json` plan describes, canonicalised
|
|
228
|
+
* (#2300): every proposed change, at its address, with the values that would
|
|
229
|
+
* be written, sorted so two renderings of the same plan project identically.
|
|
230
|
+
*
|
|
231
|
+
* Everything else in the document is dropped, and the drops are the point —
|
|
232
|
+
* see `packages/core/src/lifecycle/plan-digest.ts` for the general rule. Here
|
|
233
|
+
* that means `timestamp` and `terraform_version` (when the plan was taken and
|
|
234
|
+
* by which binary), `format_version` (how it was rendered), `prior_state` and
|
|
235
|
+
* `planned_values` (restatements of the same change set from another angle),
|
|
236
|
+
* `configuration` (the source that produced it, which can be refactored
|
|
237
|
+
* without changing a single proposed action) and `checks` / `relevant_
|
|
238
|
+
* attributes` (diagnostics about the plan, not the plan). What is left is
|
|
239
|
+
* what applying it would do.
|
|
240
|
+
*/
|
|
241
|
+
export declare function terraformPlanChangeSet(planJson: unknown): {
|
|
242
|
+
resourceChanges: TerraformPlannedChange[];
|
|
243
|
+
outputChanges: unknown;
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* The identity of a terraform plan (#2300): {@link terraformPlanChangeSet}
|
|
247
|
+
* hashed. This is what a `TerraformApplyOp` gate binds its approval to, and
|
|
248
|
+
* what a later run recomputes and compares.
|
|
249
|
+
*
|
|
250
|
+
* Taken over the `show -json` rendering rather than the plan file's bytes on
|
|
251
|
+
* purpose: the file is an opaque, version-stamped archive, and two runs of
|
|
252
|
+
* `plan -out` over an unchanged root produce different bytes. The rendering
|
|
253
|
+
* is the same plan both times.
|
|
254
|
+
*/
|
|
255
|
+
export declare function terraformPlanDigest(planJson: unknown): string;
|
|
206
256
|
/** What {@link terraformPlan} resolved. Carries the plan itself, so no later step re-plans. */
|
|
207
257
|
export interface TerraformPlanResult extends PlanChangeCounts {
|
|
208
258
|
/** `true` when terraform reported exit 2 under `-detailed-exitcode`: the plan proposes changes. */
|
|
@@ -215,6 +265,13 @@ export interface TerraformPlanResult extends PlanChangeCounts {
|
|
|
215
265
|
json: unknown;
|
|
216
266
|
/** `terraform show -no-color <planFile>` — the human-readable plan. */
|
|
217
267
|
text: string;
|
|
268
|
+
/**
|
|
269
|
+
* This plan's identity (#2300) — {@link terraformPlanDigest} over
|
|
270
|
+
* {@link TerraformPlanResult.json}. Hand it to a gate step's `plan` so the
|
|
271
|
+
* approval binds to this plan and not to the next run: `gate(name, { plan:
|
|
272
|
+
* plan.out.planDigest })`.
|
|
273
|
+
*/
|
|
274
|
+
planDigest: string;
|
|
218
275
|
}
|
|
219
276
|
/**
|
|
220
277
|
* Why a live apply refused the approval artifact it was handed. Both are
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"terraform.d.ts","sourceRoot":"","sources":["../../../src/op/activities/terraform.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;
|
|
1
|
+
{"version":3,"file":"terraform.d.ts","sourceRoot":"","sources":["../../../src/op/activities/terraform.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAQH,OAAO,KAAK,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEzE,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,aAAa,CAAC;AAWrB,wEAAwE;AACxE,eAAO,MAAM,iBAAiB,iBAAiB,CAAC;AAEhD,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,cAAc,CAAC;AAEpD,gHAAgH;AAChH,eAAO,MAAM,+BAA+B,yBAAyB,CAAC;AAEtE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,qBAAqB,WAAW,CAAC;AAE9C;;;;;;;;GAQG;AACH,eAAO,MAAM,+BAA+B,mEACsB,CAAC;AAEnE,mHAAmH;AACnH,eAAO,MAAM,4BAA4B,IAAI,CAAC;AAE9C,wGAAwG;AACxG,eAAO,MAAM,mCAAmC,wDAAwD,CAAC;AAEzG,0GAA0G;AAC1G,eAAO,MAAM,8BAA8B,oDAAoD,CAAC;AAIhG,8EAA8E;AAC9E,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,iBAAkB,SAAQ,iBAAiB;IAC1D,8EAA8E;IAC9E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,iBAAkB,SAAQ,iBAAiB;IAC1D,8FAA8F;IAC9F,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,kBAAmB,SAAQ,iBAAiB;IAC3D;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,iBAAkB,SAAQ,iBAAiB;IAC1D,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,qBAAsB,SAAQ,iBAAiB;IAC9D;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,mBAAoB,SAAQ,iBAAiB;IAC5D,mHAAmH;IACnH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8FAA8F;IAC9F,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,MAAM,sBAAsB,GAAG,iBAAiB,CAAC;AAEvD,MAAM,WAAW,kBAAmB,SAAQ,iBAAiB;IAC3D;;;;;OAKG;IACH,SAAS,EAAE,iBAAiB,EAAE,CAAC;IAC/B;;;;OAIG;IACH,SAAS,CAAC,EAAE,iBAAiB,EAAE,CAAC;CACjC;AAED,2CAA2C;AAC3C,MAAM,WAAW,mBAAmB;IAClC,uEAAuE;IACvE,GAAG,EAAE,MAAM,CAAC;IACZ,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,2DAA2D;AAC3D,MAAM,WAAW,gBAAgB;IAC/B,qGAAqG;IACrG,IAAI,EAAE,MAAM,CAAC;IACb,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;IACvB,aAAa,EAAE,OAAO,CAAC;IACvB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,OAAO,GAAG;IACzD,eAAe,EAAE,sBAAsB,EAAE,CAAC;IAC1C,aAAa,EAAE,OAAO,CAAC;CACxB,CA+BA;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,GAAG,MAAM,CAE7D;AAED,+FAA+F;AAC/F,MAAM,WAAW,mBAAoB,SAAQ,gBAAgB;IAC3D,mGAAmG;IACnG,OAAO,EAAE,OAAO,CAAC;IACjB,+FAA+F;IAC/F,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAC;IACZ,iDAAiD;IACjD,IAAI,EAAE,OAAO,CAAC;IACd,uEAAuE;IACvE,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,wBAAwB,GAAG,mBAAmB,GAAG,cAAc,CAAC;AAE5E,4CAA4C;AAC5C,MAAM,WAAW,oBAAoB;IACnC,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAC;IACZ,qFAAqF;IACrF,OAAO,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,OAAO,CAAC,EAAE,wBAAwB,CAAC;IACnC,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,sFAAsF;AACtF,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;;;OAQG;IACH,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,WAAW,uBAAwB,SAAQ,qBAAqB,EAAE,gBAAgB;IACtF,wFAAwF;IACxF,KAAK,EAAE,OAAO,CAAC;IACf;;;OAGG;IACH,IAAI,EAAE,OAAO,CAAC;IACd;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB,iHAAiH;IACjH,SAAS,EAAE,iBAAiB,EAAE,CAAC;IAC/B,kGAAkG;IAClG,SAAS,EAAE,iBAAiB,EAAE,CAAC;IAC/B,0FAA0F;IAC1F,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAC;IACZ,yHAAyH;IACzH,YAAY,EAAE,MAAM,CAAC;IACrB,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,6CAA6C;AAC7C,MAAM,WAAW,qBAAqB;IACpC,sFAAsF;IACtF,IAAI,EAAE,OAAO,CAAC;IACd,yHAAyH;IACzH,GAAG,EAAE,MAAM,CAAC;IACZ,kCAAkC;IAClC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,yFAAyF;AACzF,MAAM,WAAW,eAAe;IAC9B,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb,gFAAgF;IAChF,QAAQ,EAAE,MAAM,CAAC;IACjB,uFAAuF;IACvF,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,4CAA4C;AAC5C,MAAM,WAAW,oBAAoB;IACnC;;;;;;;OAOG;IACH,SAAS,EAAE,WAAW,CAAC;IACvB,mFAAmF;IACnF,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,oEAAoE;IACpE,YAAY,EAAE,MAAM,CAAC;IACrB,wFAAwF;IACxF,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,mFAAmF;IACnF,SAAS,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAC;CACb;AAED,gDAAgD;AAChD,MAAM,WAAW,wBAAwB;IACvC,8FAA8F;IAC9F,OAAO,EAAE,OAAO,CAAC;IACjB,4FAA4F;IAC5F,IAAI,EAAE,OAAO,CAAC;IACd,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAC;CACb;AAED,2CAA2C;AAC3C,MAAM,WAAW,mBAAoB,SAAQ,gBAAgB;IAC3D,kEAAkE;IAClE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;IACzB,kCAAkC;IAClC,IAAI,EAAE,OAAO,CAAC;IACd,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAC;IACZ,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAID;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,iFAAiF;AACjF,wBAAgB,eAAe,CAAC,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,CAEhE;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,mBAAmB,EAAE,WAAW,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAK1G;AAED,wFAAwF;AACxF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,GAAG,MAAM,CAQT;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,GAAG,MAAM,CAMT;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAExF;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,OAAO,CAAC;IACd,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,GAAG,MAAM,CAcT;AAED,sDAAsD;AACtD,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,MAAM,CAI7G;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAE1E;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,OAAO,GAAG,qBAAqB,CAS7E;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAIvG;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,GAAG,gBAAgB,CAYpE;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAMxE;AAgBD,iGAAiG;AACjG,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAOpE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAG/E;AA8BD,4FAA4F;AAC5F,wBAAgB,oCAAoC,IAAI,IAAI,CAE3D;AAwED;;;;GAIG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,iBAAiB,EACvB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,mBAAmB,CAAC,CAa9B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,iBAAiB,EACvB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,mBAAmB,CAAC,CA4C9B;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,wBAAwB,GAAG,SAAS,CAI5F;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,kBAAkB,EACxB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,oBAAoB,CAAC,CAoC/B;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,iBAAiB,EACvB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,mBAAmB,CAAC,CA2B9B;AAmBD;;;;;;;;;;;;;;GAcG;AACH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,qBAAqB,GAAG;IAAE,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,EACvD,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,uBAAuB,CAAC,CA0GlC;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,mBAAmB,EACzB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,qBAAqB,CAAC,CAWhC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoDG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,kBAAkB,EACxB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,oBAAoB,CAAC,CA8C/B;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,sBAAsB,EAC5B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,wBAAwB,CAAC,CAuBnC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"activity-contracts.d.ts","sourceRoot":"","sources":["../../src/op/activity-contracts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AA+BH,eAAO,MAAM,qBAAqB;;;;;;;;EASjC,CAAC;AAEF,eAAO,MAAM,qBAAqB
|
|
1
|
+
{"version":3,"file":"activity-contracts.d.ts","sourceRoot":"","sources":["../../src/op/activity-contracts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AA+BH,eAAO,MAAM,qBAAqB;;;;;;;;EASjC,CAAC;AAEF,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;EAiBjC,CAAC;AAEF,eAAO,MAAM,sBAAsB;;;;;;;;;;EAWlC,CAAC;AAEF,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;EAYjC,CAAC;AAEF,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;EAwBrC,CAAC;AAEF,eAAO,MAAM,uBAAuB;;;;;;;;;EASnC,CAAC;AAEF,eAAO,MAAM,0BAA0B;;;;;;;;EAKtC,CAAC;AAEF,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;EAgBlC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-terraform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src/",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"zod": "^4.3.6",
|
|
59
|
-
"@intentius/chant": "^0.
|
|
59
|
+
"@intentius/chant": "^0.63.0",
|
|
60
60
|
"typescript": "^5.9.3"
|
|
61
61
|
},
|
|
62
62
|
"description": "terraform lexicon for chant, existing Terraform root modules as declarable entities",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# What the gated acceptance suites have actually run against
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Five `describe` blocks across three files run this lexicon against a real
|
|
4
4
|
binary rather than a fixture, and every one of them skips rather than fails
|
|
5
5
|
when its dependency is absent. A skip is silent by design, so without this
|
|
6
6
|
file nothing in the tree says which of them has ever passed, against what, or
|
|
@@ -170,6 +170,47 @@ The fixture was deliberately left as an `aws_vpc`. A log group would make the
|
|
|
170
170
|
block pass off `unowned[]` alone and would stop it proving the content-matcher
|
|
171
171
|
path, which is the only thing it exists to prove.
|
|
172
172
|
|
|
173
|
+
### 5. `TerraformAdoptOp reaches its gate on a checkout that has never run init`
|
|
174
|
+
|
|
175
|
+
Same file, third block (#2302). One test: build `TerraformAdoptOp` and drive
|
|
176
|
+
it through `runOpLocally`, exactly as the apply Op's own acceptance suite
|
|
177
|
+
does, over a project this test creates fresh and never initializes by hand —
|
|
178
|
+
no `terraformInit` call anywhere in the block. The Op's own Init phase is what
|
|
179
|
+
has to download the provider, or the Ledger step fails the way it failed in
|
|
180
|
+
INTENTIUS/choudoufu#1026. `TerraformAdoptOp` gates unconditionally, so a
|
|
181
|
+
passing run ends `status: "gated"` right after the Ledger phase — reaching the
|
|
182
|
+
gate is the pass, not a completed adoption.
|
|
183
|
+
|
|
184
|
+
Gated on a `choudoufu` on PATH and `CHOUDOUFU_EMULATOR_ENDPOINT`. No `aws` CLI:
|
|
185
|
+
this block creates no live resource of its own.
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
npx vitest run lexicons/terraform/src/composites/terraform-adopt-op.acceptance.test.ts
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Last passed: 2026-09-09, choudoufu v0.15.0 with the pinned floci emulator up.
|
|
192
|
+
Removing the Init phase this block exists to guard reproduces
|
|
193
|
+
INTENTIUS/choudoufu#1026 verbatim:
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
choudoufu live-plan failed in <dir> (exit 1)
|
|
197
|
+
Error: Provider unavailable for marker discovery
|
|
198
|
+
|
|
199
|
+
Finding the live resources of this estate needs provider
|
|
200
|
+
provider["registry.opentofu.org/hashicorp/aws"], which could not be used:
|
|
201
|
+
cannot read the schema of provider registry.opentofu.org/hashicorp/aws:
|
|
202
|
+
failed to instantiate provider "registry.opentofu.org/hashicorp/aws" to
|
|
203
|
+
obtain schema: unavailable provider "registry.opentofu.org/hashicorp/aws".
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
With the Init phase back, the same run reaches its gate:
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
✓ TerraformAdoptOp reaches its gate on a checkout that has never run init >
|
|
210
|
+
Init downloads the provider the Ledger step needs, and the run reaches
|
|
211
|
+
its gate
|
|
212
|
+
```
|
|
213
|
+
|
|
173
214
|
## Running everything that can run on a machine with no choudoufu
|
|
174
215
|
|
|
175
216
|
```
|
|
@@ -179,15 +220,15 @@ npx vitest run \
|
|
|
179
220
|
lexicons/terraform/src/op/activities/choudoufu.acceptance.test.ts
|
|
180
221
|
```
|
|
181
222
|
|
|
182
|
-
With `terraform` on PATH and the registry reachable, that is 1 passed and
|
|
223
|
+
With `terraform` on PATH and the registry reachable, that is 1 passed and 6
|
|
183
224
|
skipped, and each skip names its own reason in the block title. With
|
|
184
|
-
choudoufu v0.15.0
|
|
185
|
-
and 0 skipped: no block in this file skips any more. On
|
|
186
|
-
exactly what it printed.
|
|
225
|
+
choudoufu v0.15.0 and the emulator present (the `aws` CLI besides, for block
|
|
226
|
+
4), it is 7 passed and 0 skipped: no block in this file skips any more. On
|
|
227
|
+
2026-09-09 that is exactly what it printed.
|
|
187
228
|
|
|
188
229
|
```
|
|
189
230
|
Test Files 3 passed (3)
|
|
190
|
-
Tests
|
|
231
|
+
Tests 7 passed (7)
|
|
191
232
|
```
|
|
192
233
|
|
|
193
234
|
Bring the emulator up on a fixed port from choudoufu's own compose file rather
|
|
@@ -69,6 +69,8 @@ import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
69
69
|
import { tmpdir } from "node:os";
|
|
70
70
|
import { join } from "node:path";
|
|
71
71
|
import { afterAll, describe, expect, it } from "vitest";
|
|
72
|
+
import { loadActivities, loadProfiles, runOpLocally, type OpConfig } from "@intentius/chant/op";
|
|
73
|
+
import { TerraformAdoptOp } from "./terraform-adopt-op";
|
|
72
74
|
import { choudoufuAdopt, choudoufuLivePlan, terraformInit } from "../op/activities/terraform";
|
|
73
75
|
|
|
74
76
|
function onPath(cmd: string): boolean {
|
|
@@ -177,3 +179,66 @@ describe.skipIf(skipReason !== "")(
|
|
|
177
179
|
);
|
|
178
180
|
},
|
|
179
181
|
);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* #2302: `TerraformAdoptOp` built with no Init phase reached its Ledger step
|
|
185
|
+
* with an empty `.terraform` on every checkout that had never run `init` —
|
|
186
|
+
* every fresh CI checkout, not a GitLab peculiarity. This drives the built Op
|
|
187
|
+
* itself through `runOpLocally`, the way `terraform-apply-op.acceptance.test.ts`
|
|
188
|
+
* does, over a project this test creates fresh and never initializes by hand:
|
|
189
|
+
* no `terraformInit` call anywhere in this block, unlike the suite above. The
|
|
190
|
+
* Op's own Init phase is what has to do that work, or the Ledger step fails
|
|
191
|
+
* the same way it failed in INTENTIUS/choudoufu#1026.
|
|
192
|
+
*
|
|
193
|
+
* `TerraformAdoptOp` gates unconditionally (no `gate: "never"`, unlike
|
|
194
|
+
* `TerraformApplyOp`), so a passing run here ends with `status: "gated"`
|
|
195
|
+
* right after the Ledger phase — "reaching its gate" is the pass, not a
|
|
196
|
+
* completed adoption. That is proof enough: the Ledger step needs the
|
|
197
|
+
* provider schema, and a run that reaches the gate is a run whose Ledger step
|
|
198
|
+
* already read it.
|
|
199
|
+
*
|
|
200
|
+
* Needs only a `choudoufu` on PATH and the emulator, not the `aws` CLI: this
|
|
201
|
+
* block creates no live resource and never reads `ledger.adoptions`, so a
|
|
202
|
+
* narrower gate than the suite above is correct instead of borrowed.
|
|
203
|
+
*/
|
|
204
|
+
const gateSkipReason = !onPath("choudoufu")
|
|
205
|
+
? "no choudoufu binary on PATH"
|
|
206
|
+
: !emulatorEndpoint
|
|
207
|
+
? "CHOUDOUFU_EMULATOR_ENDPOINT is not set (bring up choudoufu's `just smoke` emulator stack and export it)"
|
|
208
|
+
: "";
|
|
209
|
+
|
|
210
|
+
describe.skipIf(gateSkipReason !== "")(
|
|
211
|
+
`TerraformAdoptOp reaches its gate on a checkout that has never run init${gateSkipReason ? ` (skipped: ${gateSkipReason})` : ""}`,
|
|
212
|
+
() => {
|
|
213
|
+
it(
|
|
214
|
+
"Init downloads the provider the Ledger step needs, and the run reaches its gate",
|
|
215
|
+
{ timeout: 300_000 },
|
|
216
|
+
async () => {
|
|
217
|
+
const dir = project();
|
|
218
|
+
|
|
219
|
+
process.env.AWS_ENDPOINT_URL ??= emulatorEndpoint;
|
|
220
|
+
process.env.AWS_ACCESS_KEY_ID ??= "choudoufu-emulator";
|
|
221
|
+
process.env.AWS_SECRET_ACCESS_KEY ??= "choudoufu-emulator";
|
|
222
|
+
process.env.AWS_DEFAULT_REGION ??= "us-east-1";
|
|
223
|
+
|
|
224
|
+
// No `terraformInit` call here. `dir/root` is a fresh checkout: no
|
|
225
|
+
// `.terraform`, only the fixture's own `main.tf` and
|
|
226
|
+
// `estate.chdf.hcl` — exactly what `git clone` leaves behind, since
|
|
227
|
+
// `.terraform` is what every `.gitignore` in this ecosystem excludes.
|
|
228
|
+
const { op } = TerraformAdoptOp({ name: "gate-check-adopt", root: "estate", cwd: dir });
|
|
229
|
+
|
|
230
|
+
const activities = await loadActivities(["terraform"]);
|
|
231
|
+
const result = await runOpLocally((op as unknown as { props: OpConfig }).props, activities, await loadProfiles());
|
|
232
|
+
|
|
233
|
+
expect(result.status).toBe("gated");
|
|
234
|
+
expect(result.records.map((r) => `${r.phase}:${r.status}`)).toEqual([
|
|
235
|
+
"Init:ok",
|
|
236
|
+
"Check:ok",
|
|
237
|
+
"Ledger:ok",
|
|
238
|
+
"Gate:skipped",
|
|
239
|
+
"Adopt:skipped",
|
|
240
|
+
]);
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
},
|
|
244
|
+
);
|
|
@@ -26,15 +26,37 @@ const isActivity = (s: StepDefinition): s is ActivityStep => s.kind === "activit
|
|
|
26
26
|
const steps = (op: OpConfig): ActivityStep[] => op.phases.flatMap((p) => p.steps.filter(isActivity));
|
|
27
27
|
const base = { name: "estate-adopt", root: "estate" };
|
|
28
28
|
|
|
29
|
-
describe("TerraformAdoptOp phases (#2105)", () => {
|
|
30
|
-
test("Check, Ledger, Gate, Adopt, in that order", () => {
|
|
29
|
+
describe("TerraformAdoptOp phases (#2105, #2302)", () => {
|
|
30
|
+
test("Init, Check, Ledger, Gate, Adopt, in that order", () => {
|
|
31
31
|
const op = props(base);
|
|
32
|
-
expect(phaseNames(op)).toEqual(["Check", "Ledger", "Gate", "Adopt"]);
|
|
33
|
-
expect(steps(op).map((s) => s.fn)).toEqual([
|
|
32
|
+
expect(phaseNames(op)).toEqual(["Init", "Check", "Ledger", "Gate", "Adopt"]);
|
|
33
|
+
expect(steps(op).map((s) => s.fn)).toEqual([
|
|
34
|
+
"terraformInit",
|
|
35
|
+
"choudoufuLiveCheck",
|
|
36
|
+
"choudoufuLivePlan",
|
|
37
|
+
"choudoufuAdopt",
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// #2302: the Ledger step's `live-plan` needs the provider schema to
|
|
42
|
+
// discover markers, which a checkout that has never run `init` does not
|
|
43
|
+
// have — every fresh CI checkout, not a GitLab peculiarity. Without this
|
|
44
|
+
// phase `live-plan` fails outright: "Error: Provider unavailable for
|
|
45
|
+
// marker discovery".
|
|
46
|
+
test("Init runs terraform init first, the same shape TerraformApplyOp and TerraformWatchOp use", () => {
|
|
47
|
+
const init = props(base).phases[0].steps[0] as ActivityStep;
|
|
48
|
+
expect(init.fn).toBe("terraformInit");
|
|
49
|
+
expect(init.args?.root).toBe("estate");
|
|
50
|
+
expect(init.args && "upgrade" in init.args).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("upgrade rides the Init step only, the same option name as the other two Ops", () => {
|
|
54
|
+
const init = props({ ...base, upgrade: true }).phases[0].steps[0] as ActivityStep;
|
|
55
|
+
expect(init.args?.upgrade).toBe(true);
|
|
34
56
|
});
|
|
35
57
|
|
|
36
|
-
test("Check runs live-check
|
|
37
|
-
const check = props(base).phases[
|
|
58
|
+
test("Check runs live-check second, and reports whether the root was refused", () => {
|
|
59
|
+
const check = props(base).phases[1].steps[0] as ActivityStep;
|
|
38
60
|
expect(check.fn).toBe("choudoufuLiveCheck");
|
|
39
61
|
expect(check.outcomeAttribute).toEqual({ name: "Refused", from: "refused" });
|
|
40
62
|
// No cloud calls, so this is the cheapest place to learn the root cannot
|
|
@@ -43,7 +65,7 @@ describe("TerraformAdoptOp phases (#2105)", () => {
|
|
|
43
65
|
});
|
|
44
66
|
|
|
45
67
|
test("Ledger is the -adoption-only live plan, and publishes both counts", () => {
|
|
46
|
-
const ledger = props(base).phases[
|
|
68
|
+
const ledger = props(base).phases[2].steps[0] as ActivityStep;
|
|
47
69
|
expect(ledger.fn).toBe("choudoufuLivePlan");
|
|
48
70
|
expect(ledger.id).toBe("ledger");
|
|
49
71
|
expect(ledger.args?.adoptionOnly).toBe(true);
|
|
@@ -64,14 +86,14 @@ describe("TerraformAdoptOp phases (#2105)", () => {
|
|
|
64
86
|
|
|
65
87
|
test("an explicit estate rides the Ledger step only", () => {
|
|
66
88
|
const op = props({ ...base, estate: "prod-networking" });
|
|
67
|
-
expect((op.phases[
|
|
68
|
-
expect((op.phases[
|
|
89
|
+
expect((op.phases[2].steps[0] as ActivityStep).args?.estate).toBe("prod-networking");
|
|
90
|
+
expect((op.phases[1].steps[0] as ActivityStep).args?.estate).toBeUndefined();
|
|
69
91
|
});
|
|
70
92
|
});
|
|
71
93
|
|
|
72
94
|
describe("TerraformAdoptOp gates on the ledger (#2105)", () => {
|
|
73
95
|
const gateOf = (op: OpConfig): GateStep => {
|
|
74
|
-
const step = op.phases[
|
|
96
|
+
const step = op.phases[3].steps[0];
|
|
75
97
|
expect(step.kind).toBe("gate");
|
|
76
98
|
return step as GateStep;
|
|
77
99
|
};
|
|
@@ -107,7 +129,7 @@ describe("TerraformAdoptOp gates on the ledger (#2105)", () => {
|
|
|
107
129
|
});
|
|
108
130
|
|
|
109
131
|
describe("TerraformAdoptOp adopts the ledger's matches and nothing else (#2105)", () => {
|
|
110
|
-
const adoptStep = (op: OpConfig): ActivityStep => op.phases[
|
|
132
|
+
const adoptStep = (op: OpConfig): ActivityStep => op.phases[4].steps[0] as ActivityStep;
|
|
111
133
|
|
|
112
134
|
test("the Adopt step takes the ledger's adoptable set by reference", () => {
|
|
113
135
|
const adopt = adoptStep(props(base));
|
|
@@ -13,8 +13,18 @@
|
|
|
13
13
|
* the two values that would claim it. Cloud-to-code here is "claim what the
|
|
14
14
|
* configuration already describes" — a tag write, not a regeneration.
|
|
15
15
|
*
|
|
16
|
-
* Phases: Check, Ledger, Gate, Adopt.
|
|
16
|
+
* Phases: Init, Check, Ledger, Gate, Adopt.
|
|
17
17
|
*
|
|
18
|
+
* - **Init** runs `terraform init`, the same step `TerraformApplyOp` and
|
|
19
|
+
* `TerraformWatchOp` open with. The Ledger step's `live-plan` needs the
|
|
20
|
+
* provider schema to discover markers on the live resources it reads, and
|
|
21
|
+
* a checkout that has never run `init` has no `.terraform` directory to
|
|
22
|
+
* read one from — which is every fresh CI checkout, not a GitLab
|
|
23
|
+
* peculiarity (#2302). Without this phase `live-plan` fails outright:
|
|
24
|
+
* `Error: Provider unavailable for marker discovery`. This composite had
|
|
25
|
+
* no Init phase because nothing had ever run `live-adopt` before
|
|
26
|
+
* INTENTIUS/choudoufu#1026 did, so the gap survived every guard that only
|
|
27
|
+
* read the generated YAML.
|
|
18
28
|
* - **Check** runs `live-check`, which makes no cloud calls and says whether
|
|
19
29
|
* this configuration can move under markers at all. It runs first because
|
|
20
30
|
* a root that `live-check` refuses will produce an adoption ledger that
|
|
@@ -83,6 +93,7 @@
|
|
|
83
93
|
|
|
84
94
|
import { Op, phase, gate, activity, OpResource } from "@intentius/chant/op";
|
|
85
95
|
import {
|
|
96
|
+
terraformInit as initStep,
|
|
86
97
|
choudoufuLiveCheck as checkStep,
|
|
87
98
|
choudoufuLivePlan as ledgerStep,
|
|
88
99
|
choudoufuAdopt as adoptStep,
|
|
@@ -110,6 +121,12 @@ export interface TerraformAdoptOpConfig {
|
|
|
110
121
|
gateTimeout?: string;
|
|
111
122
|
/** Override the gate description shown to the approver. */
|
|
112
123
|
gateDescription?: string;
|
|
124
|
+
/**
|
|
125
|
+
* `-upgrade` on the Init step: re-resolve provider and module versions.
|
|
126
|
+
* Same field, same meaning, as `TerraformApplyOpConfig.upgrade` and
|
|
127
|
+
* `TerraformWatchOpConfig.upgrade`.
|
|
128
|
+
*/
|
|
129
|
+
upgrade?: boolean;
|
|
113
130
|
/**
|
|
114
131
|
* Directory each step starts the `chant.config.*` search from, which is what
|
|
115
132
|
* `terraform.roots` and the root's relative `dir` resolve against. Default:
|
|
@@ -130,7 +147,7 @@ export interface TerraformAdoptOpConfig {
|
|
|
130
147
|
}
|
|
131
148
|
|
|
132
149
|
export interface TerraformAdoptOpResources {
|
|
133
|
-
/** Op resource. Generates the Check/Ledger/Gate/Adopt workflow. */
|
|
150
|
+
/** Op resource. Generates the Init/Check/Ledger/Gate/Adopt workflow. */
|
|
134
151
|
op: InstanceType<typeof OpResource>;
|
|
135
152
|
}
|
|
136
153
|
|
|
@@ -178,6 +195,7 @@ export function TerraformAdoptOp(config: TerraformAdoptOpConfig): TerraformAdopt
|
|
|
178
195
|
TerraformMode: "live",
|
|
179
196
|
},
|
|
180
197
|
phases: [
|
|
198
|
+
phase("Init", [initStep(config.root, { ...where, ...(config.upgrade ? { upgrade: true } : {}) })]),
|
|
181
199
|
phase("Check", [check]),
|
|
182
200
|
phase("Ledger", [ledger]),
|
|
183
201
|
phase("Gate", [
|
|
@@ -351,3 +351,35 @@ describe("TerraformApplyOp on a live root (#2106 follow-up)", () => {
|
|
|
351
351
|
expect(fns).toEqual(["terraformInit", "terraformPlan", "terraformApply"]);
|
|
352
352
|
});
|
|
353
353
|
});
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* #2300 — the gate binds the plan. The Plan step publishes `planDigest` and
|
|
357
|
+
* the Gate step references it, so the resolution `chant approve` writes names
|
|
358
|
+
* the plan it approves. Before this, INTENTIUS/choudoufu#1026 approved,
|
|
359
|
+
* renamed a resource in the root, re-ran, and watched the apply proceed.
|
|
360
|
+
*/
|
|
361
|
+
describe("TerraformApplyOp binds the gate to the plan (#2300)", () => {
|
|
362
|
+
test("the gate step references the Plan step's own digest", () => {
|
|
363
|
+
const op = props({ name: "app-apply", root: "app" });
|
|
364
|
+
const gate = op.phases.find((p) => p.name === "Gate")!.steps.find(isGate)!;
|
|
365
|
+
expect(isStepOutputRef(gate.plan)).toBe(true);
|
|
366
|
+
expect(gate.plan).toMatchObject({ step: "plan", path: "planDigest" });
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("a run records the plan on the pending fact it leaves behind", async () => {
|
|
370
|
+
const op = props({ name: "prod-apply", root: "app" });
|
|
371
|
+
const digest = `sha256:${"c".repeat(64)}`;
|
|
372
|
+
const activities = new Map<string, (args: Record<string, unknown>) => Promise<unknown>>([
|
|
373
|
+
["terraformInit", async () => ({})],
|
|
374
|
+
["terraformPlan", async () => ({ planFile: "chant.tfplan", planDigest: digest })],
|
|
375
|
+
["terraformShow", async () => ({})],
|
|
376
|
+
["terraformApply", async () => ({})],
|
|
377
|
+
]);
|
|
378
|
+
const gates = memoryGateLedgerPort();
|
|
379
|
+
const result = await runOpLocally(op, activities, {}, undefined, { gates, now: "2026-09-09T12:00:00.000Z" });
|
|
380
|
+
|
|
381
|
+
expect(result.status).toBe("gated");
|
|
382
|
+
expect(result.gate?.planDigest).toBe(digest);
|
|
383
|
+
expect(gates.appended[0].planDigest).toBe(digest);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
@@ -56,11 +56,21 @@
|
|
|
56
56
|
*
|
|
57
57
|
* A gate is a fact on the gate ledger, not a wait (#2119). Reaching one,
|
|
58
58
|
* `chant run` consults the ledger (`packages/core/src/op/gate.ts`): a
|
|
59
|
-
* resolution
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
59
|
+
* resolution recorded for this run's own plan lets the run walk through, and
|
|
60
|
+
* anything else records the pending fact and ends the run with status `gated`
|
|
61
|
+
* and exit 3. `chant approve <op> <gate>` writes the resolution, and the next
|
|
62
|
+
* run reads it. `gate: "never"` drops the Gate phase entirely, so a run never
|
|
63
|
+
* stops.
|
|
64
|
+
*
|
|
65
|
+
* **The gate binds the plan** (#2300). The Plan step publishes
|
|
66
|
+
* `planDigest` — its `terraform show -json` change set, hashed — and the Gate
|
|
67
|
+
* step references it, so the resolution names the plan it approves. That
|
|
68
|
+
* closes the window the exit-3 refusal above does not: exit 3 compares the
|
|
69
|
+
* Apply step's fresh plan against the *file* it was handed inside one run,
|
|
70
|
+
* and this compares a later run's fresh plan against what a human actually
|
|
71
|
+
* approved. INTENTIUS/choudoufu#1026 measured the gap by approving, renaming
|
|
72
|
+
* a resource, and re-running: the second run applied without complaint. It
|
|
73
|
+
* now stops, naming the approved digest and the planned one.
|
|
64
74
|
*
|
|
65
75
|
* @example
|
|
66
76
|
* ```typescript
|
|
@@ -226,6 +236,11 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply
|
|
|
226
236
|
show,
|
|
227
237
|
gate(config.gateName ?? config.signalName ?? `approve-${config.name}`, {
|
|
228
238
|
...(config.gateTimeout ? { timeout: config.gateTimeout } : {}),
|
|
239
|
+
// #2300: the approval is for this plan, not for the next run. The
|
|
240
|
+
// Plan step's digest rides onto the pending fact, `chant approve`
|
|
241
|
+
// copies it onto the resolution, and a later run whose fresh plan
|
|
242
|
+
// digests differently is refused with both digests named.
|
|
243
|
+
plan: plan.out.planDigest,
|
|
229
244
|
description:
|
|
230
245
|
config.gateDescription ??
|
|
231
246
|
`Approve terraform apply of ${live ? "live " : ""}root "${config.root}" (gate: ${gateMode}). ` +
|
|
@@ -103,6 +103,20 @@ describe("TerraformWatchOp finding modes (#2087)", () => {
|
|
|
103
103
|
expect(step.outcomeAttribute).toEqual({ name: "Comment", from: "commentUrl" });
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
test("the finding step names its Op, so two watches over one root cannot collide (#2319)", () => {
|
|
107
|
+
// The pairing #2319 names: a stock drift watch and a `live: true` one over
|
|
108
|
+
// the same root is what this composite is built for, and both pass the
|
|
109
|
+
// root as `env`. Before #2319 that was the whole marker, so each nightly
|
|
110
|
+
// run PATCHed the title and body of the other Op's issue.
|
|
111
|
+
expect(findingStep("issue").args?.op).toBe("app-watch");
|
|
112
|
+
const stock = props({ name: "app-drift", root: "app", findingMode: "issue" });
|
|
113
|
+
const paired = props({ name: "app-drift-nightly", root: "app", findingMode: "issue" });
|
|
114
|
+
const a = stock.phases[2].steps[0] as ActivityStep;
|
|
115
|
+
const b = paired.phases[2].steps[0] as ActivityStep;
|
|
116
|
+
expect(a.args?.env).toBe(b.args?.env);
|
|
117
|
+
expect(a.args?.op).not.toBe(b.args?.op);
|
|
118
|
+
});
|
|
119
|
+
|
|
106
120
|
test("the finding step derives no plan of its own", () => {
|
|
107
121
|
// `entries: []` is what stops reconcilePr shelling to `chant lifecycle
|
|
108
122
|
// plan --json`: the finding is already written by the time it runs.
|
|
@@ -269,11 +269,21 @@ export function TerraformWatchOp(config: TerraformWatchOpConfig): TerraformWatch
|
|
|
269
269
|
// terraform that is the root, which with its workspace is the deployment
|
|
270
270
|
// target. `entries: []` and `body` together mean the activity opens what
|
|
271
271
|
// it is given rather than running `chant lifecycle plan` to find out.
|
|
272
|
+
//
|
|
273
|
+
// `op` is what keeps the sticky issue this Op's own (#2319). The root
|
|
274
|
+
// alone does not: a stock drift watch and a `live: true` watch over one
|
|
275
|
+
// root is the pairing this composite is built for, and before #2319 both
|
|
276
|
+
// resolved to one env-keyed marker, so each nightly run rewrote the
|
|
277
|
+
// other's issue title and body. Two Ops in a project do not share a raw
|
|
278
|
+
// name — it names the Op's output directory — though the marker carries
|
|
279
|
+
// the slug rather than the raw name, and the slug is not injective; see
|
|
280
|
+
// `issueMarker` for what that leaves and why it is accepted.
|
|
272
281
|
const report: ActivityStep = {
|
|
273
282
|
kind: "activity",
|
|
274
283
|
fn: "reconcilePr",
|
|
275
284
|
args: {
|
|
276
285
|
env: config.root,
|
|
286
|
+
op: config.name,
|
|
277
287
|
mode: findingMode,
|
|
278
288
|
entries: [],
|
|
279
289
|
title: config.title ?? `Terraform drift in root "${config.root}"`,
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
terraformEnvironment,
|
|
30
30
|
terraformBinary,
|
|
31
31
|
countPlanChanges,
|
|
32
|
+
terraformPlanChangeSet,
|
|
33
|
+
terraformPlanDigest,
|
|
32
34
|
quoteArg,
|
|
33
35
|
DEFAULT_PLAN_FILE,
|
|
34
36
|
} from "./terraform";
|
|
@@ -253,6 +255,79 @@ describe("countPlanChanges (#2086)", () => {
|
|
|
253
255
|
});
|
|
254
256
|
});
|
|
255
257
|
|
|
258
|
+
/**
|
|
259
|
+
* #2300. The digest is what a `TerraformApplyOp` gate binds its approval to,
|
|
260
|
+
* so what it covers and what it ignores is the contract an approver is
|
|
261
|
+
* relying on. Taken over the `show -json` rendering rather than the plan
|
|
262
|
+
* file's bytes: two `plan -out` runs over an unchanged root write different
|
|
263
|
+
* bytes and the same plan.
|
|
264
|
+
*/
|
|
265
|
+
describe("terraformPlanDigest (#2300)", () => {
|
|
266
|
+
test("the same change set digests the same across renderings that differ only in noise", () => {
|
|
267
|
+
const a = JSON.parse(PLAN_JSON) as Record<string, unknown>;
|
|
268
|
+
const b = {
|
|
269
|
+
...JSON.parse(PLAN_JSON) as Record<string, unknown>,
|
|
270
|
+
format_version: "1.3",
|
|
271
|
+
terraform_version: "1.9.0",
|
|
272
|
+
timestamp: "2026-09-09T12:00:00Z",
|
|
273
|
+
prior_state: { anything: true },
|
|
274
|
+
configuration: { root_module: { resources: ["moved around"] } },
|
|
275
|
+
checks: [],
|
|
276
|
+
};
|
|
277
|
+
expect(terraformPlanDigest(b)).toBe(terraformPlanDigest(a));
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("a renamed resource changes it — the exact edit choudoufu#1026 made between approve and re-run", () => {
|
|
281
|
+
const before = JSON.parse(PLAN_JSON) as { resource_changes: { address: string }[] };
|
|
282
|
+
const after = JSON.parse(PLAN_JSON) as { resource_changes: { address: string }[] };
|
|
283
|
+
after.resource_changes[0].address = "null_resource.renamed";
|
|
284
|
+
expect(terraformPlanDigest(after)).not.toBe(terraformPlanDigest(before));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("a changed planned value changes it, even at the same addresses and actions", () => {
|
|
288
|
+
const before = { resource_changes: [{ address: "null_resource.a", change: { actions: ["create"], after: { triggers: { v: "1" } } } }] };
|
|
289
|
+
const after = { resource_changes: [{ address: "null_resource.a", change: { actions: ["create"], after: { triggers: { v: "2" } } } }] };
|
|
290
|
+
expect(terraformPlanDigest(after)).not.toBe(terraformPlanDigest(before));
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("a changed action changes it — create is not destroy", () => {
|
|
294
|
+
const create = { resource_changes: [{ address: "null_resource.a", change: { actions: ["create"] } }] };
|
|
295
|
+
const destroy = { resource_changes: [{ address: "null_resource.a", change: { actions: ["delete"] } }] };
|
|
296
|
+
expect(terraformPlanDigest(destroy)).not.toBe(terraformPlanDigest(create));
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("output changes are part of the plan", () => {
|
|
300
|
+
const withOutput = { resource_changes: [], output_changes: { url: { actions: ["create"], after: "https://a" } } };
|
|
301
|
+
const withOther = { resource_changes: [], output_changes: { url: { actions: ["create"], after: "https://b" } } };
|
|
302
|
+
expect(terraformPlanDigest(withOther)).not.toBe(terraformPlanDigest(withOutput));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("the emitter's iteration order does not change the answer", () => {
|
|
306
|
+
const one = { resource_changes: [{ address: "b", change: { actions: ["create"] } }, { address: "a", change: { actions: ["create"] } }] };
|
|
307
|
+
const two = { resource_changes: [{ address: "a", change: { actions: ["create"] } }, { address: "b", change: { actions: ["create"] } }] };
|
|
308
|
+
expect(terraformPlanDigest(one)).toBe(terraformPlanDigest(two));
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("a plan with no resource_changes still digests, rather than throwing", () => {
|
|
312
|
+
expect(terraformPlanDigest({ format_version: "1.2" })).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
313
|
+
expect(terraformPlanDigest(null)).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("the change set keeps the fields an approver reads and drops the rest", () => {
|
|
317
|
+
const { resourceChanges } = terraformPlanChangeSet({
|
|
318
|
+
timestamp: "2026-09-09T12:00:00Z",
|
|
319
|
+
resource_changes: [{
|
|
320
|
+
address: "null_resource.a", mode: "managed", type: "null_resource", name: "a",
|
|
321
|
+
provider_name: "registry.terraform.io/hashicorp/null",
|
|
322
|
+
change: { actions: ["create"], before: null, after: { x: 1 }, after_unknown: {}, before_sensitive: false },
|
|
323
|
+
}],
|
|
324
|
+
});
|
|
325
|
+
expect(resourceChanges[0]).toMatchObject({ address: "null_resource.a", actions: ["create"], after: { x: 1 } });
|
|
326
|
+
expect(resourceChanges[0]).not.toHaveProperty("before_sensitive");
|
|
327
|
+
expect(resourceChanges[0]).not.toHaveProperty("change");
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
256
331
|
// ── Root resolution ─────────────────────────────────────────────────────────
|
|
257
332
|
|
|
258
333
|
describe("root resolution against terraform.roots (#2086)", () => {
|
|
@@ -323,6 +398,16 @@ describe("terraformPlan — the -detailed-exitcode mapping (#2086)", () => {
|
|
|
323
398
|
]);
|
|
324
399
|
});
|
|
325
400
|
|
|
401
|
+
// #2300 — the Plan phase is where the plan gets its identity, so the
|
|
402
|
+
// activity's own result carries it and the Gate step references it from
|
|
403
|
+
// there rather than re-reading the file.
|
|
404
|
+
test("the result carries the plan's digest, taken off the show -json rendering", async () => {
|
|
405
|
+
const dir = project({ roots: { app: { dir: "./infra" } } });
|
|
406
|
+
showReplies();
|
|
407
|
+
const result = await terraformPlan({ root: "app", cwd: dir });
|
|
408
|
+
expect(result.planDigest).toBe(terraformPlanDigest(JSON.parse(PLAN_JSON)));
|
|
409
|
+
});
|
|
410
|
+
|
|
326
411
|
test("exit 2: changed, and the plan is still shown", async () => {
|
|
327
412
|
const dir = project({ roots: { app: { dir: "./infra" } } });
|
|
328
413
|
replies.push({ match: "plan ", reply: execError(2, "", "Plan: 2 to add...\n") });
|
|
@@ -44,6 +44,7 @@ import { writeFileSync } from "node:fs";
|
|
|
44
44
|
import { promisify } from "node:util";
|
|
45
45
|
import { resolve, dirname, join } from "node:path";
|
|
46
46
|
import { loadChantConfigUpward } from "@intentius/chant/config";
|
|
47
|
+
import { computePlanDigest } from "@intentius/chant/op";
|
|
47
48
|
import type { TerraformConfig, TerraformRootConfig } from "../../config";
|
|
48
49
|
import { detectLiveEstate } from "./live-detect";
|
|
49
50
|
import {
|
|
@@ -247,6 +248,92 @@ export interface PlanChangeCounts {
|
|
|
247
248
|
destroys: number;
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
/**
|
|
252
|
+
* One resource's proposed change, as a plan digest sees it (#2300) — the
|
|
253
|
+
* projection of a `terraform show -json` `resource_changes` entry that
|
|
254
|
+
* survives into {@link terraformPlanDigest}.
|
|
255
|
+
*/
|
|
256
|
+
export interface TerraformPlannedChange {
|
|
257
|
+
address: unknown;
|
|
258
|
+
mode: unknown;
|
|
259
|
+
type: unknown;
|
|
260
|
+
name: unknown;
|
|
261
|
+
index: unknown;
|
|
262
|
+
provider_name: unknown;
|
|
263
|
+
deposed: unknown;
|
|
264
|
+
actions: unknown;
|
|
265
|
+
before: unknown;
|
|
266
|
+
after: unknown;
|
|
267
|
+
after_unknown: unknown;
|
|
268
|
+
replace_paths: unknown;
|
|
269
|
+
importing: unknown;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The change set a `terraform show -json` plan describes, canonicalised
|
|
274
|
+
* (#2300): every proposed change, at its address, with the values that would
|
|
275
|
+
* be written, sorted so two renderings of the same plan project identically.
|
|
276
|
+
*
|
|
277
|
+
* Everything else in the document is dropped, and the drops are the point —
|
|
278
|
+
* see `packages/core/src/lifecycle/plan-digest.ts` for the general rule. Here
|
|
279
|
+
* that means `timestamp` and `terraform_version` (when the plan was taken and
|
|
280
|
+
* by which binary), `format_version` (how it was rendered), `prior_state` and
|
|
281
|
+
* `planned_values` (restatements of the same change set from another angle),
|
|
282
|
+
* `configuration` (the source that produced it, which can be refactored
|
|
283
|
+
* without changing a single proposed action) and `checks` / `relevant_
|
|
284
|
+
* attributes` (diagnostics about the plan, not the plan). What is left is
|
|
285
|
+
* what applying it would do.
|
|
286
|
+
*/
|
|
287
|
+
export function terraformPlanChangeSet(planJson: unknown): {
|
|
288
|
+
resourceChanges: TerraformPlannedChange[];
|
|
289
|
+
outputChanges: unknown;
|
|
290
|
+
} {
|
|
291
|
+
const doc = planJson as { resource_changes?: unknown; output_changes?: unknown } | null | undefined;
|
|
292
|
+
const raw = Array.isArray(doc?.resource_changes) ? doc.resource_changes : [];
|
|
293
|
+
const resourceChanges = raw.map((entry): TerraformPlannedChange => {
|
|
294
|
+
const e = (entry ?? {}) as Record<string, unknown>;
|
|
295
|
+
const change = (e.change ?? {}) as Record<string, unknown>;
|
|
296
|
+
return {
|
|
297
|
+
address: e.address,
|
|
298
|
+
mode: e.mode,
|
|
299
|
+
type: e.type,
|
|
300
|
+
name: e.name,
|
|
301
|
+
index: e.index,
|
|
302
|
+
provider_name: e.provider_name,
|
|
303
|
+
deposed: e.deposed,
|
|
304
|
+
actions: change.actions,
|
|
305
|
+
before: change.before,
|
|
306
|
+
after: change.after,
|
|
307
|
+
after_unknown: change.after_unknown,
|
|
308
|
+
replace_paths: change.replace_paths,
|
|
309
|
+
importing: change.importing,
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
// terraform already emits these in address order, but nothing in the format
|
|
313
|
+
// promises it, and a digest that depends on the emitter's iteration order
|
|
314
|
+
// would refuse a plan that is identical in every way that matters.
|
|
315
|
+
resourceChanges.sort((a, b) =>
|
|
316
|
+
`${String(a.address)}\u0000${String(a.deposed ?? "")}`.localeCompare(
|
|
317
|
+
`${String(b.address)}\u0000${String(b.deposed ?? "")}`,
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
return { resourceChanges, outputChanges: doc?.output_changes ?? {} };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The identity of a terraform plan (#2300): {@link terraformPlanChangeSet}
|
|
325
|
+
* hashed. This is what a `TerraformApplyOp` gate binds its approval to, and
|
|
326
|
+
* what a later run recomputes and compares.
|
|
327
|
+
*
|
|
328
|
+
* Taken over the `show -json` rendering rather than the plan file's bytes on
|
|
329
|
+
* purpose: the file is an opaque, version-stamped archive, and two runs of
|
|
330
|
+
* `plan -out` over an unchanged root produce different bytes. The rendering
|
|
331
|
+
* is the same plan both times.
|
|
332
|
+
*/
|
|
333
|
+
export function terraformPlanDigest(planJson: unknown): string {
|
|
334
|
+
return computePlanDigest("terraform-plan", terraformPlanChangeSet(planJson));
|
|
335
|
+
}
|
|
336
|
+
|
|
250
337
|
/** What {@link terraformPlan} resolved. Carries the plan itself, so no later step re-plans. */
|
|
251
338
|
export interface TerraformPlanResult extends PlanChangeCounts {
|
|
252
339
|
/** `true` when terraform reported exit 2 under `-detailed-exitcode`: the plan proposes changes. */
|
|
@@ -259,6 +346,13 @@ export interface TerraformPlanResult extends PlanChangeCounts {
|
|
|
259
346
|
json: unknown;
|
|
260
347
|
/** `terraform show -no-color <planFile>` — the human-readable plan. */
|
|
261
348
|
text: string;
|
|
349
|
+
/**
|
|
350
|
+
* This plan's identity (#2300) — {@link terraformPlanDigest} over
|
|
351
|
+
* {@link TerraformPlanResult.json}. Hand it to a gate step's `plan` so the
|
|
352
|
+
* approval binds to this plan and not to the next run: `gate(name, { plan:
|
|
353
|
+
* plan.out.planDigest })`.
|
|
354
|
+
*/
|
|
355
|
+
planDigest: string;
|
|
262
356
|
}
|
|
263
357
|
|
|
264
358
|
/**
|
|
@@ -858,7 +952,15 @@ export async function terraformPlan(
|
|
|
858
952
|
const textRun = await run(terraformShowCommand({ binary, json: false, planFile }), dir, env, signal);
|
|
859
953
|
const json: unknown = JSON.parse(jsonRun.stdout);
|
|
860
954
|
|
|
861
|
-
return {
|
|
955
|
+
return {
|
|
956
|
+
changed,
|
|
957
|
+
planFile,
|
|
958
|
+
dir,
|
|
959
|
+
json,
|
|
960
|
+
text: textRun.stdout,
|
|
961
|
+
planDigest: terraformPlanDigest(json),
|
|
962
|
+
...countPlanChanges(json),
|
|
963
|
+
};
|
|
862
964
|
}
|
|
863
965
|
|
|
864
966
|
/**
|