@intentius/chant-lexicon-terraform 0.57.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/README.md +53 -0
- package/dist/codegen/docs-cli.d.ts +3 -0
- package/dist/codegen/docs-cli.d.ts.map +1 -0
- package/dist/codegen/docs.d.ts +11 -0
- package/dist/codegen/docs.d.ts.map +1 -0
- package/dist/codegen/generate-cli.d.ts +3 -0
- package/dist/codegen/generate-cli.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts +28 -0
- package/dist/codegen/generate.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +17 -0
- package/dist/codegen/package.d.ts.map +1 -0
- package/dist/composites/terraform-apply-op.d.ts +88 -0
- package/dist/composites/terraform-apply-op.d.ts.map +1 -0
- package/dist/composites/terraform-watch-op.d.ts +124 -0
- package/dist/composites/terraform-watch-op.d.ts.map +1 -0
- package/dist/config.d.ts +83 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +127 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/generated/index.d.ts +2 -0
- package/dist/generated/index.d.ts.map +1 -0
- package/dist/hcl/parse.d.ts +87 -0
- package/dist/hcl/parse.d.ts.map +1 -0
- package/dist/hcl/roots.d.ts +37 -0
- package/dist/hcl/roots.d.ts.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/integrity.json +12 -0
- package/dist/lint/audit-catalog.d.ts +12 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/lint/audit-lineage.d.ts +21 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts +3 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -0
- package/dist/lint/post-synth/tf001.d.ts +17 -0
- package/dist/lint/post-synth/tf001.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +5 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/plan-before-apply.d.ts +30 -0
- package/dist/lint/rules/plan-before-apply.d.ts.map +1 -0
- package/dist/lsp/completions.d.ts +26 -0
- package/dist/lsp/completions.d.ts.map +1 -0
- package/dist/lsp/context.d.ts +70 -0
- package/dist/lsp/context.d.ts.map +1 -0
- package/dist/lsp/hover.d.ts +12 -0
- package/dist/lsp/hover.d.ts.map +1 -0
- package/dist/lsp/option-keys.d.ts +34 -0
- package/dist/lsp/option-keys.d.ts.map +1 -0
- package/dist/manifest.json +6 -0
- package/dist/meta.json +1 -0
- package/dist/okf/index.md +8 -0
- package/dist/okf/rules/TF001.md +11 -0
- package/dist/okf/rules/TF101.md +11 -0
- package/dist/op/activities/index.d.ts +22 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/op/activities/terraform.d.ts +213 -0
- package/dist/op/activities/terraform.d.ts.map +1 -0
- package/dist/op/builders.d.ts +56 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/package-cli.d.ts +3 -0
- package/dist/package-cli.d.ts.map +1 -0
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/rules/plan-before-apply.ts +143 -0
- package/dist/rules/tf001.ts +66 -0
- package/dist/serializer.d.ts +19 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/skills/chant-terraform.md +92 -0
- package/dist/state-ownership.d.ts +27 -0
- package/dist/state-ownership.d.ts.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/validate-cli.d.ts +3 -0
- package/dist/validate-cli.d.ts.map +1 -0
- package/dist/validate.d.ts +15 -0
- package/dist/validate.d.ts.map +1 -0
- package/package.json +75 -0
- package/src/__fixtures__/no-backend/main.tf +24 -0
- package/src/__fixtures__/show-state.json +68 -0
- package/src/__fixtures__/with-backend/main.tf +28 -0
- package/src/__fixtures__/with-module/main.tf +49 -0
- package/src/__fixtures__/with-module/modules/inner/main.tf +5 -0
- package/src/codegen/docs-cli.ts +4 -0
- package/src/codegen/docs.ts +50 -0
- package/src/codegen/generate-cli.ts +10 -0
- package/src/codegen/generate.ts +68 -0
- package/src/codegen/package.ts +50 -0
- package/src/composites/terraform-apply-op.acceptance.test.ts +138 -0
- package/src/composites/terraform-apply-op.test.ts +193 -0
- package/src/composites/terraform-apply-op.ts +166 -0
- package/src/composites/terraform-watch-op.test.ts +184 -0
- package/src/composites/terraform-watch-op.ts +204 -0
- package/src/config.ts +76 -0
- package/src/describe-resources.test.ts +342 -0
- package/src/describe-resources.ts +357 -0
- package/src/generated/index.d.ts +2 -0
- package/src/generated/index.ts +4 -0
- package/src/generated/lexicon-terraform.json +1 -0
- package/src/hcl/parse.ts +235 -0
- package/src/hcl/roots.ts +67 -0
- package/src/index.ts +65 -0
- package/src/lint/audit-catalog.ts +30 -0
- package/src/lint/audit-lineage.ts +21 -0
- package/src/lint/audit.test.ts +45 -0
- package/src/lint/post-synth/index.ts +7 -0
- package/src/lint/post-synth/post-synth.test.ts +95 -0
- package/src/lint/post-synth/tf001.ts +66 -0
- package/src/lint/rules/index.ts +7 -0
- package/src/lint/rules/plan-before-apply.test.ts +111 -0
- package/src/lint/rules/plan-before-apply.ts +143 -0
- package/src/lsp/completions.test.ts +120 -0
- package/src/lsp/completions.ts +101 -0
- package/src/lsp/context.test.ts +152 -0
- package/src/lsp/context.ts +349 -0
- package/src/lsp/hover.test.ts +82 -0
- package/src/lsp/hover.ts +44 -0
- package/src/lsp/option-keys.ts +106 -0
- package/src/op/activities/index.ts +48 -0
- package/src/op/activities/registry.test.ts +29 -0
- package/src/op/activities/terraform.test.ts +445 -0
- package/src/op/activities/terraform.ts +469 -0
- package/src/op/builders.test.ts +90 -0
- package/src/op/builders.ts +96 -0
- package/src/package-cli.ts +21 -0
- package/src/plugin.test.ts +271 -0
- package/src/plugin.ts +157 -0
- package/src/serializer.test.ts +26 -0
- package/src/serializer.ts +26 -0
- package/src/skills/chant-terraform.md +92 -0
- package/src/state-ownership.ts +32 -0
- package/src/validate-cli.ts +5 -0
- package/src/validate.ts +28 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TerraformApplyOp composite tests (#2086) — the phase names, the gate, the
|
|
3
|
+
* plan-to-apply reference, and the compensation refusal.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
findGate,
|
|
9
|
+
isStepOutputRef,
|
|
10
|
+
runOpLocally,
|
|
11
|
+
LocalGateUnsupportedError,
|
|
12
|
+
type ActivityStep,
|
|
13
|
+
type GateStep,
|
|
14
|
+
type OpConfig,
|
|
15
|
+
type StepDefinition,
|
|
16
|
+
} from "@intentius/chant/op";
|
|
17
|
+
import { TerraformApplyOp } from "./terraform-apply-op";
|
|
18
|
+
|
|
19
|
+
/** The Op's declared config, which is what `chant build` serializes. */
|
|
20
|
+
function props(config: Parameters<typeof TerraformApplyOp>[0]): OpConfig {
|
|
21
|
+
return (TerraformApplyOp(config).op as unknown as { props: OpConfig }).props;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const phaseNames = (op: OpConfig): string[] => op.phases.map((p) => p.name);
|
|
25
|
+
const isActivity = (s: StepDefinition): s is ActivityStep => s.kind === "activity";
|
|
26
|
+
const isGate = (s: StepDefinition): s is GateStep => s.kind === "gate";
|
|
27
|
+
|
|
28
|
+
describe("TerraformApplyOp phases (#2086)", () => {
|
|
29
|
+
test("default gate mode is on-destroy: Init, Plan, Gate, Apply", () => {
|
|
30
|
+
expect(phaseNames(props({ name: "app-apply", root: "app" }))).toEqual([
|
|
31
|
+
"Init",
|
|
32
|
+
"Plan",
|
|
33
|
+
"Gate",
|
|
34
|
+
"Apply",
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('gate: "always" builds the same four phases', () => {
|
|
39
|
+
expect(phaseNames(props({ name: "app-apply", root: "app", gate: "always" }))).toEqual([
|
|
40
|
+
"Init",
|
|
41
|
+
"Plan",
|
|
42
|
+
"Gate",
|
|
43
|
+
"Apply",
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('gate: "never" emits no Gate phase, which is what makes the Op runnable locally', () => {
|
|
48
|
+
const op = props({ name: "app-apply", root: "app", gate: "never" });
|
|
49
|
+
expect(phaseNames(op)).toEqual(["Init", "Plan", "Apply"]);
|
|
50
|
+
expect(op.phases.flatMap((p) => p.steps).some(isGate)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("each phase runs the activity it is named for, with the root threaded through", () => {
|
|
54
|
+
const op = props({ name: "app-apply", root: "app", gate: "never" });
|
|
55
|
+
const fns = op.phases.flatMap((p) => p.steps.filter(isActivity)).map((s) => s.fn);
|
|
56
|
+
expect(fns).toEqual(["terraformInit", "terraformPlan", "terraformApply"]);
|
|
57
|
+
for (const step of op.phases.flatMap((p) => p.steps.filter(isActivity))) {
|
|
58
|
+
expect(step.args?.root).toBe("app");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("Init and Apply carry longInfra; the gate's show step is fastIdempotent", () => {
|
|
63
|
+
const op = props({ name: "app-apply", root: "app" });
|
|
64
|
+
const byPhase = Object.fromEntries(op.phases.map((p) => [p.name, p.steps.filter(isActivity)]));
|
|
65
|
+
expect(byPhase.Init[0].profile).toBe("longInfra");
|
|
66
|
+
expect(byPhase.Plan[0].profile).toBe("longInfra");
|
|
67
|
+
expect(byPhase.Gate[0].profile).toBe("fastIdempotent");
|
|
68
|
+
expect(byPhase.Apply[0].profile).toBe("longInfra");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("TerraformApplyOp gate (#2086)", () => {
|
|
73
|
+
test("signal name defaults to approve-<name>", () => {
|
|
74
|
+
const op = props({ name: "prod-apply", root: "app" });
|
|
75
|
+
const gate = op.phases.find((p) => p.name === "Gate")!.steps.find(isGate)!;
|
|
76
|
+
expect(gate.signalName).toBe("approve-prod-apply");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("an explicit signalName, timeout and description win", () => {
|
|
80
|
+
const op = props({
|
|
81
|
+
name: "prod-apply",
|
|
82
|
+
root: "app",
|
|
83
|
+
signalName: "approve-terraform",
|
|
84
|
+
gateTimeout: "72h",
|
|
85
|
+
gateDescription: "Change window only",
|
|
86
|
+
});
|
|
87
|
+
const gate = op.phases.find((p) => p.name === "Gate")!.steps.find(isGate)!;
|
|
88
|
+
expect(gate).toMatchObject({
|
|
89
|
+
signalName: "approve-terraform",
|
|
90
|
+
timeout: "72h",
|
|
91
|
+
description: "Change window only",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("the Gate phase reports the plan's destroy count before it waits", () => {
|
|
96
|
+
// GateStep has no condition field, so "on-destroy" cannot branch at build
|
|
97
|
+
// time. The approver is told instead: a `show` of the saved plan runs
|
|
98
|
+
// first and surfaces `destroys` as a search attribute.
|
|
99
|
+
const op = props({ name: "prod-apply", root: "app" });
|
|
100
|
+
const steps = op.phases.find((p) => p.name === "Gate")!.steps;
|
|
101
|
+
const show = steps[0] as ActivityStep;
|
|
102
|
+
expect(show.fn).toBe("terraformShow");
|
|
103
|
+
expect(show.outcomeAttribute).toEqual({ name: "Destroys", from: "destroys" });
|
|
104
|
+
expect(steps[1].kind).toBe("gate");
|
|
105
|
+
expect(isStepOutputRef(show.args?.planFile)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("TerraformApplyOp plan-to-apply reference (#2086)", () => {
|
|
110
|
+
test("Plan surfaces `changed`, and Apply applies that step's own plan file", () => {
|
|
111
|
+
const op = props({ name: "app-apply", root: "app", gate: "never" });
|
|
112
|
+
const plan = op.phases.find((p) => p.name === "Plan")!.steps[0] as ActivityStep;
|
|
113
|
+
const apply = op.phases.find((p) => p.name === "Apply")!.steps[0] as ActivityStep;
|
|
114
|
+
|
|
115
|
+
expect(plan.id).toBe("plan");
|
|
116
|
+
expect(plan.outcomeAttribute).toEqual({ name: "Changed", from: "changed" });
|
|
117
|
+
expect(plan.args?.planFile).toBe("chant.tfplan");
|
|
118
|
+
|
|
119
|
+
const ref = apply.args?.planFile;
|
|
120
|
+
expect(isStepOutputRef(ref)).toBe(true);
|
|
121
|
+
expect(ref).toMatchObject({ step: "plan", path: "planFile" });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("an explicit planFile is what Plan writes and Apply reads back by reference", () => {
|
|
125
|
+
const op = props({ name: "app-apply", root: "app", gate: "never", planFile: "prod.tfplan" });
|
|
126
|
+
const plan = op.phases.find((p) => p.name === "Plan")!.steps[0] as ActivityStep;
|
|
127
|
+
expect(plan.args?.planFile).toBe("prod.tfplan");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("upgrade is opt-in on the Init step", () => {
|
|
131
|
+
const withUpgrade = props({ name: "a", root: "app", gate: "never", upgrade: true });
|
|
132
|
+
const without = props({ name: "a", root: "app", gate: "never" });
|
|
133
|
+
expect((withUpgrade.phases[0].steps[0] as ActivityStep).args).toEqual({ root: "app", upgrade: true });
|
|
134
|
+
expect((without.phases[0].steps[0] as ActivityStep).args).toEqual({ root: "app" });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("TerraformApplyOp compensation — total or refused (#2086)", () => {
|
|
139
|
+
test("compensate: true throws at build time, naming the Op and terraform's lack of rollback", () => {
|
|
140
|
+
expect(() => TerraformApplyOp({ name: "prod-apply", root: "app", compensate: true })).toThrow(
|
|
141
|
+
/TerraformApplyOp "prod-apply".*no automatic rollback/s,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("an object without a command is refused the same way", () => {
|
|
146
|
+
expect(() => TerraformApplyOp({ name: "prod-apply", root: "app", compensate: {} })).toThrow(
|
|
147
|
+
/TerraformApplyOp "prod-apply"/,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("compensate: false and the default build no onFailure phase", () => {
|
|
152
|
+
expect(props({ name: "a", root: "app", compensate: false }).onFailure).toBeUndefined();
|
|
153
|
+
expect(props({ name: "a", root: "app" }).onFailure).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("compensate: { command } builds an onFailure Rollback phase running that command", () => {
|
|
157
|
+
const op = props({
|
|
158
|
+
name: "a",
|
|
159
|
+
root: "app",
|
|
160
|
+
compensate: { command: "terraform apply -input=false rollback.tfplan" },
|
|
161
|
+
});
|
|
162
|
+
expect(op.onFailure?.map((p) => p.name)).toEqual(["Rollback"]);
|
|
163
|
+
expect(op.onFailure![0].steps[0]).toMatchObject({
|
|
164
|
+
kind: "activity",
|
|
165
|
+
fn: "shellCmd",
|
|
166
|
+
args: { cmd: "terraform apply -input=false rollback.tfplan" },
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("TerraformApplyOp on the local executor (#2086)", () => {
|
|
172
|
+
test("a gated Op is refused up front, naming the signal the local executor cannot wait for", async () => {
|
|
173
|
+
const op = props({ name: "prod-apply", root: "app" });
|
|
174
|
+
await expect(runOpLocally(op, new Map(), {})).rejects.toBeInstanceOf(LocalGateUnsupportedError);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('gate: "never" clears that refusal, which is what makes `chant run` viable', () => {
|
|
178
|
+
expect(findGate(props({ name: "prod-apply", root: "app", gate: "never" }))).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("TerraformApplyOp Op metadata (#2086)", () => {
|
|
183
|
+
test("task queue defaults to the name and search attributes name the root", () => {
|
|
184
|
+
const op = props({ name: "app-apply", root: "app" });
|
|
185
|
+
expect(op.name).toBe("app-apply");
|
|
186
|
+
expect(op.taskQueue).toBe("app-apply");
|
|
187
|
+
expect(op.searchAttributes).toEqual({ Apply: "true", TerraformRoot: "app" });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("an explicit taskQueue wins", () => {
|
|
191
|
+
expect(props({ name: "app-apply", root: "app", taskQueue: "infra" }).taskQueue).toBe("infra");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TerraformApplyOp composite (#2086) — init, plan, approve, apply for one root
|
|
3
|
+
* module named in `terraform.roots`.
|
|
4
|
+
*
|
|
5
|
+
* Authority stays with terraform: chant runs the binary, reads its plan, and
|
|
6
|
+
* hands the same saved plan to apply. The Apply step never re-plans, because
|
|
7
|
+
* it takes the Plan step's own `planFile` as a `StepOutputRef` — whatever was
|
|
8
|
+
* approved is what runs.
|
|
9
|
+
*
|
|
10
|
+
* Phases: Init, Plan, Gate, Apply.
|
|
11
|
+
*
|
|
12
|
+
* The gate is durable-runtime work, so an Op that emits one needs Temporal;
|
|
13
|
+
* `packages/core/src/op/local-executor.ts` refuses any Op containing a gate.
|
|
14
|
+
* `gate: "never"` is what makes the Op runnable with `chant run` locally.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* import { TerraformApplyOp } from "@intentius/chant-lexicon-terraform";
|
|
19
|
+
*
|
|
20
|
+
* export const { op } = TerraformApplyOp({ name: "app-apply", root: "app" });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Op, phase, gate, activity, OpResource } from "@intentius/chant/op";
|
|
25
|
+
import { DEFAULT_PLAN_FILE } from "../op/activities/terraform";
|
|
26
|
+
import {
|
|
27
|
+
terraformInit as initStep,
|
|
28
|
+
terraformPlan as planStep,
|
|
29
|
+
terraformApply as applyStep,
|
|
30
|
+
terraformShow as showStep,
|
|
31
|
+
} from "../op/builders";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* When to pause for a human before applying.
|
|
35
|
+
*
|
|
36
|
+
* `GateStep` (`packages/core/src/op/types.ts`) carries a signal name, a
|
|
37
|
+
* timeout and a description, and nothing conditional — so `"on-destroy"`
|
|
38
|
+
* cannot branch at build time on a count the plan only produces at run time.
|
|
39
|
+
* The workable v1 is that `"on-destroy"` and `"always"` emit the same Gate
|
|
40
|
+
* phase, and the approver is told what is at stake instead: the phase reports
|
|
41
|
+
* the plan's `destroys` count as the `Destroys` search attribute before it
|
|
42
|
+
* waits, and the gate description says so. A gate that skips itself when the
|
|
43
|
+
* plan turns out additive is a new step kind, out of scope here.
|
|
44
|
+
*/
|
|
45
|
+
export type TerraformGateMode = "on-destroy" | "always" | "never";
|
|
46
|
+
|
|
47
|
+
export interface TerraformApplyOpConfig {
|
|
48
|
+
/** Op name (kebab-case). Also the default task queue and gate signal suffix. */
|
|
49
|
+
name: string;
|
|
50
|
+
/** Key into the project's `terraform.roots`. The root carries dir, workspace, var files and backend config. */
|
|
51
|
+
root: string;
|
|
52
|
+
/** Plan file written by Plan and consumed by Apply, relative to the root dir. Default: `chant.tfplan`. */
|
|
53
|
+
planFile?: string;
|
|
54
|
+
/**
|
|
55
|
+
* When to emit the approval gate. Default: `"on-destroy"`. See
|
|
56
|
+
* {@link TerraformGateMode} for why `"on-destroy"` and `"always"` build the
|
|
57
|
+
* same phase.
|
|
58
|
+
*/
|
|
59
|
+
gate?: TerraformGateMode;
|
|
60
|
+
/** Gate signal name. Default: `approve-<name>`, as `ApplyOp` does. */
|
|
61
|
+
signalName?: string;
|
|
62
|
+
/** Temporal duration the gate waits before timing out. Default: core's own (48h). */
|
|
63
|
+
gateTimeout?: string;
|
|
64
|
+
/** Override the gate description shown to the approver. */
|
|
65
|
+
gateDescription?: string;
|
|
66
|
+
/** `-upgrade` on the Init step: re-resolve provider and module versions. */
|
|
67
|
+
upgrade?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Directory each step starts the `chant.config.*` search from, which is
|
|
70
|
+
* what `terraform.roots` and the root's relative `dir` resolve against.
|
|
71
|
+
* Default: the running process's cwd, which is the project root under
|
|
72
|
+
* `chant run`.
|
|
73
|
+
*/
|
|
74
|
+
cwd?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Saga-style rollback on a failed apply, run as an `onFailure` phase.
|
|
77
|
+
*
|
|
78
|
+
* Terraform has no automatic rollback: a half-applied plan is undone by
|
|
79
|
+
* planning and applying the inverse, which is a decision about the estate
|
|
80
|
+
* rather than something chant can synthesize. So compensation here is
|
|
81
|
+
* total or refused, the same stance `ApplyOp` takes (`lexicons/temporal/src/
|
|
82
|
+
* composites/apply-op.ts`) — `true`, or an object with no `command`, throws
|
|
83
|
+
* when the Op is built, naming the Op, rather than warning at the moment a
|
|
84
|
+
* rollback is already needed. Supply `compensate: { command: "..." }` with a
|
|
85
|
+
* rollback of your own, or leave it unset.
|
|
86
|
+
*/
|
|
87
|
+
compensate?: boolean | { command?: string };
|
|
88
|
+
/** Override the task queue. Defaults to `name`. */
|
|
89
|
+
taskQueue?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface TerraformApplyOpResources {
|
|
93
|
+
/** Op resource — generates the Init/Plan/[Gate]/Apply workflow. */
|
|
94
|
+
op: InstanceType<typeof OpResource>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApplyOpResources {
|
|
98
|
+
const taskQueue = config.taskQueue ?? config.name;
|
|
99
|
+
const planFile = config.planFile ?? DEFAULT_PLAN_FILE;
|
|
100
|
+
const gateMode: TerraformGateMode = config.gate ?? "on-destroy";
|
|
101
|
+
|
|
102
|
+
// Terraform has no rollback to run, so asking for one without supplying it
|
|
103
|
+
// fails here, at build time, with the Op named — not as a warning when the
|
|
104
|
+
// apply has already half-run. Same shape as ApplyOp's refusal (#1449).
|
|
105
|
+
const compensateCommand = typeof config.compensate === "object" ? config.compensate.command : undefined;
|
|
106
|
+
if (config.compensate !== undefined && config.compensate !== false && compensateCommand === undefined) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`TerraformApplyOp "${config.name}": compensate is enabled, but terraform has no automatic ` +
|
|
109
|
+
`rollback — undoing a partial apply means planning and applying the inverse, which is a ` +
|
|
110
|
+
`decision about the estate rather than something chant can synthesize. Either supply ` +
|
|
111
|
+
`compensate: { command: "..." } with a rollback of your own, or set compensate: false.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Every step resolves the root through the same project config, so the cwd
|
|
116
|
+
// travels with each of them rather than being read off the process once.
|
|
117
|
+
const where = config.cwd ? { cwd: config.cwd } : {};
|
|
118
|
+
|
|
119
|
+
// `id` is what makes `plan.out` legal, and `plan.out.planFile` is how the
|
|
120
|
+
// Apply step below names this step's saved plan.
|
|
121
|
+
const plan = planStep(config.root, { planFile, ...where, id: "plan" });
|
|
122
|
+
plan.outcomeAttribute = { name: "Changed", from: "changed" };
|
|
123
|
+
|
|
124
|
+
const phases = [
|
|
125
|
+
phase("Init", [initStep(config.root, { ...where, ...(config.upgrade ? { upgrade: true } : {}) })]),
|
|
126
|
+
phase("Plan", [plan]),
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
if (gateMode !== "never") {
|
|
130
|
+
// Re-render the saved plan just before the wait, so the approver's
|
|
131
|
+
// `Destroys` attribute comes off the plan that will actually apply.
|
|
132
|
+
// `show` against a plan file calls no provider.
|
|
133
|
+
const show = showStep(config.root, { ...where, planFile: plan.out.planFile });
|
|
134
|
+
show.outcomeAttribute = { name: "Destroys", from: "destroys" };
|
|
135
|
+
phases.push(
|
|
136
|
+
phase("Gate", [
|
|
137
|
+
show,
|
|
138
|
+
gate(config.signalName ?? `approve-${config.name}`, {
|
|
139
|
+
...(config.gateTimeout ? { timeout: config.gateTimeout } : {}),
|
|
140
|
+
description:
|
|
141
|
+
config.gateDescription ??
|
|
142
|
+
`Approve terraform apply of root "${config.root}" (gate: ${gateMode}). ` +
|
|
143
|
+
`The Destroys search attribute on this phase is the plan's destroy count.`,
|
|
144
|
+
}),
|
|
145
|
+
]),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
phases.push(phase("Apply", [applyStep(config.root, { ...where, planFile: plan.out.planFile })]));
|
|
150
|
+
|
|
151
|
+
const op = Op({
|
|
152
|
+
name: config.name,
|
|
153
|
+
overview: `Init, plan and apply the "${config.root}" terraform root`,
|
|
154
|
+
taskQueue,
|
|
155
|
+
searchAttributes: {
|
|
156
|
+
Apply: "true",
|
|
157
|
+
TerraformRoot: config.root,
|
|
158
|
+
},
|
|
159
|
+
phases,
|
|
160
|
+
...(compensateCommand
|
|
161
|
+
? { onFailure: [phase("Rollback", [activity("shellCmd", { cmd: compensateCommand })])] }
|
|
162
|
+
: {}),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return { op };
|
|
166
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TerraformWatchOp composite tests (#2087): the phases, the Drift outcome
|
|
3
|
+
* attribute, the three finding modes, the schedule, and the hard rule that
|
|
4
|
+
* only the `-no-color` plan text can reach an issue or PR body.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
isStepOutputRef,
|
|
10
|
+
collectStepOutputRefs,
|
|
11
|
+
type ActivityStep,
|
|
12
|
+
type OpConfig,
|
|
13
|
+
type StepDefinition,
|
|
14
|
+
} from "@intentius/chant/op";
|
|
15
|
+
|
|
16
|
+
import { TerraformWatchOp } from "./terraform-watch-op";
|
|
17
|
+
|
|
18
|
+
/** The Op's declared config, which is what `chant build` serializes. */
|
|
19
|
+
function props(config: Parameters<typeof TerraformWatchOp>[0]): OpConfig {
|
|
20
|
+
return (TerraformWatchOp(config).op as unknown as { props: OpConfig }).props;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const phaseNames = (op: OpConfig): string[] => op.phases.map((p) => p.name);
|
|
24
|
+
const isActivity = (s: StepDefinition): s is ActivityStep => s.kind === "activity";
|
|
25
|
+
const steps = (op: OpConfig): ActivityStep[] => op.phases.flatMap((p) => p.steps.filter(isActivity));
|
|
26
|
+
|
|
27
|
+
describe("TerraformWatchOp phases (#2087)", () => {
|
|
28
|
+
test("report mode is Init then Plan, and opens nothing", () => {
|
|
29
|
+
const op = props({ name: "app-watch", root: "app" });
|
|
30
|
+
expect(phaseNames(op)).toEqual(["Init", "Plan"]);
|
|
31
|
+
expect(steps(op).map((s) => s.fn)).toEqual(["terraformInit", "terraformPlan"]);
|
|
32
|
+
expect(steps(op).some((s) => s.fn === "reconcilePr")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('"report" is the default finding mode', () => {
|
|
36
|
+
expect(phaseNames(props({ name: "app-watch", root: "app", findingMode: "report" }))).toEqual(
|
|
37
|
+
phaseNames(props({ name: "app-watch", root: "app" })),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("every step names the configured root", () => {
|
|
42
|
+
for (const step of steps(props({ name: "app-watch", root: "app", findingMode: "issue" }))) {
|
|
43
|
+
if (step.fn === "reconcilePr") continue;
|
|
44
|
+
expect(step.args?.root).toBe("app");
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("the Plan step reports drift as a search attribute", () => {
|
|
49
|
+
const plan = props({ name: "app-watch", root: "app" }).phases[1].steps[0] as ActivityStep;
|
|
50
|
+
expect(plan.fn).toBe("terraformPlan");
|
|
51
|
+
expect(plan.id).toBe("plan");
|
|
52
|
+
// `terraformPlanCommand` always emits -detailed-exitcode, which is what
|
|
53
|
+
// makes `changed` answerable at all. See ../op/activities/terraform.ts.
|
|
54
|
+
expect(plan.outcomeAttribute).toEqual({ name: "Drift", from: "changed" });
|
|
55
|
+
expect(plan.args?.planFile).toBe("chant.tfplan");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("cwd and upgrade ride the steps that take them", () => {
|
|
59
|
+
const op = props({ name: "app-watch", root: "app", cwd: "/repo", upgrade: true });
|
|
60
|
+
const [init, plan] = steps(op);
|
|
61
|
+
expect(init.args).toMatchObject({ root: "app", cwd: "/repo", upgrade: true });
|
|
62
|
+
expect(plan.args).toMatchObject({ root: "app", cwd: "/repo" });
|
|
63
|
+
expect(plan.args?.upgrade).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("no cwd means no cwd arg, so each step reads the process's own", () => {
|
|
67
|
+
for (const step of steps(props({ name: "app-watch", root: "app" }))) {
|
|
68
|
+
expect(step.args && "cwd" in step.args).toBe(false);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("TerraformWatchOp finding modes (#2087)", () => {
|
|
74
|
+
const findingStep = (mode: "issue" | "pull-request"): ActivityStep => {
|
|
75
|
+
const op = props({ name: "app-watch", root: "app", findingMode: mode });
|
|
76
|
+
expect(phaseNames(op)).toEqual(["Init", "Plan", "Report"]);
|
|
77
|
+
return op.phases[2].steps[0] as ActivityStep;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
test("issue mode opens an issue through reconcilePr and surfaces its URL", () => {
|
|
81
|
+
const step = findingStep("issue");
|
|
82
|
+
expect(step.fn).toBe("reconcilePr");
|
|
83
|
+
expect(step.args?.mode).toBe("issue");
|
|
84
|
+
expect(step.args?.env).toBe("app");
|
|
85
|
+
expect(step.outcomeAttribute).toEqual({ name: "Issue", from: "issueUrl" });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("pull-request mode opens a PR through the same activity", () => {
|
|
89
|
+
const step = findingStep("pull-request");
|
|
90
|
+
expect(step.fn).toBe("reconcilePr");
|
|
91
|
+
expect(step.args?.mode).toBe("pull-request");
|
|
92
|
+
expect(step.outcomeAttribute).toEqual({ name: "PR", from: "prUrl" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("the finding step derives no plan of its own", () => {
|
|
96
|
+
// `entries: []` is what stops reconcilePr shelling to `chant lifecycle
|
|
97
|
+
// plan --json`: the finding is already written by the time it runs.
|
|
98
|
+
expect(findingStep("issue").args?.entries).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("title defaults to the root and can be overridden, and branch only rides when given", () => {
|
|
102
|
+
expect(findingStep("issue").args?.title).toBe('Terraform drift in root "app"');
|
|
103
|
+
const custom = props({
|
|
104
|
+
name: "app-watch",
|
|
105
|
+
root: "app",
|
|
106
|
+
findingMode: "pull-request",
|
|
107
|
+
title: "Nightly drift",
|
|
108
|
+
branch: "chant/tf-drift",
|
|
109
|
+
}).phases[2].steps[0] as ActivityStep;
|
|
110
|
+
expect(custom.args?.title).toBe("Nightly drift");
|
|
111
|
+
expect(custom.args?.branch).toBe("chant/tf-drift");
|
|
112
|
+
expect(findingStep("issue").args && "branch" in findingStep("issue").args!).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("TerraformWatchOp posts the human plan and never the plan JSON (#2087)", () => {
|
|
117
|
+
for (const mode of ["issue", "pull-request"] as const) {
|
|
118
|
+
test(`${mode}: the body is a reference to the Plan step's -no-color text`, () => {
|
|
119
|
+
const op = props({ name: "app-watch", root: "app", findingMode: mode });
|
|
120
|
+
const report = op.phases[2].steps[0] as ActivityStep;
|
|
121
|
+
const body = report.args?.body;
|
|
122
|
+
expect(isStepOutputRef(body)).toBe(true);
|
|
123
|
+
expect(body).toMatchObject({ step: "plan", path: "text" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test(`${mode}: no reference to the plan JSON reaches the finding step`, () => {
|
|
127
|
+
// `TerraformPlanResult.json` is `terraform show -json` over the saved
|
|
128
|
+
// plan: every attribute value the plan touches, provider credentials
|
|
129
|
+
// included. The only other thing that could carry it is a literal, so
|
|
130
|
+
// both the references and the serialized args are checked.
|
|
131
|
+
const op = props({ name: "app-watch", root: "app", findingMode: mode });
|
|
132
|
+
const report = op.phases[2].steps[0] as ActivityStep;
|
|
133
|
+
const paths = collectStepOutputRefs(report.args).map((r) => r.path);
|
|
134
|
+
expect(paths).toEqual(["text"]);
|
|
135
|
+
expect(paths).not.toContain("json");
|
|
136
|
+
expect(JSON.stringify(report.args)).not.toContain("json");
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
test("the whole Op references exactly one field of the plan", () => {
|
|
141
|
+
const op = props({ name: "app-watch", root: "app", findingMode: "issue" });
|
|
142
|
+
const refs = op.phases.flatMap((p) => p.steps.filter(isActivity)).flatMap((s) => collectStepOutputRefs(s.args));
|
|
143
|
+
expect(refs).toHaveLength(1);
|
|
144
|
+
expect(refs[0]).toMatchObject({ step: "plan", path: "text" });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("TerraformWatchOp schedule (#2087)", () => {
|
|
149
|
+
test("omitting `schedule` returns no schedule", () => {
|
|
150
|
+
const result = TerraformWatchOp({ name: "app-watch", root: "app" });
|
|
151
|
+
expect(result.schedule).toBeUndefined();
|
|
152
|
+
expect(Object.prototype.hasOwnProperty.call(result, "schedule")).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a cron returns a Temporal::Schedule beside the Op", () => {
|
|
156
|
+
const { op, schedule } = TerraformWatchOp({
|
|
157
|
+
name: "app-watch",
|
|
158
|
+
root: "app",
|
|
159
|
+
schedule: "0 6 * * *",
|
|
160
|
+
});
|
|
161
|
+
expect(schedule).toBeDefined();
|
|
162
|
+
expect(schedule!.entityType).toBe("Temporal::Schedule");
|
|
163
|
+
expect(schedule!.lexicon).toBe("temporal");
|
|
164
|
+
expect((schedule as unknown as { props: Record<string, unknown> }).props).toEqual({
|
|
165
|
+
scheduleId: "app-watch-schedule",
|
|
166
|
+
spec: { cronExpressions: ["0 6 * * *"] },
|
|
167
|
+
action: { workflowType: "appWatchWorkflow", taskQueue: "app-watch" },
|
|
168
|
+
});
|
|
169
|
+
expect((op as unknown as { props: OpConfig }).props.taskQueue).toBe("app-watch");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("an explicit taskQueue reaches both the Op and the schedule action", () => {
|
|
173
|
+
const { op, schedule } = TerraformWatchOp({
|
|
174
|
+
name: "app-watch",
|
|
175
|
+
root: "app",
|
|
176
|
+
schedule: "0 6 * * *",
|
|
177
|
+
taskQueue: "infra",
|
|
178
|
+
});
|
|
179
|
+
expect((op as unknown as { props: OpConfig }).props.taskQueue).toBe("infra");
|
|
180
|
+
expect(
|
|
181
|
+
((schedule as unknown as { props: { action: { taskQueue: string } } }).props.action).taskQueue,
|
|
182
|
+
).toBe("infra");
|
|
183
|
+
});
|
|
184
|
+
});
|