@intentius/chant-lexicon-terraform 0.124.31 → 0.124.33
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/__fixtures__/terragrunt/affected-cases.js +111 -0
- package/dist/__fixtures__/terragrunt/record.js +57 -0
- package/dist/behaviour/index.js +6 -0
- package/dist/behaviour/kinds.js +194 -0
- package/dist/behaviour/predict.js +297 -0
- package/dist/behaviour/reference-catalog.js +178 -0
- package/dist/behaviour/request.js +482 -0
- package/dist/change-set.js +234 -0
- package/dist/changed-roots.js +97 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +44 -0
- package/dist/codegen/generate-cli.js +9 -0
- package/dist/codegen/generate.js +59 -0
- package/dist/codegen/package.js +43 -0
- package/dist/commands.js +106 -0
- package/dist/components/capability-plugin.d.ts +38 -2
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/capability-plugin.js +260 -0
- package/dist/composites/terraform-adopt-op.d.ts +2 -0
- package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
- package/dist/composites/terraform-adopt-op.js +153 -0
- package/dist/composites/terraform-apply-op.d.ts +2 -0
- package/dist/composites/terraform-apply-op.d.ts.map +1 -1
- package/dist/composites/terraform-apply-op.js +184 -0
- package/dist/composites/terraform-pin-rollout-op.js +45 -0
- package/dist/composites/terraform-watch-op.js +219 -0
- package/dist/config.js +118 -0
- package/dist/describe-resources.js +984 -0
- package/dist/generated/index.js +1 -0
- package/dist/hcl/descend.js +208 -0
- package/dist/hcl/edges.js +287 -0
- package/dist/hcl/jsonencode.js +442 -0
- package/dist/hcl/marker.js +212 -0
- package/dist/hcl/parse.js +372 -0
- package/dist/hcl/references.js +156 -0
- package/dist/hcl/roots.js +98 -0
- package/dist/hcl/suppressions.js +189 -0
- package/dist/hcl/value.js +72 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +501 -0
- package/dist/lint/audit-lineage.js +442 -0
- package/dist/lint/post-synth/blocks.js +87 -0
- package/dist/lint/post-synth/fixtures/load.js +94 -0
- package/dist/lint/post-synth/flat-attributes.js +135 -0
- package/dist/lint/post-synth/index.js +90 -0
- package/dist/lint/post-synth/module-source.js +153 -0
- package/dist/lint/post-synth/scope.js +104 -0
- package/dist/lint/post-synth/terragrunt.js +63 -0
- package/dist/lint/post-synth/tf001.js +138 -0
- package/dist/lint/post-synth/tf002.js +143 -0
- package/dist/lint/post-synth/tf003.js +60 -0
- package/dist/lint/post-synth/tf004.js +54 -0
- package/dist/lint/post-synth/tf005.js +68 -0
- package/dist/lint/post-synth/tf006.js +43 -0
- package/dist/lint/post-synth/tf007.js +66 -0
- package/dist/lint/post-synth/tf008.js +64 -0
- package/dist/lint/post-synth/tf009.js +49 -0
- package/dist/lint/post-synth/tf010.js +40 -0
- package/dist/lint/post-synth/tf011.js +38 -0
- package/dist/lint/post-synth/tf012.js +36 -0
- package/dist/lint/post-synth/tf013.js +53 -0
- package/dist/lint/post-synth/tf014.js +61 -0
- package/dist/lint/post-synth/tf015.js +62 -0
- package/dist/lint/post-synth/tf016.js +66 -0
- package/dist/lint/post-synth/tf017.js +39 -0
- package/dist/lint/post-synth/tf018.js +63 -0
- package/dist/lint/post-synth/tf019.js +60 -0
- package/dist/lint/post-synth/tf020.js +103 -0
- package/dist/lint/post-synth/tf021.js +117 -0
- package/dist/lint/post-synth/tf022.js +51 -0
- package/dist/lint/post-synth/tf024.js +62 -0
- package/dist/lint/post-synth/tf025.js +83 -0
- package/dist/lint/post-synth/tf026.js +74 -0
- package/dist/lint/post-synth/tf027.js +66 -0
- package/dist/lint/post-synth/tf028.js +83 -0
- package/dist/lint/post-synth/tf029.js +73 -0
- package/dist/lint/post-synth/tf030.js +253 -0
- package/dist/lint/post-synth/tf031.js +332 -0
- package/dist/lint/post-synth/tf032.js +201 -0
- package/dist/lint/post-synth/tf033.js +126 -0
- package/dist/lint/post-synth/tf034.js +45 -0
- package/dist/lint/post-synth/tf035.js +63 -0
- package/dist/lint/post-synth/tf036.js +97 -0
- package/dist/lint/post-synth/tf037.js +80 -0
- package/dist/lint/post-synth/tf038.js +63 -0
- package/dist/lint/post-synth/tf039.js +55 -0
- package/dist/lint/post-synth/tf040.js +67 -0
- package/dist/lint/post-synth/tf041.js +53 -0
- package/dist/lint/post-synth/tf042.js +38 -0
- package/dist/lint/post-synth/tf043.js +45 -0
- package/dist/lint/post-synth/tf044.js +45 -0
- package/dist/lint/post-synth/tf045.js +44 -0
- package/dist/lint/rules/fixtures/load.js +50 -0
- package/dist/lint/rules/index.js +4 -0
- package/dist/lint/rules/plan-before-apply.js +136 -0
- package/dist/lint/secret-shape.js +149 -0
- package/dist/live-ownership.js +57 -0
- package/dist/lsp/completions.js +95 -0
- package/dist/lsp/context.js +358 -0
- package/dist/lsp/hover.js +45 -0
- package/dist/lsp/option-keys.js +93 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.js +30 -0
- package/dist/op/activities/live-detect.js +143 -0
- package/dist/op/activities/pin-rollout.js +45 -0
- package/dist/op/activities/terraform.js +1270 -0
- package/dist/op/activity-contracts.js +162 -0
- package/dist/op/adoption.js +272 -0
- package/dist/op/builders.js +125 -0
- package/dist/op/resolve-root-mode.js +77 -0
- package/dist/package-cli.js +16 -0
- package/dist/pin/edit-ts.js +76 -0
- package/dist/pin/edit.js +118 -0
- package/dist/pin/forge.js +65 -0
- package/dist/pin/index.js +16 -0
- package/dist/pin/rollout.d.ts.map +1 -1
- package/dist/pin/rollout.js +382 -0
- package/dist/pin/scan.js +287 -0
- package/dist/pin/source.js +171 -0
- package/dist/pin/waves.js +109 -0
- package/dist/plan-digest.js +67 -0
- package/dist/plugin.js +323 -0
- package/dist/serializer.js +23 -0
- package/dist/state-ownership.js +29 -0
- package/dist/terragrunt/affected.d.ts.map +1 -1
- package/dist/terragrunt/affected.js +290 -0
- package/dist/terragrunt/index.js +10 -0
- package/dist/terragrunt/mocks.js +270 -0
- package/dist/terragrunt/run.js +264 -0
- package/dist/terragrunt/units.js +195 -0
- package/dist/terragrunt/wave.js +125 -0
- package/dist/validate-cli.js +4 -0
- package/dist/validate.js +31 -0
- package/package.json +10 -10
- package/src/codegen/package.ts +2 -2
- package/src/components/capability-plugin.test.ts +97 -2
- package/src/components/capability-plugin.ts +117 -12
- package/src/components/pr-loop.tofu.test.ts +51 -11
- package/src/composites/terraform-adopt-op.test.ts +6 -0
- package/src/composites/terraform-adopt-op.ts +7 -2
- package/src/composites/terraform-apply-op.test.ts +6 -0
- package/src/composites/terraform-apply-op.ts +7 -2
- package/src/index.ts +1 -0
- package/src/pin/rollout.ts +2 -1
- package/src/terragrunt/affected.test.ts +11 -0
- package/src/terragrunt/affected.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-terraform",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.33",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src/",
|
|
@@ -11,38 +11,38 @@
|
|
|
11
11
|
".": {
|
|
12
12
|
"development": "./src/index.ts",
|
|
13
13
|
"types": "./dist/index.d.ts",
|
|
14
|
-
"default": "./
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
15
|
},
|
|
16
16
|
"./workspace-kinds": "./workspace-kinds.json",
|
|
17
17
|
"./*": {
|
|
18
18
|
"development": "./src/*.ts",
|
|
19
19
|
"types": "./dist/*.d.ts",
|
|
20
|
-
"default": "./
|
|
20
|
+
"default": "./dist/*.js"
|
|
21
21
|
},
|
|
22
22
|
"./op/activities": {
|
|
23
23
|
"development": "./src/op/activities/index.ts",
|
|
24
24
|
"types": "./dist/op/activities/index.d.ts",
|
|
25
|
-
"default": "./
|
|
25
|
+
"default": "./dist/op/activities/index.js"
|
|
26
26
|
},
|
|
27
27
|
"./op/activity-contracts": {
|
|
28
28
|
"development": "./src/op/activity-contracts.ts",
|
|
29
29
|
"types": "./dist/op/activity-contracts.d.ts",
|
|
30
|
-
"default": "./
|
|
30
|
+
"default": "./dist/op/activity-contracts.js"
|
|
31
31
|
},
|
|
32
32
|
"./pin": {
|
|
33
33
|
"development": "./src/pin/index.ts",
|
|
34
34
|
"types": "./dist/pin/index.d.ts",
|
|
35
|
-
"default": "./
|
|
35
|
+
"default": "./dist/pin/index.js"
|
|
36
36
|
},
|
|
37
37
|
"./terragrunt": {
|
|
38
38
|
"development": "./src/terragrunt/index.ts",
|
|
39
39
|
"types": "./dist/terragrunt/index.d.ts",
|
|
40
|
-
"default": "./
|
|
40
|
+
"default": "./dist/terragrunt/index.js"
|
|
41
41
|
},
|
|
42
42
|
"./lint/post-synth": {
|
|
43
43
|
"development": "./src/lint/post-synth/index.ts",
|
|
44
44
|
"types": "./dist/lint/post-synth/index.d.ts",
|
|
45
|
-
"default": "./
|
|
45
|
+
"default": "./dist/lint/post-synth/index.js"
|
|
46
46
|
},
|
|
47
47
|
"./manifest": "./dist/manifest.json",
|
|
48
48
|
"./meta": "./dist/meta.json",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"generate": "npx tsx src/codegen/generate-cli.ts",
|
|
53
53
|
"validate": "npx tsx src/validate-cli.ts",
|
|
54
54
|
"docs": "npx tsx src/codegen/docs-cli.ts",
|
|
55
|
-
"build": "../../scripts/tsc7.sh -p tsconfig.build.json && tsc-alias -p tsconfig.build.json
|
|
55
|
+
"build": "../../scripts/tsc7.sh -p tsconfig.build.json --sourceMap false && tsc-alias -p tsconfig.build.json",
|
|
56
56
|
"prepack": "npm run generate && npm run bundle && npm run validate && npm run build",
|
|
57
57
|
"bundle": "tsx src/package-cli.ts"
|
|
58
58
|
},
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"access": "public"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@intentius/chant": "^0.124.
|
|
70
|
+
"@intentius/chant": "^0.124.33",
|
|
71
71
|
"zod": "^4.3.6"
|
|
72
72
|
},
|
|
73
73
|
"description": "terraform lexicon for chant, existing Terraform root modules as declarable entities",
|
package/src/codegen/package.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
2
|
import { dirname, join } from "path";
|
|
3
|
-
import {
|
|
3
|
+
import { moduleSourceDir } from "@intentius/chant/module-source";
|
|
4
4
|
import { packagePipeline, collectSkills } from "@intentius/chant/codegen/package";
|
|
5
5
|
import { generate } from "./generate";
|
|
6
6
|
import { terraformPlugin } from "../plugin";
|
|
@@ -12,7 +12,7 @@ import { terraformPlugin } from "../plugin";
|
|
|
12
12
|
* `lint/rules` does not exist). `lexicons/aws/src/codegen/package.ts` resolves
|
|
13
13
|
* it the same way.
|
|
14
14
|
*/
|
|
15
|
-
const srcDir = dirname(
|
|
15
|
+
const srcDir = dirname(moduleSourceDir(import.meta.url));
|
|
16
16
|
|
|
17
17
|
/** The published version, so the manifest cannot drift from package.json. */
|
|
18
18
|
function packageVersion(): string {
|
|
@@ -8,6 +8,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
8
8
|
const calls: string[] = [];
|
|
9
9
|
let applyResult: Record<string, unknown> = { applied: true };
|
|
10
10
|
let changeSet: Record<string, unknown> | undefined;
|
|
11
|
+
let planJson: unknown;
|
|
11
12
|
// #3459: whether the root goes through choudoufu's set commands, and what they answer.
|
|
12
13
|
let waveRoot: { wave: true; root: string } | { wave: false; reason: string } = { wave: false, reason: "the root runs terraform" };
|
|
13
14
|
let waveApply: Record<string, unknown> = { exitCode: 0, landed: true, message: "landed" };
|
|
@@ -39,7 +40,7 @@ vi.mock("../op/activities/terraform", () => ({
|
|
|
39
40
|
}),
|
|
40
41
|
terraformPlan: vi.fn(async (args: { root: string; vars?: Record<string, unknown> }) => {
|
|
41
42
|
calls.push(`plan ${args.root} ${JSON.stringify(args.vars ?? {})}`);
|
|
42
|
-
return { planFile: "chant.tfplan", planDigest: `jcs1-sha256:${args.root}`, ...(changeSet ? { changeSet } : {}) };
|
|
43
|
+
return { planFile: "chant.tfplan", planDigest: `jcs1-sha256:${args.root}`, json: planJson, ...(changeSet ? { changeSet } : {}) };
|
|
43
44
|
}),
|
|
44
45
|
terraformApply: vi.fn(async (args: { root: string; planFile: string }) => {
|
|
45
46
|
calls.push(`apply ${args.root} ${args.planFile}`);
|
|
@@ -51,12 +52,14 @@ vi.mock("../op/activities/terraform", () => ({
|
|
|
51
52
|
}),
|
|
52
53
|
}));
|
|
53
54
|
|
|
54
|
-
const { terraformApplyCapability, terraformCapabilityPlugin } = await import("./capability-plugin");
|
|
55
|
+
const { terraformApplyCapability, terraformCapabilityPlugin, terraformPlannedOutputs, TerraformPlanMovedError } = await import("./capability-plugin");
|
|
56
|
+
const { isPlanMovedError } = await import("@intentius/chant/components/capability");
|
|
55
57
|
|
|
56
58
|
beforeEach(() => {
|
|
57
59
|
calls.length = 0;
|
|
58
60
|
applyResult = { applied: true };
|
|
59
61
|
changeSet = undefined;
|
|
62
|
+
planJson = undefined;
|
|
60
63
|
waveRoot = { wave: false, reason: "the root runs terraform" };
|
|
61
64
|
waveApply = { exitCode: 0, landed: true, message: "landed" };
|
|
62
65
|
waveApplyArgs.length = 0;
|
|
@@ -124,6 +127,24 @@ describe("terraform-apply on a choudoufu root in a gated wave (#3459)", () => {
|
|
|
124
127
|
expect(calls).toEqual(["wave-apply e01 sha256:set-e01"]);
|
|
125
128
|
expect(kept).toEqual([carry]);
|
|
126
129
|
});
|
|
130
|
+
|
|
131
|
+
test("a moved set throws core's plan-moved mark, so the command exits 3 (#3997)", async () => {
|
|
132
|
+
waveApply = { exitCode: 3, landed: false, message: "choudoufu refused the approved set" };
|
|
133
|
+
const err = await terraformApplyCapability
|
|
134
|
+
.run({ env: "test", component: "e01", plans: { e01: wavePlan } }, { root: "e01" })
|
|
135
|
+
.catch((e: unknown) => e);
|
|
136
|
+
expect(err).toBeInstanceOf(TerraformPlanMovedError);
|
|
137
|
+
expect(isPlanMovedError(err)).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a root that could not land is an ordinary failure, not a moved plan", async () => {
|
|
141
|
+
waveApply = { exitCode: 4, landed: false, message: "choudoufu could not land the root" };
|
|
142
|
+
const err = await terraformApplyCapability
|
|
143
|
+
.run({ env: "test", component: "e01", plans: { e01: wavePlan } }, { root: "e01" })
|
|
144
|
+
.catch((e: unknown) => e);
|
|
145
|
+
expect(err).toBeInstanceOf(Error);
|
|
146
|
+
expect(isPlanMovedError(err)).toBe(false);
|
|
147
|
+
});
|
|
127
148
|
});
|
|
128
149
|
|
|
129
150
|
describe("terraform-apply", () => {
|
|
@@ -137,10 +158,65 @@ describe("terraform-apply", () => {
|
|
|
137
158
|
member: "app",
|
|
138
159
|
planDigest: "jcs1-sha256:app",
|
|
139
160
|
artifact: { planFile: "chant.tfplan", planDigest: "jcs1-sha256:app" },
|
|
161
|
+
outputs: { known: {}, unknown: [] },
|
|
140
162
|
});
|
|
141
163
|
expect(calls).toEqual(["init app", 'plan app {"vpc":"v-1"}']);
|
|
142
164
|
});
|
|
143
165
|
|
|
166
|
+
test("plan answers the outputs the root will have, from the plan's planned values (#3999)", async () => {
|
|
167
|
+
planJson = {
|
|
168
|
+
planned_values: { outputs: { cidr: { sensitive: false, value: "10.1.0.0/16" }, id: { sensitive: false } } },
|
|
169
|
+
output_changes: {
|
|
170
|
+
cidr: { actions: ["update"], before: "10.0.0.0/16", after: "10.1.0.0/16", after_unknown: false },
|
|
171
|
+
id: { actions: ["create"], before: null, after_unknown: true },
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
const planned = await terraformApplyCapability.plan!({ env: "test", component: "net" }, { root: "net" });
|
|
175
|
+
expect(planned.outputs).toEqual({ known: { cidr: "10.1.0.0/16" }, unknown: ["id"] });
|
|
176
|
+
// The artifact `run` gets is still the plan file alone.
|
|
177
|
+
expect(planned.artifact).toEqual({ planFile: "chant.tfplan", planDigest: "jcs1-sha256:net" });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("a root whose vars read outputs not known yet is not planned: a hole names whose (#3999)", async () => {
|
|
181
|
+
const ctx = { env: "test", component: "app", unresolvedInputs: ["net"] };
|
|
182
|
+
const planned = await terraformApplyCapability.plan!(ctx, { root: "app", vars: { vpc: undefined, name: "app" } });
|
|
183
|
+
expect(calls).toEqual([]);
|
|
184
|
+
expect(planned.artifact).toBeUndefined();
|
|
185
|
+
expect(planned.outputs).toBeUndefined();
|
|
186
|
+
expect(planned.changeSet?.entries).toEqual([]);
|
|
187
|
+
expect(planned.changeSet?.member.holes).toEqual([{ address: "app", type: "terraform_root", reason: "inputs from net; planned again once they apply" }]);
|
|
188
|
+
expect(planned.planDigest).toBe(planned.changeSet?.member.planDigest);
|
|
189
|
+
const again = await terraformApplyCapability.plan!(ctx, { root: "app", vars: { vpc: undefined, name: "app" } });
|
|
190
|
+
expect(again.planDigest).toBe(planned.planDigest);
|
|
191
|
+
const other = await terraformApplyCapability.plan!(ctx, { root: "app", vars: { vpc: undefined, name: "web" } });
|
|
192
|
+
expect(other.planDigest).not.toBe(planned.planDigest);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("terraformPlannedOutputs (#3999)", () => {
|
|
197
|
+
test("an output is known when its planned value is there and nothing in it is unknown after apply", () => {
|
|
198
|
+
expect(
|
|
199
|
+
terraformPlannedOutputs({
|
|
200
|
+
planned_values: { outputs: { a: { value: "x" }, b: { value: { k: 1 } }, c: {}, d: { value: null } } },
|
|
201
|
+
output_changes: {
|
|
202
|
+
a: { after: "x", after_unknown: false },
|
|
203
|
+
b: { after: { k: 1 }, after_unknown: { k: false, j: true } },
|
|
204
|
+
c: { after_unknown: true },
|
|
205
|
+
d: { after: null, after_unknown: false },
|
|
206
|
+
},
|
|
207
|
+
}),
|
|
208
|
+
).toEqual({ known: { a: "x", d: null }, unknown: ["b", "c"] });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("an output with no planned value and no change is unknown; a plan with no outputs has none", () => {
|
|
212
|
+
expect(terraformPlannedOutputs({ planned_values: { outputs: { a: { sensitive: true } } } })).toEqual({ known: {}, unknown: ["a"] });
|
|
213
|
+
expect(terraformPlannedOutputs({})).toEqual({ known: {}, unknown: [] });
|
|
214
|
+
expect(terraformPlannedOutputs(undefined)).toEqual({ known: {}, unknown: [] });
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("terraform-apply, continued", () => {
|
|
219
|
+
|
|
144
220
|
test("plan hands on the root's change-set part, outside the artifact run receives (#3183)", async () => {
|
|
145
221
|
changeSet = { member: { member: "app" }, entries: [] };
|
|
146
222
|
const planned = await terraformApplyCapability.plan!({ env: "test", component: "app" }, { root: "app" });
|
|
@@ -178,4 +254,23 @@ describe("terraform-apply", () => {
|
|
|
178
254
|
).rejects.toThrow(/refused the plan it was given \(approval-mismatch\)/);
|
|
179
255
|
expect(calls).not.toContain("output app");
|
|
180
256
|
});
|
|
257
|
+
|
|
258
|
+
test.each(["approval-mismatch", "wrong-estate"])(
|
|
259
|
+
"choudoufu's %s refusal of the wave's plan throws core's plan-moved mark, so the command exits 3 (#3997)",
|
|
260
|
+
async (refused) => {
|
|
261
|
+
applyResult = { applied: false, refused, refusal: "refused" };
|
|
262
|
+
const err = await terraformApplyCapability
|
|
263
|
+
.run({ env: "test", component: "app", plans: { app: { planFile: "chant.tfplan", planDigest: "d" } } }, { root: "app" })
|
|
264
|
+
.catch((e: unknown) => e);
|
|
265
|
+
expect(err).toBeInstanceOf(TerraformPlanMovedError);
|
|
266
|
+
expect(isPlanMovedError(err)).toBe(true);
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
test("a refusal of a plan this run made itself is an ordinary failure: the next run plans afresh", async () => {
|
|
271
|
+
applyResult = { applied: false, refused: "approval-mismatch", refusal: "refused" };
|
|
272
|
+
const err = await terraformApplyCapability.run({ env: "test", component: "app" }, { root: "app" }).catch((e: unknown) => e);
|
|
273
|
+
expect(err).toBeInstanceOf(Error);
|
|
274
|
+
expect(isPlanMovedError(err)).toBe(false);
|
|
275
|
+
});
|
|
181
276
|
});
|
|
@@ -20,12 +20,28 @@
|
|
|
20
20
|
* that file and nothing else. terraform and tofu refuse a saved plan whose
|
|
21
21
|
* state moved since it was written; choudoufu re-plans against the live
|
|
22
22
|
* system and refuses with exit 3 when its fresh plan differs (choudoufu
|
|
23
|
-
* #878). Either refusal fails the step.
|
|
23
|
+
* #878). Either refusal fails the step. choudoufu's refusal of a plan the
|
|
24
|
+
* wave handed `run` throws {@link TerraformPlanMovedError}, so the command
|
|
25
|
+
* that applied it exits 3 (#3997). terraform's and tofu's stale-plan error
|
|
26
|
+
* is not classified and exits 1.
|
|
24
27
|
*
|
|
25
28
|
* Run without a wave plan (`chant run --components`, or a fan-out with no
|
|
26
29
|
* wave gate), `run` plans and applies in one go, which is what `terraform
|
|
27
30
|
* apply` with no plan file does too.
|
|
28
31
|
*
|
|
32
|
+
* ## What the plan says about the root's outputs (#3999)
|
|
33
|
+
*
|
|
34
|
+
* `plan` answers the outputs the root will have once that plan applies
|
|
35
|
+
* (`terraformPlannedOutputs`): `show -json`'s `planned_values.outputs`, less
|
|
36
|
+
* each output whose `output_changes` entry is unknown after apply. A pull
|
|
37
|
+
* request's plan reads a dependent's `stackOutput()` references from there
|
|
38
|
+
* when both roots are in the change.
|
|
39
|
+
*
|
|
40
|
+
* A root whose `vars` read an output not known yet (`DeployContext.unresolvedInputs`)
|
|
41
|
+
* is not planned: terraform takes no unknown variable. Its member gets a hole
|
|
42
|
+
* naming the roots it waits on and no artifact, and the run that applies it
|
|
43
|
+
* plans it once their outputs are known.
|
|
44
|
+
*
|
|
29
45
|
* ## choudoufu roots in a gated wave (#3459)
|
|
30
46
|
*
|
|
31
47
|
* A live choudoufu root that `live-plan-set` can plan as chant would (no
|
|
@@ -49,8 +65,9 @@
|
|
|
49
65
|
* choudoufu's wave over it is always wave 1.
|
|
50
66
|
*/
|
|
51
67
|
|
|
52
|
-
import type { Capability, DeployContext, CapabilityPlan } from "@intentius/chant/components/capability";
|
|
68
|
+
import type { Capability, DeployContext, CapabilityPlan, PlannedOutputs } from "@intentius/chant/components/capability";
|
|
53
69
|
import type { ChangeSetPart } from "@intentius/chant/change-set";
|
|
70
|
+
import { computePlanDigest } from "@intentius/chant/lifecycle/plan-digest";
|
|
54
71
|
import { ownPackageVersion, type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
|
|
55
72
|
import {
|
|
56
73
|
choudoufuPlanSet,
|
|
@@ -96,7 +113,7 @@ function requireRoot(input: TerraformApplyStepInput): void {
|
|
|
96
113
|
}
|
|
97
114
|
}
|
|
98
115
|
|
|
99
|
-
async function planRoot(input: TerraformApplyStepInput): Promise<PlannedRoot & { changeSet?: ChangeSetPart }> {
|
|
116
|
+
async function planRoot(input: TerraformApplyStepInput): Promise<PlannedRoot & { changeSet?: ChangeSetPart; json?: unknown }> {
|
|
100
117
|
requireRoot(input);
|
|
101
118
|
await terraformInit(rootArgs(input));
|
|
102
119
|
const planned = await terraformPlan({ ...rootArgs(input), ...(input.vars ? { vars: input.vars } : {}) });
|
|
@@ -104,9 +121,84 @@ async function planRoot(input: TerraformApplyStepInput): Promise<PlannedRoot & {
|
|
|
104
121
|
planFile: planned.planFile,
|
|
105
122
|
planDigest: planned.planDigest,
|
|
106
123
|
...(planned.changeSet ? { changeSet: planned.changeSet } : {}),
|
|
124
|
+
json: planned.json,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
129
|
+
|
|
130
|
+
/** Whether an `after_unknown` value marks anything unknown: `true`, or `true` anywhere inside. */
|
|
131
|
+
function anyUnknown(value: unknown): boolean {
|
|
132
|
+
if (value === true) return true;
|
|
133
|
+
if (Array.isArray(value)) return value.some(anyUnknown);
|
|
134
|
+
if (isRecord(value)) return Object.values(value).some(anyUnknown);
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The outputs a root will have once the plan `json` (`show -json`) applies
|
|
140
|
+
* (#3999). An output is known when `planned_values.outputs` gives its value
|
|
141
|
+
* and its `output_changes` entry marks no part of it unknown after apply.
|
|
142
|
+
* Every other output the plan names is unknown, the whole output even when
|
|
143
|
+
* only a part of it is.
|
|
144
|
+
*/
|
|
145
|
+
export function terraformPlannedOutputs(json: unknown): PlannedOutputs {
|
|
146
|
+
const doc = isRecord(json) ? json : {};
|
|
147
|
+
const values = isRecord(doc.planned_values) && isRecord(doc.planned_values.outputs) ? doc.planned_values.outputs : {};
|
|
148
|
+
const changes = isRecord(doc.output_changes) ? doc.output_changes : {};
|
|
149
|
+
const known: Record<string, unknown> = {};
|
|
150
|
+
const unknown: string[] = [];
|
|
151
|
+
for (const name of [...new Set([...Object.keys(values), ...Object.keys(changes)])].sort()) {
|
|
152
|
+
const change = changes[name];
|
|
153
|
+
const planned = values[name];
|
|
154
|
+
if (isRecord(change) && anyUnknown(change.after_unknown)) unknown.push(name);
|
|
155
|
+
else if (isRecord(planned) && "value" in planned) known[name] = planned.value;
|
|
156
|
+
else if (isRecord(change) && "after" in change) known[name] = change.after;
|
|
157
|
+
else unknown.push(name);
|
|
158
|
+
}
|
|
159
|
+
return { known, unknown };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The member of a root whose `vars` read outputs not known yet (#3999): no
|
|
164
|
+
* plan, a hole naming whose outputs it waits on, and a digest over the root,
|
|
165
|
+
* the vars as resolved and those names.
|
|
166
|
+
*/
|
|
167
|
+
function waitingRoot(ctx: DeployContext, input: TerraformApplyStepInput, unresolved: string[]): CapabilityPlan {
|
|
168
|
+
const vars = Object.fromEntries(Object.entries(input.vars ?? {}).map(([k, v]) => [k, v === undefined ? null : v]));
|
|
169
|
+
const planDigest = computePlanDigest("terraform-apply-unresolved", { env: ctx.env, root: input.root, vars, unresolved });
|
|
170
|
+
const changeSet: ChangeSetPart = {
|
|
171
|
+
member: {
|
|
172
|
+
member: input.root,
|
|
173
|
+
lexicon: "terraform",
|
|
174
|
+
planner: "chant",
|
|
175
|
+
status: "planned",
|
|
176
|
+
planDigest,
|
|
177
|
+
holes: [{ address: input.root, type: "terraform_root", reason: `inputs from ${unresolved.join(", ")}; planned again once they apply` }],
|
|
178
|
+
},
|
|
179
|
+
entries: [],
|
|
107
180
|
};
|
|
181
|
+
return { member: input.root, planDigest, changeSet };
|
|
108
182
|
}
|
|
109
183
|
|
|
184
|
+
/**
|
|
185
|
+
* choudoufu refused the plan `run` was handed by its wave (its exit 3):
|
|
186
|
+
* the live system moved since the plan, or the plan names another estate.
|
|
187
|
+
* Nothing was applied. `planMoved` is core's mark for a moved plan
|
|
188
|
+
* (`isPlanMovedError`), so the command that applied it exits 3, as for the
|
|
189
|
+
* other capabilities that refuse a moved plan (#3997).
|
|
190
|
+
*/
|
|
191
|
+
export class TerraformPlanMovedError extends Error {
|
|
192
|
+
readonly planMoved = true;
|
|
193
|
+
constructor(public readonly root: string, message: string) {
|
|
194
|
+
super(message);
|
|
195
|
+
this.name = "TerraformPlanMovedError";
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `live-wave-apply`'s exit status for a set whose plan moved (`CHOUDOUFU_WAVE_EXIT.setMoved`). */
|
|
200
|
+
const CHOUDOUFU_SET_MOVED_EXIT = 3;
|
|
201
|
+
|
|
110
202
|
function isPlannedRoot(value: unknown): value is PlannedRoot {
|
|
111
203
|
const v = value as Partial<PlannedRoot> | undefined;
|
|
112
204
|
return typeof v?.planFile === "string" && typeof v.planDigest === "string";
|
|
@@ -141,7 +233,9 @@ async function runChoudoufuWave(
|
|
|
141
233
|
});
|
|
142
234
|
if (applied.carry) ctx.carry?.(input.root, applied.carry);
|
|
143
235
|
if (!applied.landed) {
|
|
144
|
-
|
|
236
|
+
const message = `terraform-apply: root "${input.root}": ${applied.message}`;
|
|
237
|
+
if (applied.exitCode === CHOUDOUFU_SET_MOVED_EXIT) throw new TerraformPlanMovedError(input.root, message);
|
|
238
|
+
throw new Error(message);
|
|
145
239
|
}
|
|
146
240
|
const outputs = await terraformOutputs(rootArgs(input));
|
|
147
241
|
return { root: input.root, planDigest: planned.planDigest, fromWavePlan: true, outputs };
|
|
@@ -152,8 +246,10 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
|
|
|
152
246
|
// A root's apply has no undo short of applying the previous configuration,
|
|
153
247
|
// which is a change of its own. COMP003 asks the component to say so.
|
|
154
248
|
rollbackPolicy: "needs-opt-out",
|
|
155
|
-
async plan(
|
|
249
|
+
async plan(ctx: DeployContext, input: TerraformApplyStepInput): Promise<CapabilityPlan> {
|
|
156
250
|
requireRoot(input);
|
|
251
|
+
const unresolved = [...(ctx.unresolvedInputs ?? [])].sort();
|
|
252
|
+
if (unresolved.length > 0) return waitingRoot(ctx, input, unresolved);
|
|
157
253
|
const wave = await choudoufuWaveRoot({ ...rootArgs(input), ...(input.vars ? { vars: input.vars } : {}) });
|
|
158
254
|
if (wave.wave) {
|
|
159
255
|
const set = await choudoufuPlanSet(rootArgs(input));
|
|
@@ -161,10 +257,16 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
|
|
|
161
257
|
planDigest: set.planDigest,
|
|
162
258
|
choudoufu: { planSetFile: set.planSetFile, setDigest: set.setDigest },
|
|
163
259
|
};
|
|
164
|
-
return { member: input.root, planDigest: set.planDigest, artifact, changeSet: set.changeSet };
|
|
260
|
+
return { member: input.root, planDigest: set.planDigest, artifact, changeSet: set.changeSet, outputs: terraformPlannedOutputs(set.json) };
|
|
165
261
|
}
|
|
166
|
-
const { changeSet, ...planned } = await planRoot(input);
|
|
167
|
-
return {
|
|
262
|
+
const { changeSet, json, ...planned } = await planRoot(input);
|
|
263
|
+
return {
|
|
264
|
+
member: input.root,
|
|
265
|
+
planDigest: planned.planDigest,
|
|
266
|
+
artifact: planned,
|
|
267
|
+
...(changeSet ? { changeSet } : {}),
|
|
268
|
+
outputs: terraformPlannedOutputs(json),
|
|
269
|
+
};
|
|
168
270
|
},
|
|
169
271
|
// The root's outputs as its state holds them now (#3183): what a dependent
|
|
170
272
|
// planned before this root applies reads through `stackOutput()`.
|
|
@@ -176,13 +278,16 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
|
|
|
176
278
|
async run(ctx: DeployContext, input: TerraformApplyStepInput): Promise<TerraformApplyStepOutput> {
|
|
177
279
|
const fromWave = ctx.plans?.[input.root];
|
|
178
280
|
if (isChoudoufuWavePlan(fromWave)) return runChoudoufuWave(ctx, input, fromWave);
|
|
179
|
-
const planned: PlannedRoot = isPlannedRoot(fromWave) ? fromWave : await planRoot(input);
|
|
281
|
+
const planned: PlannedRoot = isPlannedRoot(fromWave) ? fromWave : await planRoot(input).then(({ planFile, planDigest }) => ({ planFile, planDigest }));
|
|
180
282
|
const applied = await terraformApply({ ...rootArgs(input), planFile: planned.planFile });
|
|
181
283
|
if (!applied.applied) {
|
|
182
|
-
|
|
284
|
+
const message =
|
|
183
285
|
`terraform-apply: root "${input.root}" refused the plan it was given (${applied.refused ?? "refused"}). ` +
|
|
184
|
-
|
|
185
|
-
|
|
286
|
+
(applied.refusal ?? "");
|
|
287
|
+
// A plan the wave approved that choudoufu refuses moved since the
|
|
288
|
+
// approval. One this run made itself is planned afresh by the next run.
|
|
289
|
+
if (applied.refused !== undefined && isPlannedRoot(fromWave)) throw new TerraformPlanMovedError(input.root, message);
|
|
290
|
+
throw new Error(message);
|
|
186
291
|
}
|
|
187
292
|
const outputs = await terraformOutputs(rootArgs(input));
|
|
188
293
|
return { root: input.root, planDigest: planned.planDigest, fromWavePlan: isPlannedRoot(fromWave), outputs };
|
|
@@ -103,6 +103,9 @@ class FakeGithub {
|
|
|
103
103
|
return ok({});
|
|
104
104
|
}
|
|
105
105
|
if ((m = path.match(/^\/pulls\/(\d+)\/reviews$/))) return ok(this.reviews[Number(m[1])] ?? []);
|
|
106
|
+
// #4000: the author and committers, whose own approval never counts.
|
|
107
|
+
if ((m = path.match(/^\/pulls\/(\d+)$/))) return ok({ number: Number(m[1]), user: { login: "ci" } });
|
|
108
|
+
if ((m = path.match(/^\/pulls\/(\d+)\/commits$/))) return ok([{ author: { login: "ci" }, committer: { login: "ci" } }]);
|
|
106
109
|
if ((m = path.match(/^\/commits\/([0-9a-f]+)\/pulls$/))) {
|
|
107
110
|
const pr = this.mergedBy[m[1]];
|
|
108
111
|
return ok(pr ? [{ number: pr, merged_at: "2026-10-03T00:00:00Z", merge_commit_sha: m[1] }] : []);
|
|
@@ -194,12 +197,16 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
|
|
|
194
197
|
return report("plan");
|
|
195
198
|
}
|
|
196
199
|
|
|
200
|
+
/** The commit before the last merge, which a re-run of its apply measures from. */
|
|
201
|
+
let mergedFrom = "";
|
|
202
|
+
const RESUME = ".chant/pr-resume/pr-apply.json";
|
|
203
|
+
|
|
197
204
|
async function merge(pr: number): Promise<number> {
|
|
198
205
|
git("checkout", "-q", "main");
|
|
199
|
-
|
|
206
|
+
mergedFrom = git("rev-parse", "HEAD");
|
|
200
207
|
git("merge", "-q", "--no-ff", `pr-${pr}`, "-m", `Merge pull request #${pr}`);
|
|
201
208
|
github.mergedBy[git("rev-parse", "HEAD")] = pr;
|
|
202
|
-
return handlers.runComponentsPrApply(ctx({ base:
|
|
209
|
+
return handlers.runComponentsPrApply(ctx({ base: mergedFrom, forge: "github", requireReview: true, resume: RESUME }) as never);
|
|
203
210
|
}
|
|
204
211
|
|
|
205
212
|
test("a change to one root plans that root and its dependent, and nothing else", async () => {
|
|
@@ -207,34 +214,67 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
|
|
|
207
214
|
|
|
208
215
|
expect(plan.status).toBe("planned");
|
|
209
216
|
expect(plan.selection).toMatchObject({ changed: ["a"], dependents: ["app"], waves: [["a"], ["app"]] });
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
217
|
+
// a's id is terraform_data's output, which tofu knows only after apply,
|
|
218
|
+
// so app is not planned against the id a has now (#3999): the note says
|
|
219
|
+
// the approval does not cover what app does with it.
|
|
220
|
+
expect(plan.members.map((m) => [m.member, m.counts.update, m.unknownInputs])).toEqual([
|
|
221
|
+
["a", 1, undefined],
|
|
222
|
+
["app", 0, ["a.id"]],
|
|
223
|
+
]);
|
|
224
|
+
expect(plan.changeSet.members.find((m) => m.member === "app")?.holes).toEqual([
|
|
225
|
+
{ address: "app", type: "terraform_root", reason: "inputs from a; planned again once they apply" },
|
|
213
226
|
]);
|
|
227
|
+
expect(plan.plannedOutputs).toEqual({ a: [] });
|
|
228
|
+
expect(plan.deployedOutputs).toEqual({ net: ["cidr"] });
|
|
214
229
|
expect(plan.changeSet.digest).toBe(plan.digest);
|
|
215
230
|
expect(plan.approval).toEqual({ status: "pending" });
|
|
216
231
|
|
|
217
232
|
const note = github.comments.find((c) => c.pr === 1)!;
|
|
218
233
|
expect(note.body.startsWith("<!-- chant-pr:local -->")).toBe(true);
|
|
219
234
|
expect(note.body).toContain(`chant approve pr-1 pr-apply --plan ${plan.digest} --approver github:<your-login> --sign`);
|
|
235
|
+
expect(note.body).toContain("Known only after apply: `app` reads `a.id`.");
|
|
220
236
|
expect(github.statuses.at(-1)).toMatchObject({ context: "chant/plan", state: "success", sha: plan.head });
|
|
221
237
|
}, 240_000);
|
|
222
238
|
|
|
223
|
-
test("on merge it applies the plan the reviewer approved", async () => {
|
|
239
|
+
test("on merge it applies the plan the reviewer approved, and plans the dependent again once its upstream applied (#3999)", async () => {
|
|
224
240
|
const plan = report("plan");
|
|
225
241
|
github.reviews[1] = [{ user: { login: "alice" }, state: "APPROVED" }];
|
|
226
242
|
expect((await recordGateApproval("pr-1", "pr-apply", { actor: "github:alice", plan: plan.digest })).ok).toBe(true);
|
|
227
243
|
|
|
228
|
-
|
|
244
|
+
// a applies; app, planned now against the id a applied as, waits on its own gate.
|
|
245
|
+
expect(await merge(1)).toBe(3);
|
|
246
|
+
const waiting = report("apply");
|
|
247
|
+
expect(waiting.digest).toBe(plan.digest);
|
|
248
|
+
expect(waiting.status).toBe("waiting");
|
|
249
|
+
expect(waiting.members.map((m) => [m.member, m.status, m.round])).toEqual([
|
|
250
|
+
["a", "applied", undefined],
|
|
251
|
+
["app", "waiting", 2],
|
|
252
|
+
]);
|
|
253
|
+
const round = waiting.rounds![0]!;
|
|
254
|
+
expect(round).toMatchObject({ round: 2, gate: "pr-apply-round-2", components: ["app"], status: "waiting", approval: { status: "pending" } });
|
|
255
|
+
expect(round.changeSet.entries.map((e) => [e.member, e.address, e.action])).toEqual([["app", "terraform_data.app", "update"]]);
|
|
256
|
+
expect(state("a")).toContain("10.0.0.0/16/a-v2");
|
|
257
|
+
expect(state("app")).not.toContain("a-v2");
|
|
258
|
+
expect(github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "pending" });
|
|
259
|
+
expect(github.comments.find((c) => c.pr === 1)!.body).toContain(`chant approve pr-1 pr-apply-round-2 --plan ${round.digest}`);
|
|
260
|
+
}, 240_000);
|
|
261
|
+
|
|
262
|
+
test("once the reviewer approves the round's plan, the same apply carries on from its record and applies the dependent (#3999)", async () => {
|
|
263
|
+
const plan = report("plan");
|
|
264
|
+
const round = report("apply").rounds![0]!;
|
|
265
|
+
expect((await recordGateApproval("pr-1", "pr-apply-round-2", { actor: "github:alice", plan: round.digest })).ok).toBe(true);
|
|
266
|
+
expect(await handlers.runComponentsPrApply(ctx({ base: mergedFrom, forge: "github", requireReview: true, resume: RESUME }) as never)).toBe(0);
|
|
229
267
|
const applied = report("apply");
|
|
230
268
|
expect(applied.digest).toBe(plan.digest);
|
|
231
269
|
expect(applied.status).toBe("applied");
|
|
232
270
|
expect(applied.approval).toEqual({ status: "approved", approvedBy: ["github:alice"] });
|
|
233
|
-
expect(applied.
|
|
271
|
+
expect(applied.resumed).toEqual({ applied: ["a"] });
|
|
272
|
+
expect(applied.members.map((m) => [m.member, m.status, m.round])).toEqual([
|
|
234
273
|
["a", "applied", undefined],
|
|
235
|
-
["app", "applied",
|
|
274
|
+
["app", "applied", 2],
|
|
236
275
|
]);
|
|
237
|
-
expect(
|
|
276
|
+
expect(applied.rounds?.map((r) => [r.digest, r.status, r.approval.approvedBy])).toEqual([[round.digest, "applied", ["github:alice"]]]);
|
|
277
|
+
expect(state("app")).toContain("app on 10.0.0.0/16/a-v2");
|
|
238
278
|
// The same note, now with the apply's outcome.
|
|
239
279
|
expect(github.comments.filter((c) => c.pr === 1)).toHaveLength(1);
|
|
240
280
|
expect(github.comments.find((c) => c.pr === 1)!.body).toContain("### chant apply for `local`");
|
|
@@ -251,9 +291,9 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
|
|
|
251
291
|
expect(stored.digest).toBe(applied.digest);
|
|
252
292
|
expect(stored.graph).toEqual({ member: null, at: applied.head });
|
|
253
293
|
// The plan's addresses, each on the block the terraform lexicon's graph names `<root>/<address>`.
|
|
294
|
+
// app's plan had a hole for a's id, and no entries.
|
|
254
295
|
expect(stored.entries.map((e) => [e.member, e.address, e.action, e.node])).toEqual([
|
|
255
296
|
["a", "terraform_data.subnet", "update", "a/terraform_data.subnet"],
|
|
256
|
-
["app", "terraform_data.app", "no-op", "app/terraform_data.app"],
|
|
257
297
|
]);
|
|
258
298
|
// And those are ids the estate's graph has.
|
|
259
299
|
const graph = await estateGraphIds(dir);
|
|
@@ -176,6 +176,12 @@ describe("TerraformAdoptOp compensation (#2105)", () => {
|
|
|
176
176
|
expect((op.onFailure![0].steps[0] as ActivityStep).args?.cmd).toBe("./untag.sh");
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
test("a supplied image runs the rollback in that container (#4015)", () => {
|
|
180
|
+
const image = `alpine:3.20@sha256:${"b".repeat(64)}`;
|
|
181
|
+
const op = props({ ...base, compensate: { command: "./untag.sh", image } });
|
|
182
|
+
expect((op.onFailure![0].steps[0] as ActivityStep).args).toEqual({ cmd: "./untag.sh", image });
|
|
183
|
+
});
|
|
184
|
+
|
|
179
185
|
test("compensate: false and omitting it both leave no onFailure", () => {
|
|
180
186
|
expect(props(base).onFailure).toBeUndefined();
|
|
181
187
|
expect(props({ ...base, compensate: false }).onFailure).toBeUndefined();
|
|
@@ -143,7 +143,11 @@ export interface TerraformAdoptOpConfig {
|
|
|
143
143
|
* object with no `command`, throws when the Op is built, naming the Op,
|
|
144
144
|
* rather than warning at the moment a rollback is already wanted.
|
|
145
145
|
*/
|
|
146
|
-
compensate?: boolean | {
|
|
146
|
+
compensate?: boolean | {
|
|
147
|
+
command?: string;
|
|
148
|
+
/** Run the rollback command in a container pinned by digest (#4015), as `shell()`'s `image` does. */
|
|
149
|
+
image?: string;
|
|
150
|
+
};
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
export interface TerraformAdoptOpResources {
|
|
@@ -153,6 +157,7 @@ export interface TerraformAdoptOpResources {
|
|
|
153
157
|
|
|
154
158
|
export function TerraformAdoptOp(config: TerraformAdoptOpConfig): TerraformAdoptOpResources {
|
|
155
159
|
const compensateCommand = typeof config.compensate === "object" ? config.compensate.command : undefined;
|
|
160
|
+
const compensateImage = typeof config.compensate === "object" ? config.compensate.image : undefined;
|
|
156
161
|
if (config.compensate !== undefined && config.compensate !== false && compensateCommand === undefined) {
|
|
157
162
|
throw new Error(
|
|
158
163
|
`TerraformAdoptOp "${config.name}": compensate is enabled, but there is no automatic undo for an ` +
|
|
@@ -212,7 +217,7 @@ export function TerraformAdoptOp(config: TerraformAdoptOpConfig): TerraformAdopt
|
|
|
212
217
|
phase("Adopt", [adopt]),
|
|
213
218
|
],
|
|
214
219
|
...(compensateCommand
|
|
215
|
-
? { onFailure: [phase("Rollback", [activity("shellCmd", { cmd: compensateCommand })])] }
|
|
220
|
+
? { onFailure: [phase("Rollback", [activity("shellCmd", { cmd: compensateCommand, ...(compensateImage ? { image: compensateImage } : {}) })])] }
|
|
216
221
|
: {}),
|
|
217
222
|
});
|
|
218
223
|
|
|
@@ -179,6 +179,12 @@ describe("TerraformApplyOp compensation — total or refused (#2086)", () => {
|
|
|
179
179
|
args: { cmd: "terraform apply -input=false rollback.tfplan" },
|
|
180
180
|
});
|
|
181
181
|
});
|
|
182
|
+
|
|
183
|
+
test("compensate: { command, image } runs the rollback in that image (#4015)", () => {
|
|
184
|
+
const image = `hashicorp/terraform:1.9@sha256:${"a".repeat(64)}`;
|
|
185
|
+
const op = props({ name: "a", root: "app", compensate: { command: "terraform apply rollback.tfplan", image } });
|
|
186
|
+
expect(op.onFailure![0].steps[0]).toMatchObject({ fn: "shellCmd", args: { cmd: "terraform apply rollback.tfplan", image } });
|
|
187
|
+
});
|
|
182
188
|
});
|
|
183
189
|
|
|
184
190
|
describe("TerraformApplyOp on the local executor (#2086, gate-as-fact #2119)", () => {
|
|
@@ -158,7 +158,11 @@ export interface TerraformApplyOpConfig {
|
|
|
158
158
|
* rollback is already needed. Supply `compensate: { command: "..." }` with a
|
|
159
159
|
* rollback of your own, or leave it unset.
|
|
160
160
|
*/
|
|
161
|
-
compensate?: boolean | {
|
|
161
|
+
compensate?: boolean | {
|
|
162
|
+
command?: string;
|
|
163
|
+
/** Run the rollback command in a container pinned by digest (#4015), as `shell()`'s `image` does. */
|
|
164
|
+
image?: string;
|
|
165
|
+
};
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
export interface TerraformApplyOpResources {
|
|
@@ -174,6 +178,7 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply
|
|
|
174
178
|
// fails here, at build time, with the Op named — not as a warning when the
|
|
175
179
|
// apply has already half-run. Same shape as ApplyOp's refusal (#1449).
|
|
176
180
|
const compensateCommand = typeof config.compensate === "object" ? config.compensate.command : undefined;
|
|
181
|
+
const compensateImage = typeof config.compensate === "object" ? config.compensate.image : undefined;
|
|
177
182
|
if (config.compensate !== undefined && config.compensate !== false && compensateCommand === undefined) {
|
|
178
183
|
throw new Error(
|
|
179
184
|
`TerraformApplyOp "${config.name}": compensate is enabled, but terraform has no automatic ` +
|
|
@@ -274,7 +279,7 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply
|
|
|
274
279
|
},
|
|
275
280
|
phases,
|
|
276
281
|
...(compensateCommand
|
|
277
|
-
? { onFailure: [phase("Rollback", [activity("shellCmd", { cmd: compensateCommand })])] }
|
|
282
|
+
? { onFailure: [phase("Rollback", [activity("shellCmd", { cmd: compensateCommand, ...(compensateImage ? { image: compensateImage } : {}) })])] }
|
|
278
283
|
: {}),
|
|
279
284
|
});
|
|
280
285
|
|