@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.
Files changed (149) hide show
  1. package/dist/__fixtures__/terragrunt/affected-cases.js +111 -0
  2. package/dist/__fixtures__/terragrunt/record.js +57 -0
  3. package/dist/behaviour/index.js +6 -0
  4. package/dist/behaviour/kinds.js +194 -0
  5. package/dist/behaviour/predict.js +297 -0
  6. package/dist/behaviour/reference-catalog.js +178 -0
  7. package/dist/behaviour/request.js +482 -0
  8. package/dist/change-set.js +234 -0
  9. package/dist/changed-roots.js +97 -0
  10. package/dist/codegen/docs-cli.js +3 -0
  11. package/dist/codegen/docs.js +44 -0
  12. package/dist/codegen/generate-cli.js +9 -0
  13. package/dist/codegen/generate.js +59 -0
  14. package/dist/codegen/package.js +43 -0
  15. package/dist/commands.js +106 -0
  16. package/dist/components/capability-plugin.d.ts +38 -2
  17. package/dist/components/capability-plugin.d.ts.map +1 -1
  18. package/dist/components/capability-plugin.js +260 -0
  19. package/dist/composites/terraform-adopt-op.d.ts +2 -0
  20. package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
  21. package/dist/composites/terraform-adopt-op.js +153 -0
  22. package/dist/composites/terraform-apply-op.d.ts +2 -0
  23. package/dist/composites/terraform-apply-op.d.ts.map +1 -1
  24. package/dist/composites/terraform-apply-op.js +184 -0
  25. package/dist/composites/terraform-pin-rollout-op.js +45 -0
  26. package/dist/composites/terraform-watch-op.js +219 -0
  27. package/dist/config.js +118 -0
  28. package/dist/describe-resources.js +984 -0
  29. package/dist/generated/index.js +1 -0
  30. package/dist/hcl/descend.js +208 -0
  31. package/dist/hcl/edges.js +287 -0
  32. package/dist/hcl/jsonencode.js +442 -0
  33. package/dist/hcl/marker.js +212 -0
  34. package/dist/hcl/parse.js +372 -0
  35. package/dist/hcl/references.js +156 -0
  36. package/dist/hcl/roots.js +98 -0
  37. package/dist/hcl/suppressions.js +189 -0
  38. package/dist/hcl/value.js +72 -0
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +46 -0
  42. package/dist/integrity.json +2 -2
  43. package/dist/lint/audit-catalog.js +501 -0
  44. package/dist/lint/audit-lineage.js +442 -0
  45. package/dist/lint/post-synth/blocks.js +87 -0
  46. package/dist/lint/post-synth/fixtures/load.js +94 -0
  47. package/dist/lint/post-synth/flat-attributes.js +135 -0
  48. package/dist/lint/post-synth/index.js +90 -0
  49. package/dist/lint/post-synth/module-source.js +153 -0
  50. package/dist/lint/post-synth/scope.js +104 -0
  51. package/dist/lint/post-synth/terragrunt.js +63 -0
  52. package/dist/lint/post-synth/tf001.js +138 -0
  53. package/dist/lint/post-synth/tf002.js +143 -0
  54. package/dist/lint/post-synth/tf003.js +60 -0
  55. package/dist/lint/post-synth/tf004.js +54 -0
  56. package/dist/lint/post-synth/tf005.js +68 -0
  57. package/dist/lint/post-synth/tf006.js +43 -0
  58. package/dist/lint/post-synth/tf007.js +66 -0
  59. package/dist/lint/post-synth/tf008.js +64 -0
  60. package/dist/lint/post-synth/tf009.js +49 -0
  61. package/dist/lint/post-synth/tf010.js +40 -0
  62. package/dist/lint/post-synth/tf011.js +38 -0
  63. package/dist/lint/post-synth/tf012.js +36 -0
  64. package/dist/lint/post-synth/tf013.js +53 -0
  65. package/dist/lint/post-synth/tf014.js +61 -0
  66. package/dist/lint/post-synth/tf015.js +62 -0
  67. package/dist/lint/post-synth/tf016.js +66 -0
  68. package/dist/lint/post-synth/tf017.js +39 -0
  69. package/dist/lint/post-synth/tf018.js +63 -0
  70. package/dist/lint/post-synth/tf019.js +60 -0
  71. package/dist/lint/post-synth/tf020.js +103 -0
  72. package/dist/lint/post-synth/tf021.js +117 -0
  73. package/dist/lint/post-synth/tf022.js +51 -0
  74. package/dist/lint/post-synth/tf024.js +62 -0
  75. package/dist/lint/post-synth/tf025.js +83 -0
  76. package/dist/lint/post-synth/tf026.js +74 -0
  77. package/dist/lint/post-synth/tf027.js +66 -0
  78. package/dist/lint/post-synth/tf028.js +83 -0
  79. package/dist/lint/post-synth/tf029.js +73 -0
  80. package/dist/lint/post-synth/tf030.js +253 -0
  81. package/dist/lint/post-synth/tf031.js +332 -0
  82. package/dist/lint/post-synth/tf032.js +201 -0
  83. package/dist/lint/post-synth/tf033.js +126 -0
  84. package/dist/lint/post-synth/tf034.js +45 -0
  85. package/dist/lint/post-synth/tf035.js +63 -0
  86. package/dist/lint/post-synth/tf036.js +97 -0
  87. package/dist/lint/post-synth/tf037.js +80 -0
  88. package/dist/lint/post-synth/tf038.js +63 -0
  89. package/dist/lint/post-synth/tf039.js +55 -0
  90. package/dist/lint/post-synth/tf040.js +67 -0
  91. package/dist/lint/post-synth/tf041.js +53 -0
  92. package/dist/lint/post-synth/tf042.js +38 -0
  93. package/dist/lint/post-synth/tf043.js +45 -0
  94. package/dist/lint/post-synth/tf044.js +45 -0
  95. package/dist/lint/post-synth/tf045.js +44 -0
  96. package/dist/lint/rules/fixtures/load.js +50 -0
  97. package/dist/lint/rules/index.js +4 -0
  98. package/dist/lint/rules/plan-before-apply.js +136 -0
  99. package/dist/lint/secret-shape.js +149 -0
  100. package/dist/live-ownership.js +57 -0
  101. package/dist/lsp/completions.js +95 -0
  102. package/dist/lsp/context.js +358 -0
  103. package/dist/lsp/hover.js +45 -0
  104. package/dist/lsp/option-keys.js +93 -0
  105. package/dist/manifest.json +1 -1
  106. package/dist/op/activities/index.js +30 -0
  107. package/dist/op/activities/live-detect.js +143 -0
  108. package/dist/op/activities/pin-rollout.js +45 -0
  109. package/dist/op/activities/terraform.js +1270 -0
  110. package/dist/op/activity-contracts.js +162 -0
  111. package/dist/op/adoption.js +272 -0
  112. package/dist/op/builders.js +125 -0
  113. package/dist/op/resolve-root-mode.js +77 -0
  114. package/dist/package-cli.js +16 -0
  115. package/dist/pin/edit-ts.js +76 -0
  116. package/dist/pin/edit.js +118 -0
  117. package/dist/pin/forge.js +65 -0
  118. package/dist/pin/index.js +16 -0
  119. package/dist/pin/rollout.d.ts.map +1 -1
  120. package/dist/pin/rollout.js +382 -0
  121. package/dist/pin/scan.js +287 -0
  122. package/dist/pin/source.js +171 -0
  123. package/dist/pin/waves.js +109 -0
  124. package/dist/plan-digest.js +67 -0
  125. package/dist/plugin.js +323 -0
  126. package/dist/serializer.js +23 -0
  127. package/dist/state-ownership.js +29 -0
  128. package/dist/terragrunt/affected.d.ts.map +1 -1
  129. package/dist/terragrunt/affected.js +290 -0
  130. package/dist/terragrunt/index.js +10 -0
  131. package/dist/terragrunt/mocks.js +270 -0
  132. package/dist/terragrunt/run.js +264 -0
  133. package/dist/terragrunt/units.js +195 -0
  134. package/dist/terragrunt/wave.js +125 -0
  135. package/dist/validate-cli.js +4 -0
  136. package/dist/validate.js +31 -0
  137. package/package.json +10 -10
  138. package/src/codegen/package.ts +2 -2
  139. package/src/components/capability-plugin.test.ts +97 -2
  140. package/src/components/capability-plugin.ts +117 -12
  141. package/src/components/pr-loop.tofu.test.ts +51 -11
  142. package/src/composites/terraform-adopt-op.test.ts +6 -0
  143. package/src/composites/terraform-adopt-op.ts +7 -2
  144. package/src/composites/terraform-apply-op.test.ts +6 -0
  145. package/src/composites/terraform-apply-op.ts +7 -2
  146. package/src/index.ts +1 -0
  147. package/src/pin/rollout.ts +2 -1
  148. package/src/terragrunt/affected.test.ts +11 -0
  149. 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
+ }