@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
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terraform Op activities (#2086) — init, plan, apply and show against a root
|
|
3
|
+
* module named in the project's `terraform.roots` config namespace (#2083).
|
|
4
|
+
*
|
|
5
|
+
* Shaped after `lexicons/k3s/src/op/activities/k3s.ts`: `promisify(exec)` with
|
|
6
|
+
* the caller's `AbortSignal` forwarded so a local timeout or Ctrl-C kills the
|
|
7
|
+
* child, and every command and environment string produced by a pure exported
|
|
8
|
+
* function so a test can assert on it without running terraform.
|
|
9
|
+
*
|
|
10
|
+
* Two invariants hold for every call:
|
|
11
|
+
*
|
|
12
|
+
* - `TF_IN_AUTOMATION=1` is in the environment, which is what tells terraform
|
|
13
|
+
* it is not talking to a person.
|
|
14
|
+
* - `-input=false` is on every command that accepts it, so a missing variable
|
|
15
|
+
* fails the step instead of blocking on a prompt nobody will answer.
|
|
16
|
+
* `terraform show` is the one command that does not accept the flag
|
|
17
|
+
* (terraform answers `flag provided but not defined: -input`), so it
|
|
18
|
+
* carries the environment variable alone.
|
|
19
|
+
*
|
|
20
|
+
* `terraformApply` takes a saved plan file and nothing else on a stock root,
|
|
21
|
+
* since a bare apply re-plans at apply time, which is exactly the gap an
|
|
22
|
+
* approval gate exists to close, so it is refused rather than offered. On a
|
|
23
|
+
* live root (#2103, `terraform.binary: "choudoufu"` plus a declared estate)
|
|
24
|
+
* it is the opposite: choudoufu refuses a plan file by design, so
|
|
25
|
+
* `terraformApply` refuses one there and runs a bare `apply -auto-approve`
|
|
26
|
+
* instead.
|
|
27
|
+
*
|
|
28
|
+
* `choudoufuLivePlan`, `choudoufuLiveLs` and `choudoufuLiveCheck` (#2103) are
|
|
29
|
+
* choudoufu-only activities living in this same module for the same reason
|
|
30
|
+
* the four above do: pure command builders, the `promisify(exec)` shape, and
|
|
31
|
+
* no dependency on the lexicon's HCL parse. `./live-detect.ts` is a
|
|
32
|
+
* deliberately separate, cheap regex-based read of "does this root declare a
|
|
33
|
+
* live estate," used only to decide how `terraformApply` reads a non-zero
|
|
34
|
+
* exit and to
|
|
35
|
+
* auto-detect `-estate` for the two live-only reads that need it; the
|
|
36
|
+
* accurate, AST-based version lives in `../../hcl/parse.ts` and stays out of
|
|
37
|
+
* this module's dependency graph. `choudoufu version` is checked once per
|
|
38
|
+
* module load (`ensureChoudoufuVersion`) and refuses a binary older than
|
|
39
|
+
* {@link MIN_CHOUDOUFU_VERSION}.
|
|
40
|
+
*/
|
|
41
|
+
import { exec } from "node:child_process";
|
|
42
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
43
|
+
import { promisify } from "node:util";
|
|
44
|
+
import { resolve, dirname, join, relative, sep } from "node:path";
|
|
45
|
+
import { loadChantConfigUpward } from "@intentius/chant/config";
|
|
46
|
+
import { liveReadKey, memoLiveRead } from "@intentius/chant/live-read-session";
|
|
47
|
+
import { terraformPlanDigest } from "../../plan-digest.js";
|
|
48
|
+
import { choudoufuSetPlanParts, plannerForBinary, terraformChangeSetPart } from "../../change-set.js";
|
|
49
|
+
import { detectLiveEstate } from "./live-detect.js";
|
|
50
|
+
import { parseAdoptionCommands, readAdoptionLedger, renderAdoptionLedger, } from "../adoption.js";
|
|
51
|
+
const execAsync = promisify(exec);
|
|
52
|
+
/**
|
|
53
|
+
* `terraform show -json` on a large estate runs to megabytes, well past
|
|
54
|
+
* `exec`'s 1 MiB default, and the failure mode there is a truncated buffer
|
|
55
|
+
* rather than a clear error.
|
|
56
|
+
*/
|
|
57
|
+
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
58
|
+
/** Plan file written into the root directory when a step names none. */
|
|
59
|
+
export const DEFAULT_PLAN_FILE = "chant.tfplan";
|
|
60
|
+
/** Binary used when the `terraform` namespace records no preference. */
|
|
61
|
+
export const DEFAULT_TERRAFORM_BINARY = "terraform";
|
|
62
|
+
/** Where {@link choudoufuLivePlan} writes GitHub issue #788's JSON document, relative to the root directory. */
|
|
63
|
+
export const DEFAULT_LIVE_PLAN_DOCUMENT_FILE = "chant.live-plan.json";
|
|
64
|
+
/**
|
|
65
|
+
* choudoufu below this refuses. v0.12.0 brought `live-plan -json`, `live-ls`
|
|
66
|
+
* and `live-check -json` (#2103); v0.13.0 brought the approval artifact
|
|
67
|
+
* ([choudoufu #878](https://github.com/INTENTIUS/choudoufu/issues/878), PR
|
|
68
|
+
* 889): under a live block `plan -out=FILE` is accepted and `apply FILE`
|
|
69
|
+
* re-plans the live system and refuses on a mismatch with exit status 3.
|
|
70
|
+
* v0.14.0 brought the `-json` document on a configuration that names its own
|
|
71
|
+
* estate ([choudoufu #894](https://github.com/INTENTIUS/choudoufu/issues/894),
|
|
72
|
+
* PR 915), which is the shape every chant live root has: before it,
|
|
73
|
+
* {@link choudoufuLivePlan} could not produce the document for a real root at
|
|
74
|
+
* all. v0.15.0 brought the document's `adoptable` section and the `swept`
|
|
75
|
+
* list beside it
|
|
76
|
+
* ([choudoufu #962](https://github.com/INTENTIUS/choudoufu/issues/962), PR
|
|
77
|
+
* 963), which is the only place a content match reaches a machine: before it,
|
|
78
|
+
* a declared resource whose identity the provider assigns could never be
|
|
79
|
+
* adopted through the document at all, and `TerraformAdoptOp`'s own
|
|
80
|
+
* acceptance suite had never passed on any binary (#2241).
|
|
81
|
+
*
|
|
82
|
+
* {@link terraformApply} depends on the exit-3 refusal, `describeResources()`
|
|
83
|
+
* and both document-reading Ops depend on the document, and the adopt path
|
|
84
|
+
* depends on the `adoptable` section, so the floor is the release that
|
|
85
|
+
* shipped the last of the three.
|
|
86
|
+
*/
|
|
87
|
+
export const MIN_CHOUDOUFU_VERSION = "0.15.0";
|
|
88
|
+
/**
|
|
89
|
+
* The choudoufu release a gated wave needs for a choudoufu root (#3459):
|
|
90
|
+
* the set digest on `live-plan-set -json`, and `live-wave-apply` with its
|
|
91
|
+
* resume file (choudoufu#1754, merged as choudoufu PR 1816 after v0.21.0).
|
|
92
|
+
* choudoufu's changelog lists both under `v0.22.0 (Unreleased)`, so this is
|
|
93
|
+
* the version main releases as.
|
|
94
|
+
*
|
|
95
|
+
* Only the wave path checks it, so plain `terraform` and `tofu` roots, and a
|
|
96
|
+
* choudoufu root run any other way, do not wait on it. A dev build prints no
|
|
97
|
+
* release version. For one, the check is the capability itself: a set plan
|
|
98
|
+
* document without a top-level `digest` came from a choudoufu older than
|
|
99
|
+
* choudoufu#1754, and {@link choudoufuPlanSet} refuses it.
|
|
100
|
+
*/
|
|
101
|
+
export const MIN_CHOUDOUFU_WAVES_VERSION = "0.22.0";
|
|
102
|
+
/**
|
|
103
|
+
* Where a choudoufu root's wave files live, relative to the project root:
|
|
104
|
+
* `<root>/plan-set.json` (the set plan document chant planned and the
|
|
105
|
+
* approver approved), `<root>/resume-<digest>.json` (choudoufu's resume
|
|
106
|
+
* file) and `<root>/out/` (plan files and logs).
|
|
107
|
+
*/
|
|
108
|
+
export const CHOUDOUFU_WAVES_DIR = ".chant/choudoufu-waves";
|
|
109
|
+
/** `live-wave-apply`'s exit statuses (choudoufu#1754). */
|
|
110
|
+
export const CHOUDOUFU_WAVE_EXIT = { landed: 0, error: 1, setMoved: 3, rootFailed: 4 };
|
|
111
|
+
/**
|
|
112
|
+
* choudoufu's own refusal summary for `-out` on the `live-plan -estate`
|
|
113
|
+
* surface (`internal/command/live_mode.go`). That is the one surface where a
|
|
114
|
+
* saved plan file is still refused: a directory reached through `-estate` has
|
|
115
|
+
* no `live` block, so plain `plan` and `apply` there are ordinary state-backed
|
|
116
|
+
* commands and a file written from this form would be applied by something
|
|
117
|
+
* that never heard of the estate. Under a `live` block `plan -out` is
|
|
118
|
+
* accepted (choudoufu #878).
|
|
119
|
+
*/
|
|
120
|
+
export const CHOUDOUFU_LIVE_PLAN_OUT_REFUSAL = "Saved plan files are not available under live resource markers";
|
|
121
|
+
/** Exit status choudoufu's approval refusals carry: not 1 (any ordinary failure), not 2 (`-detailed-exitcode`). */
|
|
122
|
+
export const CHOUDOUFU_APPROVAL_EXIT_CODE = 3;
|
|
123
|
+
/** Summary line of choudoufu's refusal when the fresh live plan differs from the approved plan file. */
|
|
124
|
+
export const CHOUDOUFU_APPROVAL_MISMATCH_REFUSAL = "The approved plan no longer matches the live system";
|
|
125
|
+
/** Summary line of choudoufu's sibling refusal when the plan file was produced against another estate. */
|
|
126
|
+
export const CHOUDOUFU_WRONG_ESTATE_REFUSAL = "The approved plan belongs to a different estate";
|
|
127
|
+
export { terraformPlanChangeSet, terraformPlanDigest } from "../../plan-digest.js";
|
|
128
|
+
// ── Pure command and environment builders ───────────────────────────────────
|
|
129
|
+
/**
|
|
130
|
+
* Quote a command-line argument for the shell `exec` runs it through, leaving
|
|
131
|
+
* ordinary paths and `key=value` pairs untouched so command strings stay
|
|
132
|
+
* readable in logs and in tests.
|
|
133
|
+
*/
|
|
134
|
+
export function quoteArg(value) {
|
|
135
|
+
return /^[A-Za-z0-9._/:=@,+-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
136
|
+
}
|
|
137
|
+
/** Which CLI drives the roots: `terraform.binary`, defaulting to `terraform`. */
|
|
138
|
+
export function terraformBinary(config) {
|
|
139
|
+
return config?.binary ?? DEFAULT_TERRAFORM_BINARY;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Environment for every invocation. `TF_IN_AUTOMATION` suppresses the
|
|
143
|
+
* "run terraform apply next" hand-holding and is terraform's own signal that
|
|
144
|
+
* no human is watching; `TF_WORKSPACE` is how a workspace gets selected
|
|
145
|
+
* without a separate `terraform workspace select` round-trip.
|
|
146
|
+
*/
|
|
147
|
+
export function terraformEnvironment(root) {
|
|
148
|
+
return {
|
|
149
|
+
TF_IN_AUTOMATION: "1",
|
|
150
|
+
...(root?.workspace ? { TF_WORKSPACE: root.workspace } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** `terraform init`, with the root's `backendConfig` as `-backend-config=k=v` flags. */
|
|
154
|
+
export function terraformInitCommand(opts) {
|
|
155
|
+
const parts = [opts.binary, "init", "-input=false"];
|
|
156
|
+
if (opts.upgrade)
|
|
157
|
+
parts.push("-upgrade");
|
|
158
|
+
if (opts.reconfigure)
|
|
159
|
+
parts.push("-reconfigure");
|
|
160
|
+
for (const [key, value] of Object.entries(opts.backendConfig ?? {})) {
|
|
161
|
+
parts.push(`-backend-config=${quoteArg(`${key}=${value}`)}`);
|
|
162
|
+
}
|
|
163
|
+
return parts.join(" ");
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* `terraform plan`. `-detailed-exitcode` is what makes the run answerable:
|
|
167
|
+
* 0 means no changes, 2 means changes, and anything else is a failure. See
|
|
168
|
+
* {@link terraformPlan}.
|
|
169
|
+
*/
|
|
170
|
+
export function terraformPlanCommand(opts) {
|
|
171
|
+
const parts = [opts.binary, "plan", "-input=false", "-detailed-exitcode"];
|
|
172
|
+
if (opts.destroy)
|
|
173
|
+
parts.push("-destroy");
|
|
174
|
+
for (const varFile of opts.varFiles ?? [])
|
|
175
|
+
parts.push(`-var-file=${quoteArg(varFile)}`);
|
|
176
|
+
for (const [name, value] of Object.entries(opts.vars ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
|
|
177
|
+
if (value === undefined)
|
|
178
|
+
continue;
|
|
179
|
+
parts.push(`-var=${quoteArg(`${name}=${typeof value === "string" ? value : JSON.stringify(value)}`)}`);
|
|
180
|
+
}
|
|
181
|
+
parts.push(`-out=${quoteArg(opts.planFile)}`);
|
|
182
|
+
return parts.join(" ");
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* `terraform apply <planFile>` — a saved plan, never a bare apply. The same
|
|
186
|
+
* command on a live root: choudoufu v0.13.0 admits a plan file under a `live`
|
|
187
|
+
* block, and a matched one applies without re-prompting, exactly as stock's
|
|
188
|
+
* own `apply <planfile>` does, so no `-auto-approve` is needed or passed.
|
|
189
|
+
*/
|
|
190
|
+
export function terraformApplyCommand(opts) {
|
|
191
|
+
return `${opts.binary} apply -input=false ${quoteArg(opts.planFile)}`;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* `live-plan -detailed-exitcode [-json] [-estate=<estate>]`. `-json` is what
|
|
195
|
+
* prints GitHub issue #788's document instead of the plan; the human render
|
|
196
|
+
* needs a second invocation without it (see {@link choudoufuLivePlan}).
|
|
197
|
+
*
|
|
198
|
+
* `estate` is omitted exactly when the configuration names its own estate.
|
|
199
|
+
* choudoufu refuses `-estate` beside a `live` block or an `estate.chdf.hcl`
|
|
200
|
+
* sidecar ("Estate named by both the live block and -estate"), and since
|
|
201
|
+
* v0.14.0 it does not need one: the run reads the declared name itself and
|
|
202
|
+
* prints the same document ([choudoufu #894](https://github.com/INTENTIUS/choudoufu/issues/894),
|
|
203
|
+
* PR 915). The flag is still how a root that declares nothing names an
|
|
204
|
+
* estate for the run.
|
|
205
|
+
*
|
|
206
|
+
* `adoptionOnly` makes that second invocation print the adoption ledger and
|
|
207
|
+
* nothing else (GitHub issue #587). It is refused here alongside `json`
|
|
208
|
+
* because choudoufu refuses the pair itself, and a builder that emitted a
|
|
209
|
+
* command the binary rejects would move the error to the far side of a
|
|
210
|
+
* process spawn for no gain.
|
|
211
|
+
*/
|
|
212
|
+
export function choudoufuLivePlanCommand(opts) {
|
|
213
|
+
if (opts.json && opts.adoptionOnly) {
|
|
214
|
+
throw new Error("choudoufuLivePlanCommand: -adoption-only and -json cannot be combined. choudoufu refuses the pair " +
|
|
215
|
+
"(-adoption-only asks for the adoption ledger and -json asks for the bound/omissions/unowned " +
|
|
216
|
+
"document), so the two reports come from two runs.");
|
|
217
|
+
}
|
|
218
|
+
const parts = [opts.binary, "live-plan", "-detailed-exitcode"];
|
|
219
|
+
if (opts.json)
|
|
220
|
+
parts.push("-json");
|
|
221
|
+
if (opts.adoptionOnly)
|
|
222
|
+
parts.push("-adoption-only");
|
|
223
|
+
if (opts.noColor)
|
|
224
|
+
parts.push("-no-color");
|
|
225
|
+
if (opts.estate !== undefined)
|
|
226
|
+
parts.push(`-estate=${quoteArg(opts.estate)}`);
|
|
227
|
+
return parts.join(" ");
|
|
228
|
+
}
|
|
229
|
+
/** `live-ls -estate=<estate> -json [-consistent]`. */
|
|
230
|
+
export function choudoufuLiveLsCommand(opts) {
|
|
231
|
+
const parts = [opts.binary, "live-ls", `-estate=${quoteArg(opts.estate)}`, "-json"];
|
|
232
|
+
if (opts.consistent)
|
|
233
|
+
parts.push("-consistent");
|
|
234
|
+
return parts.join(" ");
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* `live-check -json`. No `DIR` argument: the activity already runs in the
|
|
238
|
+
* root directory (`cwd`), so `-json` alone checks it, since DIR defaults to
|
|
239
|
+
* `.`. Makes no cloud calls; a non-zero exit is a refusal, not a failure this
|
|
240
|
+
* builder's caller need distinguish from one.
|
|
241
|
+
*/
|
|
242
|
+
export function choudoufuLiveCheckCommand(opts) {
|
|
243
|
+
return `${opts.binary} live-check -json`;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Project `live-plan -json`'s `unowned` section into counts: how many live
|
|
247
|
+
* resources at a declared identity carry no ownership marker for this
|
|
248
|
+
* estate, and of those, how many an exact content match makes adoptable
|
|
249
|
+
* (`adopt_tofu_estate`/`adopt_tofu_address` present).
|
|
250
|
+
*/
|
|
251
|
+
export function countLivePlanUnowned(document) {
|
|
252
|
+
const doc = (document ?? {});
|
|
253
|
+
const unowned = Array.isArray(doc.unowned) ? doc.unowned : [];
|
|
254
|
+
const claimable = (entry) => {
|
|
255
|
+
const e = entry;
|
|
256
|
+
return Boolean(e?.adopt_tofu_estate || e?.adopt_tofu_address);
|
|
257
|
+
};
|
|
258
|
+
const matched = Array.isArray(doc.adoptable) ? doc.adoptable.filter(claimable).length : 0;
|
|
259
|
+
return { unowned: unowned.length, adoptable: unowned.filter(claimable).length + matched };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* `terraform show`. The one command here that takes no `-input` flag
|
|
263
|
+
* (terraform answers `flag provided but not defined: -input`), so automation
|
|
264
|
+
* rests on `TF_IN_AUTOMATION` alone. Show reads an artifact and prompts for
|
|
265
|
+
* nothing in the first place.
|
|
266
|
+
*/
|
|
267
|
+
export function terraformShowCommand(opts) {
|
|
268
|
+
const parts = [opts.binary, "show", opts.json ? "-json" : "-no-color"];
|
|
269
|
+
if (opts.planFile)
|
|
270
|
+
parts.push(quoteArg(opts.planFile));
|
|
271
|
+
return parts.join(" ");
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Project a plan's `resource_changes` into add/change/destroy counts, the same
|
|
275
|
+
* three terraform prints at the end of a plan. A replace (`["delete","create"]`)
|
|
276
|
+
* counts as one add and one destroy, exactly as terraform reports it.
|
|
277
|
+
*/
|
|
278
|
+
export function countPlanChanges(planJson) {
|
|
279
|
+
const counts = { adds: 0, changes: 0, destroys: 0 };
|
|
280
|
+
const changes = planJson?.resource_changes;
|
|
281
|
+
if (!Array.isArray(changes))
|
|
282
|
+
return counts;
|
|
283
|
+
for (const entry of changes) {
|
|
284
|
+
const actions = entry?.change?.actions;
|
|
285
|
+
if (!Array.isArray(actions))
|
|
286
|
+
continue;
|
|
287
|
+
if (actions.includes("create"))
|
|
288
|
+
counts.adds++;
|
|
289
|
+
if (actions.includes("update"))
|
|
290
|
+
counts.changes++;
|
|
291
|
+
if (actions.includes("delete"))
|
|
292
|
+
counts.destroys++;
|
|
293
|
+
}
|
|
294
|
+
return counts;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Parse the ordinary "Plan: N to add, N to change, N to destroy." (or "...N
|
|
298
|
+
* to import, N to add...") summary line out of a human-readable `live-plan`
|
|
299
|
+
* render (#2106). "No changes." — the exit-0 case — matches nothing and
|
|
300
|
+
* reads as zero on every count, the same conservative default
|
|
301
|
+
* {@link countPlanChanges} falls back to on a shape it does not recognize.
|
|
302
|
+
*/
|
|
303
|
+
export function parseChoudoufuPlanSummary(text) {
|
|
304
|
+
const match = /Plan:\s*(?:\d+\s+to import,\s*)?(\d+)\s+to add,\s*(\d+)\s+to change,\s*(\d+)\s+to destroy\./.exec(text);
|
|
305
|
+
if (!match)
|
|
306
|
+
return { adds: 0, changes: 0, destroys: 0 };
|
|
307
|
+
return { adds: Number(match[1]), changes: Number(match[2]), destroys: Number(match[3]) };
|
|
308
|
+
}
|
|
309
|
+
// ── choudoufu version check ─────────────────────────────────────────────────
|
|
310
|
+
/**
|
|
311
|
+
* Parse a semver-ish string's leading `major.minor.patch`, ignoring anything
|
|
312
|
+
* after (a `-dev`/`-rc1` suffix, say). Unparseable input reads as `0.0.0`,
|
|
313
|
+
* which sorts as "older than everything" rather than crashing, since a
|
|
314
|
+
* version string this check cannot make sense of should not be quietly trusted.
|
|
315
|
+
*/
|
|
316
|
+
function parseVersionCore(version) {
|
|
317
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
318
|
+
if (!m)
|
|
319
|
+
return [0, 0, 0];
|
|
320
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
321
|
+
}
|
|
322
|
+
/** Whether `version` is strictly older than `min`, comparing `major.minor.patch` numerically. */
|
|
323
|
+
export function isOlderVersion(version, min) {
|
|
324
|
+
const a = parseVersionCore(version);
|
|
325
|
+
const b = parseVersionCore(min);
|
|
326
|
+
for (let i = 0; i < 3; i++) {
|
|
327
|
+
if (a[i] !== b[i])
|
|
328
|
+
return a[i] < b[i];
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Extract the release version from `choudoufu version`'s human-readable
|
|
334
|
+
* output: `"choudoufu v0.13.0 (based on OpenTofu v1.13.0)\non darwin_arm64"`.
|
|
335
|
+
* `choudoufu version -json`'s `terraform_version` field is the wrong number
|
|
336
|
+
* here: it names the upstream OpenTofu base this build forked from, not the
|
|
337
|
+
* choudoufu release itself (`internal/command/views/version.go`), so this
|
|
338
|
+
* reads the human form's `choudoufu vX.Y.Z` prefix instead. A dev build with
|
|
339
|
+
* no release tag baked in (`version.Fork` unset) prints only the upstream
|
|
340
|
+
* line and carries no choudoufu version to check at all: `undefined` here,
|
|
341
|
+
* same as an output this cannot parse.
|
|
342
|
+
*/
|
|
343
|
+
export function parseChoudoufuVersion(versionOutput) {
|
|
344
|
+
const m = /^choudoufu\s+v?(\d+\.\d+\.\d+)/m.exec(versionOutput);
|
|
345
|
+
return m?.[1];
|
|
346
|
+
}
|
|
347
|
+
/** Cached per activity-module load (#2103): `choudoufu version` runs at most once per process. */
|
|
348
|
+
let choudoufuVersionCheck;
|
|
349
|
+
/**
|
|
350
|
+
* Refuse a choudoufu binary older than {@link MIN_CHOUDOUFU_VERSION}. Runs
|
|
351
|
+
* `choudoufu version` (never `-json`; see {@link parseChoudoufuVersion}) at
|
|
352
|
+
* most once per module load, cached across every activity call regardless of
|
|
353
|
+
* which one triggered it. A no-op for `terraform`/`tofu`, and a no-op when
|
|
354
|
+
* the version cannot be determined at all (a dev build): this refuses a
|
|
355
|
+
* known-old binary, not an unknown one.
|
|
356
|
+
*/
|
|
357
|
+
async function ensureChoudoufuVersion(binary, signal) {
|
|
358
|
+
if (binary !== "choudoufu")
|
|
359
|
+
return;
|
|
360
|
+
choudoufuVersionCheck ?? (choudoufuVersionCheck = (async () => {
|
|
361
|
+
const { stdout } = await execAsync(`${binary} version`, { env: process.env, signal });
|
|
362
|
+
const version = parseChoudoufuVersion(stdout);
|
|
363
|
+
if (version === undefined)
|
|
364
|
+
return;
|
|
365
|
+
if (isOlderVersion(version, MIN_CHOUDOUFU_VERSION)) {
|
|
366
|
+
throw new Error(`choudoufu ${version} is older than the minimum supported version v${MIN_CHOUDOUFU_VERSION} ` +
|
|
367
|
+
"(needed for live-plan -json, live-ls, live-check -json, the approval artifact `plan -out` / " +
|
|
368
|
+
"`apply <planfile>` pair, and the document's adoptable section). Upgrade choudoufu.");
|
|
369
|
+
}
|
|
370
|
+
})());
|
|
371
|
+
return choudoufuVersionCheck;
|
|
372
|
+
}
|
|
373
|
+
/** Test-only: clear the cached version check so a test can simulate a fresh module load. */
|
|
374
|
+
export function __resetChoudoufuVersionCheckForTests() {
|
|
375
|
+
choudoufuVersionCheck = undefined;
|
|
376
|
+
choudoufuWavesVersionCheck = undefined;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Why `choudoufu version`'s output cannot drive a gated wave, or `undefined`
|
|
380
|
+
* when it can (#3459). A release older than
|
|
381
|
+
* {@link MIN_CHOUDOUFU_WAVES_VERSION} is refused. A dev build names no
|
|
382
|
+
* release and passes here; {@link parseChoudoufuPlanSet} then refuses a set
|
|
383
|
+
* plan document with no digest, which is what an older dev build prints.
|
|
384
|
+
*/
|
|
385
|
+
export function choudoufuWavesVersionRefusal(versionOutput) {
|
|
386
|
+
const version = parseChoudoufuVersion(versionOutput);
|
|
387
|
+
if (version === undefined || !isOlderVersion(version, MIN_CHOUDOUFU_WAVES_VERSION))
|
|
388
|
+
return undefined;
|
|
389
|
+
return (`choudoufu ${version} is older than v${MIN_CHOUDOUFU_WAVES_VERSION}, the first release with the set digest and ` +
|
|
390
|
+
"live-wave-apply (choudoufu#1754) that a gated wave uses for a choudoufu root. Upgrade choudoufu, or run the " +
|
|
391
|
+
"fan-out without --wave-gate.");
|
|
392
|
+
}
|
|
393
|
+
/** Cached like {@link choudoufuVersionCheck}, and only ever run on the wave path. */
|
|
394
|
+
let choudoufuWavesVersionCheck;
|
|
395
|
+
async function ensureChoudoufuWavesVersion(binary, signal) {
|
|
396
|
+
choudoufuWavesVersionCheck ?? (choudoufuWavesVersionCheck = (async () => {
|
|
397
|
+
const { stdout } = await execAsync(`${binary} version`, { env: process.env, signal });
|
|
398
|
+
const refusal = choudoufuWavesVersionRefusal(stdout);
|
|
399
|
+
if (refusal)
|
|
400
|
+
throw new Error(refusal);
|
|
401
|
+
})());
|
|
402
|
+
return choudoufuWavesVersionCheck;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Read `terraform.roots` out of the project config and resolve one entry.
|
|
406
|
+
* The walk is {@link loadChantConfigUpward}, so a step invoked from a
|
|
407
|
+
* subdirectory still finds the project's `chant.config.*`, and `dir` resolves
|
|
408
|
+
* against the directory that config lives in rather than against the cwd.
|
|
409
|
+
*
|
|
410
|
+
* Also runs {@link ensureChoudoufuVersion} and, on a choudoufu root,
|
|
411
|
+
* {@link detectLiveEstate}, so every activity answers "is this root live"
|
|
412
|
+
* the same way without repeating either check itself.
|
|
413
|
+
*/
|
|
414
|
+
async function resolveRoot(args, signal) {
|
|
415
|
+
const start = resolve(args.cwd ?? process.cwd());
|
|
416
|
+
const { config, configPath } = await loadChantConfigUpward(start);
|
|
417
|
+
const projectRoot = configPath ? dirname(configPath) : start;
|
|
418
|
+
const namespace = config.terraform;
|
|
419
|
+
const roots = namespace?.roots ?? {};
|
|
420
|
+
const root = roots[args.root];
|
|
421
|
+
if (!root) {
|
|
422
|
+
const known = Object.keys(roots).sort();
|
|
423
|
+
throw new Error(`terraform: no root named "${args.root}" in terraform.roots` +
|
|
424
|
+
`${configPath ? ` (${configPath})` : ""} — ` +
|
|
425
|
+
(known.length > 0 ? `known roots: ${known.join(", ")}` : "the namespace declares no roots"));
|
|
426
|
+
}
|
|
427
|
+
const binary = terraformBinary(namespace);
|
|
428
|
+
const dir = resolve(projectRoot, root.dir);
|
|
429
|
+
await ensureChoudoufuVersion(binary, signal);
|
|
430
|
+
const estate = binary === "choudoufu" ? detectLiveEstate(dir) : undefined;
|
|
431
|
+
// `estate` stays what it has always been: the estate the root's own HCL
|
|
432
|
+
// declares, which is what decides whether `-estate` may be passed at all.
|
|
433
|
+
// `live` is the broader question, and a root that names its estate in
|
|
434
|
+
// chant.config rather than in HCL (#2479) is just as live - that is the
|
|
435
|
+
// `-estate` flag form, choudoufu's own supported second pipeline.
|
|
436
|
+
const configEstate = binary === "choudoufu" ? root.estate : undefined;
|
|
437
|
+
return {
|
|
438
|
+
binary,
|
|
439
|
+
root,
|
|
440
|
+
dir,
|
|
441
|
+
...(estate !== undefined ? { estate } : {}),
|
|
442
|
+
live: estate !== undefined || configEstate !== undefined,
|
|
443
|
+
projectRoot,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
/** Run `cmd` in `dir`, forwarding the signal so an abort kills the child. */
|
|
447
|
+
async function run(cmd, dir, env, signal) {
|
|
448
|
+
return execAsync(cmd, { cwd: dir, env: { ...process.env, ...env }, signal, maxBuffer: MAX_BUFFER });
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Echo a subprocess's own output to the console for a human watching a
|
|
452
|
+
* `chant run` step. Both streams land on real stderr (`console.error`),
|
|
453
|
+
* never on real stdout (chant#2395): every activity here is reachable two
|
|
454
|
+
* ways — as a declared Op step, where a human is watching the terminal and
|
|
455
|
+
* stdout/stderr are both visible to them, and as an internal read a reader
|
|
456
|
+
* like `describeResources()` makes on a caller's behalf (`choudoufuLivePlan`,
|
|
457
|
+
* via `../../describe-resources.ts`), where the caller's own stdout is
|
|
458
|
+
* reserved for exactly one thing: the command's own JSON document, when one
|
|
459
|
+
* was asked for. Putting the subprocess's raw output on real stdout used to
|
|
460
|
+
* mean a caller running under `--json` (`chant lifecycle plan --live --json`,
|
|
461
|
+
* `chant components status --live --json`) got that raw `choudoufu
|
|
462
|
+
* live-plan -json` text landing on stdout ahead of chant's own document — two
|
|
463
|
+
* concatenated JSON values on one stream, which broke `convergeTick`'s bare
|
|
464
|
+
* `JSON.parse(stdout)` (`packages/core/src/op/activities/converge.ts`) with
|
|
465
|
+
* `Unexpected non-whitespace character after JSON at position …`. Stderr is
|
|
466
|
+
* still on the same terminal for an interactive `chant run`, so a human loses
|
|
467
|
+
* nothing; a `--json` caller now gets exactly one document on stdout.
|
|
468
|
+
*/
|
|
469
|
+
function report(stdout, stderr) {
|
|
470
|
+
if (stdout)
|
|
471
|
+
console.error(stdout);
|
|
472
|
+
if (stderr)
|
|
473
|
+
console.error(stderr);
|
|
474
|
+
}
|
|
475
|
+
// ── Activities ──────────────────────────────────────────────────────────────
|
|
476
|
+
/**
|
|
477
|
+
* `terraform init` in the named root, with the root's `backendConfig` supplied
|
|
478
|
+
* as `-backend-config` flags. Uses the longInfra profile: init downloads
|
|
479
|
+
* providers and modules, a network call of unpredictable size.
|
|
480
|
+
*/
|
|
481
|
+
export async function terraformInit(args, signal) {
|
|
482
|
+
const { binary, root, dir } = await resolveRoot(args, signal);
|
|
483
|
+
const cmd = terraformInitCommand({
|
|
484
|
+
binary,
|
|
485
|
+
...(root.backendConfig ? { backendConfig: root.backendConfig } : {}),
|
|
486
|
+
...(args.upgrade ? { upgrade: true } : {}),
|
|
487
|
+
...(args.reconfigure ? { reconfigure: true } : {}),
|
|
488
|
+
});
|
|
489
|
+
const { stdout, stderr } = await run(cmd, dir, terraformEnvironment(root), signal);
|
|
490
|
+
report(stdout, stderr);
|
|
491
|
+
return { dir, ...(root.workspace ? { workspace: root.workspace } : {}) };
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* `terraform plan -detailed-exitcode -out=<planFile>` in the named root, then
|
|
495
|
+
* `terraform show` over the written plan in both `-json` and `-no-color` form.
|
|
496
|
+
*
|
|
497
|
+
* The exit code is the answer, not an error condition: 0 is a plan with no
|
|
498
|
+
* changes, 2 is a plan with changes, and anything else, 1 included, is a
|
|
499
|
+
* failure thrown with terraform's own stderr attached. Both `show` renders
|
|
500
|
+
* come back with the result, so a gate, a report or an apply downstream reads
|
|
501
|
+
* the plan that ran instead of planning again against a moved world.
|
|
502
|
+
*
|
|
503
|
+
* Uses the longInfra profile: plan refreshes every resource against its
|
|
504
|
+
* provider.
|
|
505
|
+
*/
|
|
506
|
+
export async function terraformPlan(args, signal) {
|
|
507
|
+
const { binary, root, dir } = await resolveRoot(args, signal);
|
|
508
|
+
const planFile = args.planFile ?? DEFAULT_PLAN_FILE;
|
|
509
|
+
const env = terraformEnvironment(root);
|
|
510
|
+
const cmd = terraformPlanCommand({
|
|
511
|
+
binary,
|
|
512
|
+
planFile,
|
|
513
|
+
...(root.varFiles ? { varFiles: root.varFiles } : {}),
|
|
514
|
+
...(args.vars ? { vars: args.vars } : {}),
|
|
515
|
+
...(args.destroy ? { destroy: true } : {}),
|
|
516
|
+
});
|
|
517
|
+
let changed;
|
|
518
|
+
try {
|
|
519
|
+
const { stdout, stderr } = await run(cmd, dir, env, signal);
|
|
520
|
+
report(stdout, stderr);
|
|
521
|
+
changed = false;
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
// An abort or a spawn failure carries no numeric exit code. That is not
|
|
525
|
+
// terraform answering, so it propagates untouched.
|
|
526
|
+
const failure = err;
|
|
527
|
+
if (typeof failure.code !== "number")
|
|
528
|
+
throw err;
|
|
529
|
+
if (failure.code !== 2) {
|
|
530
|
+
const detail = (failure.stderr ?? "").trim() || (failure.stdout ?? "").trim();
|
|
531
|
+
throw new Error(`${binary} plan failed in ${dir} (exit ${failure.code})${detail ? `\n${detail}` : ""}`);
|
|
532
|
+
}
|
|
533
|
+
report(failure.stdout ?? "", failure.stderr ?? "");
|
|
534
|
+
changed = true;
|
|
535
|
+
}
|
|
536
|
+
const jsonRun = await run(terraformShowCommand({ binary, json: true, planFile }), dir, env, signal);
|
|
537
|
+
const textRun = await run(terraformShowCommand({ binary, json: false, planFile }), dir, env, signal);
|
|
538
|
+
const json = JSON.parse(jsonRun.stdout);
|
|
539
|
+
return {
|
|
540
|
+
changed,
|
|
541
|
+
planFile,
|
|
542
|
+
dir,
|
|
543
|
+
json,
|
|
544
|
+
text: textRun.stdout,
|
|
545
|
+
planDigest: terraformPlanDigest(json),
|
|
546
|
+
changeSet: terraformChangeSetPart({
|
|
547
|
+
member: args.root,
|
|
548
|
+
plan: json,
|
|
549
|
+
planner: plannerForBinary(binary),
|
|
550
|
+
...((root.estate ?? root.workspace) ? { scope: root.estate ?? root.workspace } : {}),
|
|
551
|
+
}),
|
|
552
|
+
...countPlanChanges(json),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Which of choudoufu's two approval refusals an exit-3 apply printed, or
|
|
557
|
+
* `undefined` when the output carries neither summary line. Reads both the
|
|
558
|
+
* stderr the diagnostic is written to and the stdout the fresh plan is
|
|
559
|
+
* rendered on, since the two travel separately.
|
|
560
|
+
*/
|
|
561
|
+
export function classifyApprovalRefusal(output) {
|
|
562
|
+
if (output.includes(CHOUDOUFU_WRONG_ESTATE_REFUSAL))
|
|
563
|
+
return "wrong-estate";
|
|
564
|
+
if (output.includes(CHOUDOUFU_APPROVAL_MISMATCH_REFUSAL))
|
|
565
|
+
return "approval-mismatch";
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* `terraform apply <planFile>` in the named root, on a stock root and on a
|
|
570
|
+
* live one alike.
|
|
571
|
+
*
|
|
572
|
+
* Neither kind has a bare-apply mode here: without a saved `planFile`, apply
|
|
573
|
+
* re-plans at apply time and acts on something no gate ever saw, so a missing
|
|
574
|
+
* one is refused before the CLI runs. choudoufu v0.13.0 is what makes the
|
|
575
|
+
* live half of that true (choudoufu #878, PR 889): `plan -out` is accepted
|
|
576
|
+
* under a `live` block, and `apply <planfile>` re-plans against the live
|
|
577
|
+
* system, compares its fresh plan with the file's down to the planned values,
|
|
578
|
+
* and applies only when they agree.
|
|
579
|
+
*
|
|
580
|
+
* When they do not, choudoufu exits **3**, neither 1 (any ordinary failure)
|
|
581
|
+
* nor `-detailed-exitcode`'s 2, with one of two named refusals. That is an
|
|
582
|
+
* answer rather than a breakage, so it comes back as a result (`refused`,
|
|
583
|
+
* `refusal`) instead of a thrown error, and a workflow can route the run back
|
|
584
|
+
* to review. Exit 3 is read this way only on a live root; on a stock root
|
|
585
|
+
* nothing produces it and every non-zero exit stays a failure.
|
|
586
|
+
*
|
|
587
|
+
* Uses the longInfra profile.
|
|
588
|
+
*/
|
|
589
|
+
export async function terraformApply(args, signal) {
|
|
590
|
+
const { binary, root, dir, live } = await resolveRoot(args, signal);
|
|
591
|
+
const hasPlanFile = typeof args.planFile === "string" && args.planFile.trim() !== "";
|
|
592
|
+
if (!hasPlanFile) {
|
|
593
|
+
throw new Error("terraformApply: planFile is required — this activity applies a saved plan and has no bare-apply mode. " +
|
|
594
|
+
"Pass the Plan step's own output (`plan.out.planFile`).");
|
|
595
|
+
}
|
|
596
|
+
const planFile = args.planFile;
|
|
597
|
+
const cmd = terraformApplyCommand({ binary, planFile });
|
|
598
|
+
try {
|
|
599
|
+
const { stdout, stderr } = await run(cmd, dir, terraformEnvironment(root), signal);
|
|
600
|
+
report(stdout, stderr);
|
|
601
|
+
return { planFile, dir, applied: true };
|
|
602
|
+
}
|
|
603
|
+
catch (err) {
|
|
604
|
+
// An abort or a spawn failure carries no numeric exit code. That is not
|
|
605
|
+
// the CLI answering, so it propagates untouched, same as terraformPlan.
|
|
606
|
+
const failure = err;
|
|
607
|
+
const refused = live && failure.code === CHOUDOUFU_APPROVAL_EXIT_CODE
|
|
608
|
+
? classifyApprovalRefusal(`${failure.stderr ?? ""}\n${failure.stdout ?? ""}`)
|
|
609
|
+
: undefined;
|
|
610
|
+
if (refused === undefined)
|
|
611
|
+
throw err;
|
|
612
|
+
report(failure.stdout ?? "", failure.stderr ?? "");
|
|
613
|
+
return {
|
|
614
|
+
planFile,
|
|
615
|
+
dir,
|
|
616
|
+
applied: false,
|
|
617
|
+
refused,
|
|
618
|
+
refusal: (failure.stderr ?? "").trim() || (failure.stdout ?? "").trim(),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* `terraform output -json` in the named root, as `{ name: value }` (#3049).
|
|
624
|
+
* A sensitive output's value comes back like any other, since the caller
|
|
625
|
+
* asked for the values to hand them on.
|
|
626
|
+
*/
|
|
627
|
+
export async function terraformOutputs(args, signal) {
|
|
628
|
+
const { binary, root, dir } = await resolveRoot(args, signal);
|
|
629
|
+
const { stdout } = await run(`${binary} output -json`, dir, terraformEnvironment(root), signal);
|
|
630
|
+
const parsed = JSON.parse(stdout || "{}");
|
|
631
|
+
return Object.fromEntries(Object.entries(parsed).map(([name, entry]) => [name, entry?.value]));
|
|
632
|
+
}
|
|
633
|
+
// ── choudoufu gated waves (#3459) ───────────────────────────────────────────
|
|
634
|
+
/**
|
|
635
|
+
* `live-plan-set -json` over one root, from the project root, so the
|
|
636
|
+
* document names the root by its path from there, which is what
|
|
637
|
+
* `live-wave-apply` resolves it against.
|
|
638
|
+
*/
|
|
639
|
+
export function choudoufuPlanSetCommand(opts) {
|
|
640
|
+
return [opts.binary, "live-plan-set", "-json", `-out-dir=${quoteArg(opts.outDir)}`, quoteArg(opts.root)].join(" ");
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* `live-wave-apply` over a one-root set. The set chant hands choudoufu is
|
|
644
|
+
* one root, so choudoufu's waves over it are a single wave and `-wave=1` is
|
|
645
|
+
* always that root's. chant's waves are the order; choudoufu's command is
|
|
646
|
+
* the apply, with its set check and its resume file.
|
|
647
|
+
*/
|
|
648
|
+
export function choudoufuWaveApplyCommand(opts) {
|
|
649
|
+
return [
|
|
650
|
+
opts.binary,
|
|
651
|
+
"live-wave-apply",
|
|
652
|
+
`-plan-set=${quoteArg(opts.planSet)}`,
|
|
653
|
+
`-digest=${quoteArg(opts.digest)}`,
|
|
654
|
+
"-wave=1",
|
|
655
|
+
`-resume=${quoteArg(opts.resume)}`,
|
|
656
|
+
`-out-dir=${quoteArg(opts.outDir)}`,
|
|
657
|
+
"-json",
|
|
658
|
+
].join(" ");
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* The root's directory as a set plan document names it: its path from the
|
|
662
|
+
* project root, with forward slashes. `undefined` when the root is the
|
|
663
|
+
* project root itself or outside it, since choudoufu takes only roots inside
|
|
664
|
+
* the working directory.
|
|
665
|
+
*/
|
|
666
|
+
export function choudoufuSetRootPath(projectRoot, dir) {
|
|
667
|
+
const rel = relative(projectRoot, dir);
|
|
668
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || resolve(projectRoot, rel) !== resolve(dir))
|
|
669
|
+
return undefined;
|
|
670
|
+
return rel.split(sep).join("/");
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Whether a gated wave runs this root through choudoufu's own set commands
|
|
674
|
+
* (#3459), and if not, why not. It does for a live choudoufu root that
|
|
675
|
+
* `live-plan-set` can plan as chant would: those commands run plain `init`
|
|
676
|
+
* and `plan` in each root, with no `-var-file`, no `-var` and no
|
|
677
|
+
* `-backend-config`, so a root that needs any of them goes the `plan -out`
|
|
678
|
+
* and `apply PLANFILE` way instead, where choudoufu's per-root refusal
|
|
679
|
+
* (choudoufu #878) still stands in front of the apply.
|
|
680
|
+
*/
|
|
681
|
+
export async function choudoufuWaveRoot(args, signal) {
|
|
682
|
+
const resolved = await resolveRoot(args, signal);
|
|
683
|
+
if (resolved.binary !== "choudoufu")
|
|
684
|
+
return { wave: false, reason: `the root runs ${resolved.binary}` };
|
|
685
|
+
if (!resolved.live)
|
|
686
|
+
return { wave: false, reason: "the root declares no live estate" };
|
|
687
|
+
if (args.vars && Object.values(args.vars).some((v) => v !== undefined)) {
|
|
688
|
+
return { wave: false, reason: "the step passes -var values, which live-plan-set does not take" };
|
|
689
|
+
}
|
|
690
|
+
if (resolved.root.varFiles && resolved.root.varFiles.length > 0) {
|
|
691
|
+
return { wave: false, reason: "the root has var files, which live-plan-set does not take" };
|
|
692
|
+
}
|
|
693
|
+
if (resolved.root.backendConfig && Object.keys(resolved.root.backendConfig).length > 0) {
|
|
694
|
+
return { wave: false, reason: "the root has backend config, which live-plan-set does not take" };
|
|
695
|
+
}
|
|
696
|
+
const root = choudoufuSetRootPath(resolved.projectRoot, resolved.dir);
|
|
697
|
+
if (root === undefined)
|
|
698
|
+
return { wave: false, reason: "the root's directory is not inside the project" };
|
|
699
|
+
return { wave: true, root };
|
|
700
|
+
}
|
|
701
|
+
const isObjectValue = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
702
|
+
/**
|
|
703
|
+
* Read `live-plan-set -json`'s document for one root (#3459). Refuses a
|
|
704
|
+
* document with no set digest: that choudoufu predates choudoufu#1754, and
|
|
705
|
+
* this is how a dev build, which names no release, is held to
|
|
706
|
+
* {@link MIN_CHOUDOUFU_WAVES_VERSION}. Refuses a root that did not plan,
|
|
707
|
+
* with choudoufu's own error.
|
|
708
|
+
*/
|
|
709
|
+
export function parseChoudoufuPlanSet(stdout, opts) {
|
|
710
|
+
let doc;
|
|
711
|
+
try {
|
|
712
|
+
doc = JSON.parse(stdout);
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
throw new Error(`choudoufu live-plan-set printed no JSON document for root "${opts.member}"`);
|
|
716
|
+
}
|
|
717
|
+
if (!isObjectValue(doc) || !Array.isArray(doc.roots)) {
|
|
718
|
+
throw new Error(`choudoufu live-plan-set printed no set plan document for root "${opts.member}"`);
|
|
719
|
+
}
|
|
720
|
+
if (typeof doc.digest !== "string" || doc.digest === "") {
|
|
721
|
+
throw new Error(`choudoufu's set plan document for root "${opts.member}" carries no set digest, so this choudoufu predates ` +
|
|
722
|
+
`choudoufu#1754. A gated wave over a choudoufu root needs v${MIN_CHOUDOUFU_WAVES_VERSION} or later.`);
|
|
723
|
+
}
|
|
724
|
+
const roots = doc.roots.filter(isObjectValue);
|
|
725
|
+
const entry = roots.find((r) => r.root === opts.root);
|
|
726
|
+
if (roots.length !== 1 || !entry) {
|
|
727
|
+
throw new Error(`choudoufu's set plan document for root "${opts.member}" names ${roots.map((r) => String(r.root)).join(", ") || "no root"}, ` +
|
|
728
|
+
`not only "${opts.root}"`);
|
|
729
|
+
}
|
|
730
|
+
if (entry.status !== "planned" || !isObjectValue(entry.plan)) {
|
|
731
|
+
const stage = typeof entry.stage === "string" && entry.stage ? ` at ${entry.stage}` : "";
|
|
732
|
+
const error = typeof entry.error === "string" && entry.error ? `: ${entry.error}` : "";
|
|
733
|
+
throw new Error(`choudoufu could not plan root "${opts.member}"${stage}${error}`);
|
|
734
|
+
}
|
|
735
|
+
if (typeof entry.digest !== "string" || entry.digest === "") {
|
|
736
|
+
throw new Error(`choudoufu's set plan document gives root "${opts.member}" no digest`);
|
|
737
|
+
}
|
|
738
|
+
const [changeSet] = choudoufuSetPlanParts({ document: doc, memberFor: () => opts.member });
|
|
739
|
+
return {
|
|
740
|
+
setDigest: doc.digest,
|
|
741
|
+
rootDigest: entry.digest,
|
|
742
|
+
planDigest: terraformPlanDigest(entry.plan),
|
|
743
|
+
changed: entry.changes === true,
|
|
744
|
+
json: entry.plan,
|
|
745
|
+
changeSet: changeSet,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
/** The directory a root's wave files go in, absolute. */
|
|
749
|
+
function choudoufuWavesDir(projectRoot, root) {
|
|
750
|
+
return join(projectRoot, CHOUDOUFU_WAVES_DIR, root.replace(/[^A-Za-z0-9._-]/g, "_"));
|
|
751
|
+
}
|
|
752
|
+
/** A path under the project root as choudoufu, run from there, should be given it. */
|
|
753
|
+
function fromProject(projectRoot, path) {
|
|
754
|
+
return relative(projectRoot, path).split(sep).join("/");
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Plan one live choudoufu root as a one-root set: `live-plan-set -json` from
|
|
758
|
+
* the project root (#3459). The document goes to
|
|
759
|
+
* `<CHOUDOUFU_WAVES_DIR>/<root>/plan-set.json`, the file a later
|
|
760
|
+
* `live-wave-apply` hands back to choudoufu with the set digest.
|
|
761
|
+
*/
|
|
762
|
+
export async function choudoufuPlanSet(args, signal) {
|
|
763
|
+
const resolved = await resolveRoot(args, signal);
|
|
764
|
+
const { binary, root, dir, projectRoot } = resolved;
|
|
765
|
+
if (binary !== "choudoufu")
|
|
766
|
+
throw new Error(`choudoufuPlanSet: root "${args.root}" runs ${binary}, not choudoufu`);
|
|
767
|
+
await ensureChoudoufuWavesVersion(binary, signal);
|
|
768
|
+
const rootPath = choudoufuSetRootPath(projectRoot, dir);
|
|
769
|
+
if (rootPath === undefined)
|
|
770
|
+
throw new Error(`choudoufuPlanSet: root "${args.root}" (${dir}) is not inside ${projectRoot}`);
|
|
771
|
+
const wavesDir = choudoufuWavesDir(projectRoot, args.root);
|
|
772
|
+
mkdirSync(wavesDir, { recursive: true });
|
|
773
|
+
const cmd = choudoufuPlanSetCommand({ binary, root: rootPath, outDir: fromProject(projectRoot, join(wavesDir, "out")) });
|
|
774
|
+
let stdout;
|
|
775
|
+
try {
|
|
776
|
+
const out = await run(cmd, projectRoot, terraformEnvironment(root), signal);
|
|
777
|
+
report("", out.stderr);
|
|
778
|
+
stdout = out.stdout;
|
|
779
|
+
}
|
|
780
|
+
catch (err) {
|
|
781
|
+
// 2 is "planned, with changes" and 4 is "a root failed", which the
|
|
782
|
+
// document itself explains. Anything else is the command not running.
|
|
783
|
+
const failure = err;
|
|
784
|
+
if (failure.code !== 2 && failure.code !== 4)
|
|
785
|
+
throw err;
|
|
786
|
+
report("", failure.stderr ?? "");
|
|
787
|
+
stdout = failure.stdout ?? "";
|
|
788
|
+
}
|
|
789
|
+
const parsed = parseChoudoufuPlanSet(stdout, { member: args.root, root: rootPath });
|
|
790
|
+
const planSetFile = join(wavesDir, "plan-set.json");
|
|
791
|
+
writeFileSync(planSetFile, stdout);
|
|
792
|
+
return { ...parsed, planSetFile, root: rootPath };
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* The resume file to hand choudoufu for `setDigest`, from what an earlier
|
|
796
|
+
* attempt carried, or `undefined`. choudoufu refuses a resume file written
|
|
797
|
+
* for another digest, and a re-planned root with a moved plan has another
|
|
798
|
+
* digest, so only a file for this same set comes back.
|
|
799
|
+
*/
|
|
800
|
+
export function carriedChoudoufuResume(carried, setDigest) {
|
|
801
|
+
if (!isObjectValue(carried) || carried.kind !== "choudoufu-wave-resume")
|
|
802
|
+
return undefined;
|
|
803
|
+
if (carried.setDigest !== setDigest || !isObjectValue(carried.resume))
|
|
804
|
+
return undefined;
|
|
805
|
+
return carried.resume.set_digest === setDigest ? carried.resume : undefined;
|
|
806
|
+
}
|
|
807
|
+
/** One line for each of `live-wave-apply`'s exit statuses, with choudoufu's own error when it gave one. */
|
|
808
|
+
export function describeChoudoufuWaveExit(exitCode, result, stderr) {
|
|
809
|
+
const error = isObjectValue(result) && typeof result.error === "string" && result.error ? result.error : "";
|
|
810
|
+
const moved = isObjectValue(result) && Array.isArray(result.moved)
|
|
811
|
+
? result.moved
|
|
812
|
+
.filter(isObjectValue)
|
|
813
|
+
.map((m) => `${String(m.root)} moved: approved ${String(m.approved_digest)}, now ${String(m.fresh_digest)}`)
|
|
814
|
+
: [];
|
|
815
|
+
const detail = error || moved.join("; ") || stderr.trim().split("\n").find((l) => l.trim() !== "")?.trim() || "";
|
|
816
|
+
const suffix = detail ? ` (${detail})` : "";
|
|
817
|
+
switch (exitCode) {
|
|
818
|
+
case CHOUDOUFU_WAVE_EXIT.landed:
|
|
819
|
+
return "landed";
|
|
820
|
+
case CHOUDOUFU_WAVE_EXIT.setMoved:
|
|
821
|
+
return `choudoufu refused the approved set: the root's plan moved since it was approved, and nothing was applied${suffix}`;
|
|
822
|
+
case CHOUDOUFU_WAVE_EXIT.rootFailed:
|
|
823
|
+
return `choudoufu could not land the root; its resume file records why${suffix}`;
|
|
824
|
+
default:
|
|
825
|
+
return `choudoufu live-wave-apply could not run (exit ${exitCode})${suffix}`;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Apply one live choudoufu root through `live-wave-apply` (#3459), with the
|
|
830
|
+
* set plan document and set digest {@link choudoufuPlanSet} produced. When
|
|
831
|
+
* the root carried a resume file for this same digest from an earlier
|
|
832
|
+
* attempt, it is written back first, so choudoufu sees what it already
|
|
833
|
+
* recorded. Its resume file after the run comes back as `carry`, landed or
|
|
834
|
+
* not.
|
|
835
|
+
*
|
|
836
|
+
* Never throws for a choudoufu answer: exit 3 (the set moved) and exit 4 (the
|
|
837
|
+
* root failed) come back with `landed: false`, and so does exit 1. An abort
|
|
838
|
+
* or a spawn failure propagates.
|
|
839
|
+
*/
|
|
840
|
+
export async function choudoufuWaveApply(args, signal) {
|
|
841
|
+
const resolved = await resolveRoot(args, signal);
|
|
842
|
+
const { binary, root, projectRoot } = resolved;
|
|
843
|
+
if (binary !== "choudoufu")
|
|
844
|
+
throw new Error(`choudoufuWaveApply: root "${args.root}" runs ${binary}, not choudoufu`);
|
|
845
|
+
await ensureChoudoufuWavesVersion(binary, signal);
|
|
846
|
+
const wavesDir = choudoufuWavesDir(projectRoot, args.root);
|
|
847
|
+
mkdirSync(wavesDir, { recursive: true });
|
|
848
|
+
// One file per digest: choudoufu refuses a resume file written for
|
|
849
|
+
// another, and a file left by an earlier set must not block this one.
|
|
850
|
+
const resumeFile = join(wavesDir, `resume-${args.setDigest.replace(/^sha256:/, "").slice(0, 16)}.json`);
|
|
851
|
+
const carried = carriedChoudoufuResume(args.carried, args.setDigest);
|
|
852
|
+
if (carried !== undefined)
|
|
853
|
+
writeFileSync(resumeFile, JSON.stringify(carried, null, 2) + "\n");
|
|
854
|
+
const cmd = choudoufuWaveApplyCommand({
|
|
855
|
+
binary,
|
|
856
|
+
planSet: fromProject(projectRoot, args.planSetFile),
|
|
857
|
+
digest: args.setDigest,
|
|
858
|
+
resume: fromProject(projectRoot, resumeFile),
|
|
859
|
+
outDir: fromProject(projectRoot, join(wavesDir, "out")),
|
|
860
|
+
});
|
|
861
|
+
let exitCode = 0;
|
|
862
|
+
let stdout = "";
|
|
863
|
+
let stderr = "";
|
|
864
|
+
try {
|
|
865
|
+
const out = await run(cmd, projectRoot, terraformEnvironment(root), signal);
|
|
866
|
+
stdout = out.stdout;
|
|
867
|
+
stderr = out.stderr;
|
|
868
|
+
}
|
|
869
|
+
catch (err) {
|
|
870
|
+
const failure = err;
|
|
871
|
+
if (typeof failure.code !== "number")
|
|
872
|
+
throw err;
|
|
873
|
+
exitCode = failure.code;
|
|
874
|
+
stdout = failure.stdout ?? "";
|
|
875
|
+
stderr = failure.stderr ?? "";
|
|
876
|
+
}
|
|
877
|
+
report(stdout, stderr);
|
|
878
|
+
let result;
|
|
879
|
+
try {
|
|
880
|
+
result = stdout.trim() ? JSON.parse(stdout) : undefined;
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
result = undefined;
|
|
884
|
+
}
|
|
885
|
+
let carry;
|
|
886
|
+
if (existsSync(resumeFile)) {
|
|
887
|
+
try {
|
|
888
|
+
carry = { kind: "choudoufu-wave-resume", setDigest: args.setDigest, resume: JSON.parse(readFileSync(resumeFile, "utf8")) };
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
carry = undefined;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return {
|
|
895
|
+
exitCode,
|
|
896
|
+
landed: exitCode === CHOUDOUFU_WAVE_EXIT.landed,
|
|
897
|
+
...(result !== undefined ? { result } : {}),
|
|
898
|
+
...(carry ? { carry } : {}),
|
|
899
|
+
message: describeChoudoufuWaveExit(exitCode, result, stderr),
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* `terraform show` over current state, or over a saved plan when `planFile` is
|
|
904
|
+
* given. Returns the parsed `-json` output and the `-no-color` render, plus
|
|
905
|
+
* the add/change/destroy counts when the subject is a plan. State has no
|
|
906
|
+
* change set, so for state the three counts are zero.
|
|
907
|
+
*
|
|
908
|
+
* Uses the fastIdempotent profile: show reads an artifact and calls no
|
|
909
|
+
* provider.
|
|
910
|
+
*/
|
|
911
|
+
export async function terraformShow(args, signal) {
|
|
912
|
+
const { binary, root, dir } = await resolveRoot(args, signal);
|
|
913
|
+
const env = terraformEnvironment(root);
|
|
914
|
+
const planFile = args.planFile;
|
|
915
|
+
const jsonRun = await run(terraformShowCommand({ binary, json: true, ...(planFile ? { planFile } : {}) }), dir, env, signal);
|
|
916
|
+
const textRun = await run(terraformShowCommand({ binary, json: false, ...(planFile ? { planFile } : {}) }), dir, env, signal);
|
|
917
|
+
const json = JSON.parse(jsonRun.stdout);
|
|
918
|
+
return {
|
|
919
|
+
source: planFile ? "plan" : "state",
|
|
920
|
+
json,
|
|
921
|
+
text: textRun.stdout,
|
|
922
|
+
dir,
|
|
923
|
+
...(planFile ? { planFile } : {}),
|
|
924
|
+
...(planFile ? countPlanChanges(json) : { adds: 0, changes: 0, destroys: 0 }),
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* The estate a choudoufu-only activity runs against, in precedence order:
|
|
929
|
+
* `args.estate` when the step names one, then the root's own `estate`
|
|
930
|
+
* (#2479), then the one {@link resolveRoot} auto-detected from the root's
|
|
931
|
+
* `live` block or `estate.chdf.hcl` sidecar. Throws when none of the three
|
|
932
|
+
* names one, since `live-plan`/`live-ls` need an estate to filter on, unlike
|
|
933
|
+
* a stock command.
|
|
934
|
+
*
|
|
935
|
+
* The order is step, then root, then declaration - narrowest first. A root's
|
|
936
|
+
* `estate` and a declared one cannot both be in play on a healthy project:
|
|
937
|
+
* TF028 refuses that pair at build time, for the same reason choudoufu
|
|
938
|
+
* refuses `-estate` beside a `live` block. The precedence here is what
|
|
939
|
+
* happens if that refusal is ever bypassed, and it deliberately lets the
|
|
940
|
+
* declaration win, because the declaration is the thing choudoufu itself
|
|
941
|
+
* will act on.
|
|
942
|
+
*/
|
|
943
|
+
function resolveEstate(args, resolved, activity) {
|
|
944
|
+
const estate = args.estate ?? resolved.estate ?? resolved.root.estate;
|
|
945
|
+
if (!estate) {
|
|
946
|
+
throw new Error(`${activity}: no estate to run against. Declare one in the root's \`live\` block or ` +
|
|
947
|
+
"`estate.chdf.hcl` sidecar, give the root an `estate` in chant.config, or pass `estate` explicitly.");
|
|
948
|
+
}
|
|
949
|
+
return estate;
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* `choudoufu live-plan -detailed-exitcode -json -estate=<estate>`, plus a
|
|
953
|
+
* second `live-plan` without `-json` for the human-readable render. GitHub
|
|
954
|
+
* issue #788's JSON document carries no render of the plan itself
|
|
955
|
+
* (`views.LivePlanDocument` has no such field), so unlike `terraformPlan`'s
|
|
956
|
+
* single saved artifact this genuinely needs two live reads.
|
|
957
|
+
*
|
|
958
|
+
* The exit code is the answer, exactly as `terraformPlan`'s is: 0 is no
|
|
959
|
+
* drift, 2 is drift, and anything else is a failure thrown with choudoufu's
|
|
960
|
+
* own stderr attached. The JSON document is written to `documentPath` (under
|
|
961
|
+
* `dir`) for a later step or reviewer to open directly.
|
|
962
|
+
*
|
|
963
|
+
* Uses the longInfra profile: like `terraformPlan`, this reads the live
|
|
964
|
+
* system in full (the estate-wide sweep).
|
|
965
|
+
*/
|
|
966
|
+
/**
|
|
967
|
+
* The JSON document within a `live-plan -json` stdout: everything from the
|
|
968
|
+
* first line that is a bare `{` to the end. Returns the input unchanged when
|
|
969
|
+
* no such line exists.
|
|
970
|
+
*
|
|
971
|
+
* Defensive, and kept deliberately. choudoufu v0.14.0 keeps `-json` stdout to
|
|
972
|
+
* the document on every form of live root, so on a supported binary this
|
|
973
|
+
* returns its input unchanged and the slice never fires: it was written for
|
|
974
|
+
* the `-estate` path's refresh progress lines
|
|
975
|
+
* ([choudoufu #894](https://github.com/INTENTIUS/choudoufu/issues/894), fixed
|
|
976
|
+
* in PR 915, which routes every human-prose renderer of a `-json` run through
|
|
977
|
+
* `views.View.StdoutOnStderr()`). What it buys is that a future writer put
|
|
978
|
+
* back on stdout ahead of the document costs a stale slice rather than a
|
|
979
|
+
* `JSON.parse` failure, and a stdout carrying no document at all still fails
|
|
980
|
+
* in `JSON.parse` with the real text in the error.
|
|
981
|
+
*/
|
|
982
|
+
export function liveDocumentFrom(stdout) {
|
|
983
|
+
const lines = stdout.split("\n");
|
|
984
|
+
const start = lines.findIndex((line) => line.trim() === "{");
|
|
985
|
+
return start === -1 ? stdout : lines.slice(start).join("\n");
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* `live-plan -detailed-exitcode -json` alone: the document, without the
|
|
989
|
+
* second run that renders the plan for a human (chant #2498).
|
|
990
|
+
*
|
|
991
|
+
* Two of {@link choudoufuLivePlan}'s three callers never read `text`:
|
|
992
|
+
* `describeResources()` indexes the document and `predictBehaviour()` prices
|
|
993
|
+
* it. Each was paying for the render anyway, and on a drifted estate whose
|
|
994
|
+
* provider retries a deleted resource the render costs as much as the
|
|
995
|
+
* document. This is the read they make instead.
|
|
996
|
+
*
|
|
997
|
+
* Inside a `withLiveReadSession` scope (core's `live-read-session.ts`) the
|
|
998
|
+
* run is shared with every identical read in the same command, keyed on
|
|
999
|
+
* the root, its resolved directory, the binary, the estate, the flags and
|
|
1000
|
+
* the root's environment. The graph command runs in such a scope, so its
|
|
1001
|
+
* observation and its prediction see one `live-plan` between them, and the
|
|
1002
|
+
* graph's statuses and the prediction's figures are statements about the
|
|
1003
|
+
* same read. The document file is still written on every call.
|
|
1004
|
+
*/
|
|
1005
|
+
export async function choudoufuLivePlanDocument(args, signal) {
|
|
1006
|
+
const resolved = await resolveRoot(args, signal);
|
|
1007
|
+
const { binary, dir } = resolved;
|
|
1008
|
+
const estate = resolveEstate(args, resolved, "choudoufuLivePlan");
|
|
1009
|
+
const env = terraformEnvironment(resolved.root);
|
|
1010
|
+
const documentPath = args.documentPath ?? DEFAULT_LIVE_PLAN_DOCUMENT_FILE;
|
|
1011
|
+
// A root that declares its own estate is run without `-estate`: choudoufu
|
|
1012
|
+
// refuses the flag beside a declaration and, since v0.14.0, settles the name
|
|
1013
|
+
// from the declaration itself. See {@link choudoufuLivePlanCommand}.
|
|
1014
|
+
const estateFlag = resolved.estate === undefined ? { estate } : {};
|
|
1015
|
+
const planCmd = choudoufuLivePlanCommand({ binary, ...estateFlag, json: true });
|
|
1016
|
+
// The document's `adoptable` and `swept` sections are populated only on a
|
|
1017
|
+
// run that asked the estate-wide sweep the account-bounded question, which
|
|
1018
|
+
// `-adoption-only` implies and a `-json` run does not
|
|
1019
|
+
// (`internal/command/live_collect_unclaimed.go`). `-adoption-only` cannot
|
|
1020
|
+
// ride the `-json` invocation, so the env var is how a document-producing
|
|
1021
|
+
// run asks for the same thing. Set only under `adoptionOnly`: the sweep is
|
|
1022
|
+
// an account-wide list per admitted type, and an observation read has no use
|
|
1023
|
+
// for it.
|
|
1024
|
+
const adoptionOnly = args.adoptionOnly === true;
|
|
1025
|
+
const planEnv = adoptionOnly ? { ...env, TOFU_LIVE_COLLECT_UNCLAIMED: "1" } : env;
|
|
1026
|
+
const key = liveReadKey("live-plan", { root: args.root, dir, binary, estate, adoptionOnly, env });
|
|
1027
|
+
const { drift, jsonStdout } = await memoLiveRead(key, async () => {
|
|
1028
|
+
try {
|
|
1029
|
+
const { stdout, stderr } = await run(planCmd, dir, planEnv, signal);
|
|
1030
|
+
report(stdout, stderr);
|
|
1031
|
+
return { drift: false, jsonStdout: stdout };
|
|
1032
|
+
}
|
|
1033
|
+
catch (err) {
|
|
1034
|
+
const failure = err;
|
|
1035
|
+
if (typeof failure.code !== "number")
|
|
1036
|
+
throw err;
|
|
1037
|
+
if (failure.code !== 2) {
|
|
1038
|
+
const detail = (failure.stderr ?? "").trim() || (failure.stdout ?? "").trim();
|
|
1039
|
+
throw new Error(`${binary} live-plan failed in ${dir} (exit ${failure.code})${detail ? `\n${detail}` : ""}`);
|
|
1040
|
+
}
|
|
1041
|
+
report(failure.stdout ?? "", failure.stderr ?? "");
|
|
1042
|
+
return { drift: true, jsonStdout: failure.stdout ?? "" };
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
// Belt and braces: v0.14.0 keeps `-json` stdout to the document, so this is
|
|
1046
|
+
// the identity on a supported binary. See {@link liveDocumentFrom}.
|
|
1047
|
+
const document = liveDocumentFrom(jsonStdout);
|
|
1048
|
+
const json = JSON.parse(document);
|
|
1049
|
+
writeFileSync(join(dir, documentPath), document);
|
|
1050
|
+
return { drift, json, dir, documentPath, estate, ...countLivePlanUnowned(json) };
|
|
1051
|
+
}
|
|
1052
|
+
export async function choudoufuLivePlan(args, signal) {
|
|
1053
|
+
const { drift, json, dir, documentPath, estate, ...unowned } = await choudoufuLivePlanDocument(args, signal);
|
|
1054
|
+
const resolved = await resolveRoot(args, signal);
|
|
1055
|
+
const { binary } = resolved;
|
|
1056
|
+
const env = terraformEnvironment(resolved.root);
|
|
1057
|
+
const estateFlag = resolved.estate === undefined ? { estate } : {};
|
|
1058
|
+
// #788's document carries no render of the human plan itself
|
|
1059
|
+
// (`views.LivePlanDocument` has no such field), so the human text needs a
|
|
1060
|
+
// second live read rather than a field on the same response. `-adoption-only`
|
|
1061
|
+
// (#2105) changes which report that second read prints, never whether there
|
|
1062
|
+
// is one: choudoufu refuses the flag alongside `-json`, so the two reports
|
|
1063
|
+
// were always going to be two runs.
|
|
1064
|
+
const textCmd = choudoufuLivePlanCommand({
|
|
1065
|
+
binary,
|
|
1066
|
+
...estateFlag,
|
|
1067
|
+
json: false,
|
|
1068
|
+
noColor: true,
|
|
1069
|
+
...(args.adoptionOnly ? { adoptionOnly: true } : {}),
|
|
1070
|
+
});
|
|
1071
|
+
// The human render carries `-detailed-exitcode` too, so a plan with changes
|
|
1072
|
+
// comes back as exit 2 on this call exactly as it does on the `-json` one.
|
|
1073
|
+
// Both are success, and the drift answer is already settled above, so exit 2
|
|
1074
|
+
// here yields its stdout rather than throwing; anything else is a failure
|
|
1075
|
+
// with choudoufu's own output attached.
|
|
1076
|
+
let text;
|
|
1077
|
+
try {
|
|
1078
|
+
text = (await run(textCmd, dir, env, signal)).stdout;
|
|
1079
|
+
}
|
|
1080
|
+
catch (err) {
|
|
1081
|
+
const failure = err;
|
|
1082
|
+
if (failure.code !== 2) {
|
|
1083
|
+
const detail = (failure.stderr ?? "").trim() || (failure.stdout ?? "").trim();
|
|
1084
|
+
throw new Error(`${binary} live-plan (human render) failed in ${dir} (exit ${String(failure.code)})` +
|
|
1085
|
+
`${detail ? `\n${detail}` : ""}`);
|
|
1086
|
+
}
|
|
1087
|
+
text = failure.stdout ?? "";
|
|
1088
|
+
}
|
|
1089
|
+
// An `adoptable[]` row carries its own tagging command in the document
|
|
1090
|
+
// (choudoufu #962). An `unowned[]` row does not, and the render is the only
|
|
1091
|
+
// place one is printed for it, so the parse still runs on an adoption run
|
|
1092
|
+
// and the map still feeds that half. A candidate off an ordinary run carries
|
|
1093
|
+
// the two marker values and no command, which is the whole ownership
|
|
1094
|
+
// contract either way.
|
|
1095
|
+
const ledger = readAdoptionLedger(json, args.adoptionOnly ? parseAdoptionCommands(text) : undefined);
|
|
1096
|
+
const ledgerText = renderAdoptionLedger(ledger, estate);
|
|
1097
|
+
return {
|
|
1098
|
+
drift,
|
|
1099
|
+
json,
|
|
1100
|
+
text,
|
|
1101
|
+
ledger: ledgerText,
|
|
1102
|
+
finding: `${text.trimEnd()}\n\n${ledgerText}\n`,
|
|
1103
|
+
adoptions: ledger.adoptions,
|
|
1104
|
+
contested: ledger.contested,
|
|
1105
|
+
ambiguous: ledger.ambiguous,
|
|
1106
|
+
swept: ledger.swept,
|
|
1107
|
+
dir,
|
|
1108
|
+
documentPath,
|
|
1109
|
+
estate,
|
|
1110
|
+
...unowned,
|
|
1111
|
+
...parseChoudoufuPlanSummary(text),
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* `choudoufu live-ls -estate=<estate> -json [-consistent]`: every resource
|
|
1116
|
+
* the account holds under the estate, read straight from the Resource Groups
|
|
1117
|
+
* Tagging API. No configuration, state or record store is read.
|
|
1118
|
+
*
|
|
1119
|
+
* Uses the fastIdempotent profile: like `terraformShow`, this reads an
|
|
1120
|
+
* artifact-shaped answer and does not plan.
|
|
1121
|
+
*/
|
|
1122
|
+
export async function choudoufuLiveLs(args, signal) {
|
|
1123
|
+
const resolved = await resolveRoot(args, signal);
|
|
1124
|
+
const { binary, dir } = resolved;
|
|
1125
|
+
const estate = resolveEstate(args, resolved, "choudoufuLiveLs");
|
|
1126
|
+
const env = terraformEnvironment(resolved.root);
|
|
1127
|
+
const consistent = args.consistent === true;
|
|
1128
|
+
const cmd = choudoufuLiveLsCommand({ binary, estate, ...(consistent ? { consistent: true } : {}) });
|
|
1129
|
+
// Shared with every identical listing in the same read session, the way
|
|
1130
|
+
// {@link choudoufuLivePlanDocument} shares the document (chant #2498).
|
|
1131
|
+
const key = liveReadKey("live-ls", { root: args.root, dir, binary, estate, consistent, env });
|
|
1132
|
+
const stdout = await memoLiveRead(key, async () => {
|
|
1133
|
+
const result = await run(cmd, dir, env, signal);
|
|
1134
|
+
report(result.stdout, result.stderr);
|
|
1135
|
+
return result.stdout;
|
|
1136
|
+
});
|
|
1137
|
+
return { json: JSON.parse(stdout), dir, estate };
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Claim the adoptable matches an adoption ledger found, by writing the two
|
|
1141
|
+
* ownership markers onto each live resource (#2105).
|
|
1142
|
+
*
|
|
1143
|
+
* ## Why a tag write and not a policy verb
|
|
1144
|
+
*
|
|
1145
|
+
* choudoufu's `policy` block has an `adopt` verb for exactly this cell
|
|
1146
|
+
* (`declared_untagged`), and `site/content/docs/use/migrate.md` names
|
|
1147
|
+
* `policy { declared_untagged = "adopt" }` as the way to adopt a whole estate
|
|
1148
|
+
* at once instead of one resource at a time. That would have been the smaller
|
|
1149
|
+
* write: one apply, no tagging calls of chant's own. It is not reachable from
|
|
1150
|
+
* outside the configuration, and #2105 checked both ways in:
|
|
1151
|
+
*
|
|
1152
|
+
* - a `live` block in a terraform override file is dropped in silence.
|
|
1153
|
+
* `Module.mergeFile` (choudoufu's `internal/configs/module.go`) merges
|
|
1154
|
+
* backends, cloud blocks, providers and resources from an override file
|
|
1155
|
+
* and has no `Lives` case at all, so an `override.tf` carrying
|
|
1156
|
+
* `terraform { live { policy { declared_untagged = "adopt" } } }` changes
|
|
1157
|
+
* nothing. Measured, not inferred: a policy block with a deliberately
|
|
1158
|
+
* invalid verb in an override file passes `live-check` clean, and the same
|
|
1159
|
+
* block in a primary file is refused by name.
|
|
1160
|
+
* - a second primary file carrying one is refused outright: "A module may
|
|
1161
|
+
* have only one 'live' block." So is a sidecar beside an in-block form.
|
|
1162
|
+
*
|
|
1163
|
+
* Which leaves editing the root's own checked-in live configuration, running
|
|
1164
|
+
* an apply, and putting it back — a mutation of a reviewed file, with a
|
|
1165
|
+
* failure mode (a run that dies between the two edits) that leaves the estate
|
|
1166
|
+
* declaring "adopt everything declared and unmarked" for every later apply.
|
|
1167
|
+
* chant does not do that to a repository it does not own.
|
|
1168
|
+
*
|
|
1169
|
+
* So the mechanism is the tag write, which choudoufu's own migration guide
|
|
1170
|
+
* calls the whole contract: "There is no `choudoufu adopt` command and no need
|
|
1171
|
+
* for one. Two tags is the whole contract (`live/MARKERS.md`), so any tool
|
|
1172
|
+
* that writes two tags can adopt a resource." The command each write runs is
|
|
1173
|
+
* the paste-ready one choudoufu printed under `live-plan -adoption-only`,
|
|
1174
|
+
* carried on the candidate — built from choudoufu's own provider
|
|
1175
|
+
* configuration, so it lands on the region and endpoint the plan just read
|
|
1176
|
+
* rather than wherever an operator's AWS CLI profile points, and shell-quoted
|
|
1177
|
+
* by choudoufu for exactly this use.
|
|
1178
|
+
*
|
|
1179
|
+
* ## What it refuses
|
|
1180
|
+
*
|
|
1181
|
+
* A candidate with no printed command (a service whose tagging call this fork
|
|
1182
|
+
* does not spell out: IAM, Route53, S3) is refused with its two marker values
|
|
1183
|
+
* named, not guessed at. A contested address — more than one live resource at
|
|
1184
|
+
* one declared identity — is refused because no single tag write claims it;
|
|
1185
|
+
* `contested` is passed in so the Op's own result names what was refused
|
|
1186
|
+
* beside what was written, and nothing in this function can adopt one, since
|
|
1187
|
+
* `readAdoptionLedger` never puts a contested candidate in `adoptions`.
|
|
1188
|
+
*
|
|
1189
|
+
* Uses the longInfra profile: one tagging round trip per resource, against the
|
|
1190
|
+
* cloud.
|
|
1191
|
+
*/
|
|
1192
|
+
export async function choudoufuAdopt(args, signal) {
|
|
1193
|
+
const resolved = await resolveRoot(args, signal);
|
|
1194
|
+
const { dir } = resolved;
|
|
1195
|
+
const env = terraformEnvironment(resolved.root);
|
|
1196
|
+
const adoptions = args.adoptions ?? [];
|
|
1197
|
+
const contested = args.contested ?? [];
|
|
1198
|
+
const adopted = [];
|
|
1199
|
+
const refused = [];
|
|
1200
|
+
for (const candidate of contested) {
|
|
1201
|
+
refused.push({
|
|
1202
|
+
addr: candidate.addr,
|
|
1203
|
+
identity: candidate.identity,
|
|
1204
|
+
reason: "more than one live resource sits at this declared identity, so no single tag write claims it. " +
|
|
1205
|
+
`Delete the duplicate, or write tofu-estate=${candidate.markerEstate} ` +
|
|
1206
|
+
`tofu-address=${candidate.markerAddress} onto the one you mean.`,
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
for (const candidate of adoptions) {
|
|
1210
|
+
if (!candidate.command) {
|
|
1211
|
+
refused.push({
|
|
1212
|
+
addr: candidate.addr,
|
|
1213
|
+
identity: candidate.identity,
|
|
1214
|
+
reason: `${candidate.type} is tagged through its own service call, which choudoufu does not print a ` +
|
|
1215
|
+
`command for. Write tofu-estate=${candidate.markerEstate} ` +
|
|
1216
|
+
`tofu-address=${candidate.markerAddress} onto it with that call.`,
|
|
1217
|
+
});
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
const { stdout, stderr } = await run(candidate.command, dir, env, signal);
|
|
1221
|
+
report(stdout, stderr);
|
|
1222
|
+
adopted.push(candidate.addr);
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
mechanism: "tag-write",
|
|
1226
|
+
adopted,
|
|
1227
|
+
adoptedCount: adopted.length,
|
|
1228
|
+
refused,
|
|
1229
|
+
ambiguous: new Set(contested.map((c) => c.addr)).size,
|
|
1230
|
+
dir,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* `choudoufu live-check -json`: whether the configuration in this root can
|
|
1235
|
+
* move under live resource markers, and what stops it if it cannot. Makes no
|
|
1236
|
+
* cloud calls and needs no `live` block at all, since the check this activity
|
|
1237
|
+
* runs works on any configuration, which is why it needs no `estate`.
|
|
1238
|
+
*
|
|
1239
|
+
* A non-zero exit is a refusal choudoufu reports, not a failure of this
|
|
1240
|
+
* activity: `refused: true` carries it back rather than throwing. An abort
|
|
1241
|
+
* or a spawn failure (no numeric exit code) still throws.
|
|
1242
|
+
*
|
|
1243
|
+
* Uses the fastIdempotent profile: no cloud calls, no state.
|
|
1244
|
+
*/
|
|
1245
|
+
export async function choudoufuLiveCheck(args, signal) {
|
|
1246
|
+
const { binary, root, dir } = await resolveRoot(args, signal);
|
|
1247
|
+
const env = terraformEnvironment(root);
|
|
1248
|
+
const cmd = choudoufuLiveCheckCommand({ binary });
|
|
1249
|
+
const parseOrUndefined = (text) => {
|
|
1250
|
+
try {
|
|
1251
|
+
return JSON.parse(text);
|
|
1252
|
+
}
|
|
1253
|
+
catch {
|
|
1254
|
+
return undefined;
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
try {
|
|
1258
|
+
const { stdout, stderr } = await run(cmd, dir, env, signal);
|
|
1259
|
+
report(stdout, stderr);
|
|
1260
|
+
return { refused: false, json: parseOrUndefined(stdout), text: stdout, dir };
|
|
1261
|
+
}
|
|
1262
|
+
catch (err) {
|
|
1263
|
+
const failure = err;
|
|
1264
|
+
if (typeof failure.code !== "number")
|
|
1265
|
+
throw err;
|
|
1266
|
+
const stdout = failure.stdout ?? "";
|
|
1267
|
+
report(stdout, failure.stderr ?? "");
|
|
1268
|
+
return { refused: true, json: parseOrUndefined(stdout), text: stdout, dir };
|
|
1269
|
+
}
|
|
1270
|
+
}
|