@intentius/chant-lexicon-terraform 0.124.45 → 0.124.48
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__/capabilities/chant.config.d.ts +26 -0
- package/dist/__fixtures__/capabilities/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/capabilities/chant.config.js +13 -0
- package/dist/__fixtures__/effective-topology/chant.config.d.ts +13 -0
- package/dist/__fixtures__/effective-topology/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/effective-topology/chant.config.js +9 -0
- package/dist/__fixtures__/kind-type/chant.config.d.ts +13 -0
- package/dist/__fixtures__/kind-type/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/kind-type/chant.config.js +9 -0
- package/dist/__fixtures__/search-roots/chant.config.d.ts +13 -0
- package/dist/__fixtures__/search-roots/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/search-roots/chant.config.js +9 -0
- package/dist/__fixtures__/sensitive-default/chant.config.d.ts +13 -0
- package/dist/__fixtures__/sensitive-default/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/sensitive-default/chant.config.js +9 -0
- package/dist/capabilities/adapter.d.ts +9 -0
- package/dist/capabilities/adapter.d.ts.map +1 -0
- package/dist/capabilities/adapter.js +25 -0
- package/dist/capabilities/mapping.json +166 -0
- package/dist/config.d.ts +52 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/describe-resources.d.ts +101 -8
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +395 -56
- package/dist/hcl/parse.d.ts +10 -0
- package/dist/hcl/parse.d.ts.map +1 -1
- package/dist/hcl/parse.js +20 -2
- package/dist/hcl/redact.d.ts +26 -0
- package/dist/hcl/redact.d.ts.map +1 -0
- package/dist/hcl/redact.js +228 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/terraform.d.ts +109 -4
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/op/activities/terraform.js +148 -7
- package/dist/plan-observation.d.ts +153 -0
- package/dist/plan-observation.d.ts.map +1 -0
- package/dist/plan-observation.js +354 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +2 -0
- package/package.json +2 -2
- package/src/__fixtures__/ACCEPTANCE.md +53 -1
- package/src/__fixtures__/capabilities/catalog/main.tf +105 -0
- package/src/__fixtures__/capabilities/chant.config.ts +15 -0
- package/src/__fixtures__/capabilities/findings/main.tf +225 -0
- package/src/__fixtures__/capabilities/ledger/main.tf +265 -0
- package/src/__fixtures__/capabilities/package.json +1 -0
- package/src/__fixtures__/effective-topology/chant.config.ts +11 -0
- package/src/__fixtures__/effective-topology/net/main.tf +176 -0
- package/src/__fixtures__/effective-topology/package.json +6 -0
- package/src/__fixtures__/kind-type/app/main.tf +35 -0
- package/src/__fixtures__/kind-type/chant.config.ts +11 -0
- package/src/__fixtures__/kind-type/package.json +6 -0
- package/src/__fixtures__/live-plan-depth/README.md +59 -0
- package/src/__fixtures__/live-plan-depth/live-plan.json +110 -0
- package/src/__fixtures__/live-plan-depth/show-plan-sensitive.json +1 -0
- package/src/__fixtures__/live-plan-depth/show-plan.json +1 -0
- package/src/__fixtures__/plan-read/main.tf +46 -0
- package/src/__fixtures__/plan-read/next.tfvars +1 -0
- package/src/__fixtures__/plan-read-drift/README.md +33 -0
- package/src/__fixtures__/plan-read-drift/main.tf +63 -0
- package/src/__fixtures__/plan-read-drift/next.tfvars +1 -0
- package/src/__fixtures__/search-roots/chant.config.ts +11 -0
- package/src/__fixtures__/search-roots/net/main.tf +73 -0
- package/src/__fixtures__/search-roots/package.json +6 -0
- package/src/__fixtures__/sensitive-default/chant.config.ts +11 -0
- package/src/__fixtures__/sensitive-default/net/main.tf +38 -0
- package/src/__fixtures__/sensitive-default/package.json +6 -0
- package/src/__fixtures__/show-plan.json +663 -0
- package/src/capabilities/adapter.ts +32 -0
- package/src/capabilities/mapping.json +166 -0
- package/src/capabilities-workspace.e2e.test.ts +251 -0
- package/src/capabilities.test.ts +264 -0
- package/src/config.ts +26 -0
- package/src/describe-resources.live-plan.test.ts +302 -0
- package/src/describe-resources.live-root-unchanged.test.ts +126 -0
- package/src/describe-resources.plan.test.ts +437 -0
- package/src/describe-resources.ts +445 -52
- package/src/effective-topology.test.ts +134 -0
- package/src/hcl/parse.ts +30 -2
- package/src/hcl/redact.ts +181 -0
- package/src/live-plan-depth.acceptance.e2e.test.ts +193 -0
- package/src/op/activities/terraform.test.ts +118 -1
- package/src/op/activities/terraform.ts +213 -9
- package/src/plan-observation.ts +428 -0
- package/src/plan-read.acceptance.test.ts +184 -0
- package/src/plugin.ts +2 -0
- package/src/redact-ir.e2e.test.ts +79 -0
- package/src/redact.test.ts +111 -0
- package/src/search-build-roots.test.ts +125 -0
- package/src/search-count.test.ts +144 -0
- package/src/search-kind-type.test.ts +136 -0
|
@@ -24,18 +24,26 @@
|
|
|
24
24
|
* is a different one.
|
|
25
25
|
*
|
|
26
26
|
* So the returned `ObservationResult` carries `sources`, keyed by chant entity
|
|
27
|
-
* name, with one of exactly
|
|
27
|
+
* name, with one of exactly four values:
|
|
28
28
|
*
|
|
29
|
-
* - `"state"
|
|
30
|
-
* - `"live"
|
|
29
|
+
* - `"state"`: this entity's root was read with `terraform show -json`.
|
|
30
|
+
* - `"live"`: this entity's root was read with `choudoufu live-plan -json`.
|
|
31
|
+
* - `"plan"`: this entity's root was read at the plan depth (#4358), a saved
|
|
32
|
+
* plan's `show -json`, which says what the next apply would do. See the
|
|
33
|
+
* plan depth section below.
|
|
34
|
+
* - `"live+plan"`: a live root at the plan depth (#4371), where both reads
|
|
35
|
+
* answered: `live-plan -json` for presence and ownership, the plan read for
|
|
36
|
+
* the attributes. A live root whose plan read failed says `"live"`.
|
|
31
37
|
*
|
|
32
38
|
* Three guarantees a consumer may rely on. Every entity `describeResources`
|
|
33
39
|
* was asked about that belongs to a root has an entry, whatever verdict it
|
|
34
40
|
* came back with: present, absent and not-observed alike, because which read
|
|
35
|
-
* was attempted is a fact independent of what it found.
|
|
36
|
-
* declared entity's own `props.mode`
|
|
37
|
-
* decides both, so `props.mode` is also a
|
|
38
|
-
* holding the declared graph and no
|
|
41
|
+
* was attempted is a fact independent of what it found. On a root read at the
|
|
42
|
+
* default depth the value equals the declared entity's own `props.mode`
|
|
43
|
+
* (`./hcl/parse.ts`), since one fact decides both, so `props.mode` is also a
|
|
44
|
+
* valid join key for a consumer holding the declared graph and no
|
|
45
|
+
* observation; the plan depth is chosen at read time, so only `sources` says
|
|
46
|
+
* it was used. And a project mixing stock
|
|
39
47
|
* and live roots gets both values in one document, which is why this is a map
|
|
40
48
|
* and not a field on the result as a whole.
|
|
41
49
|
*
|
|
@@ -144,6 +152,49 @@
|
|
|
144
152
|
* (`live-ls`'s own help text) and a listing taken right after an apply can
|
|
145
153
|
* otherwise show a resource under both its old and new estate, or neither.
|
|
146
154
|
*
|
|
155
|
+
* ## The plan depth (#4358)
|
|
156
|
+
*
|
|
157
|
+
* A stock root can be read one depth further: `terraform.observe` (or a
|
|
158
|
+
* root's own `observe`) set to `"plan"`, or `CHANT_TERRAFORM_OBSERVE=plan`,
|
|
159
|
+
* or `depth: "plan"` from a caller. The read is `terraformPlanRead`:
|
|
160
|
+
* `plan -input=false -lock=false -out=<tmp>/plan.tfplan`, then `show -json`,
|
|
161
|
+
* with `<tmp>` under the OS temp directory and removed afterwards, and no
|
|
162
|
+
* `init`. Presence and ownership come from the plan's `prior_state` by the
|
|
163
|
+
* state read's own rule; `./plan-observation.ts` adds the plan's attributes
|
|
164
|
+
* (`planAction`, `planReplacePaths`, `planAfterUnknown`, `planDrift`, ...)
|
|
165
|
+
* to each present block, with sensitive values redacted.
|
|
166
|
+
*
|
|
167
|
+
* It falls back by depth (`planAdapter`): no provider credentials -> the
|
|
168
|
+
* state read; no backend, or a root that needs `init` -> declared only. Each
|
|
169
|
+
* root gets a note saying which read answered. A planned create has no row in
|
|
170
|
+
* `prior_state` and reads absent, as on the state read, so its plan
|
|
171
|
+
* attributes have nowhere to ride.
|
|
172
|
+
*
|
|
173
|
+
* ### On a live root (#4371)
|
|
174
|
+
*
|
|
175
|
+
* `live-plan -json` carries ownership and no attribute values (behold#404),
|
|
176
|
+
* and `choudoufu plan -out` writes a stock plan file (choudoufu #878). So a
|
|
177
|
+
* live root at the plan depth runs both (`livePlanDepthAdapter`):
|
|
178
|
+
* `live-plan -json` first, unchanged, for presence and ownership, then the
|
|
179
|
+
* same `terraformPlanRead` a stock root gets, whose attributes are added to
|
|
180
|
+
* each block `live-plan` bound. A foreign or adoptable block gets none: the
|
|
181
|
+
* plan proposes a create there, which is choudoufu's answer for an instance
|
|
182
|
+
* it does not own, not a fact about the live resource. `sources` reads
|
|
183
|
+
* `"live+plan"`.
|
|
184
|
+
*
|
|
185
|
+
* choudoufu rebuilds prior state from the account on every run, so on a live
|
|
186
|
+
* root out-of-band drift is not in `resource_drift`. It is the planned
|
|
187
|
+
* `update` itself: `planChanges` has the live value as `before` and the
|
|
188
|
+
* declared one as `after`, and `planDrift` stays empty. That is where #2472's
|
|
189
|
+
* property-level drift for a live root comes from.
|
|
190
|
+
*
|
|
191
|
+
* Falling back is one step: if the plan read fails, `live-plan` alone
|
|
192
|
+
* answered, the note says why, and `sources` reads `"live"`. If `live-plan`
|
|
193
|
+
* fails the root is not observed, as at the default depth, and the plan read
|
|
194
|
+
* is not tried, since its attributes would have no present block to ride.
|
|
195
|
+
* The cost is two refreshes of the estate per read, run one after the other
|
|
196
|
+
* so they never share the root's cache file.
|
|
197
|
+
*
|
|
147
198
|
* ## Nothing from `values` is surfaced
|
|
148
199
|
*
|
|
149
200
|
* A state row's `values` are the resource's full attribute set, secrets
|
|
@@ -168,15 +219,22 @@ import {
|
|
|
168
219
|
type DeclaredEntity,
|
|
169
220
|
type EntityObservation,
|
|
170
221
|
type ObserverAdapter,
|
|
222
|
+
type RootRead,
|
|
171
223
|
type UnobservedReason,
|
|
172
224
|
} from "@intentius/chant/observation";
|
|
173
225
|
import type { OwnershipMarker } from "@intentius/chant/ownership";
|
|
226
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
227
|
+
import { isAbsolute, join } from "node:path";
|
|
174
228
|
import {
|
|
175
229
|
choudoufuLiveLs,
|
|
176
230
|
choudoufuLivePlanDocument,
|
|
177
231
|
terraformInit,
|
|
232
|
+
terraformPlanRead,
|
|
178
233
|
terraformShow,
|
|
234
|
+
TerraformPlanReadError,
|
|
179
235
|
} from "./op/activities/terraform";
|
|
236
|
+
import { blockAddressOf, indexPlan, planAttributesFor, plannedAttributesFor, type PlanIndex } from "./plan-observation";
|
|
237
|
+
import type { TerraformObserveDepth } from "./config";
|
|
180
238
|
import { DATA_TYPE, MODULE_TYPE, RESOURCE_TYPE, callersOfKey } from "./hcl/parse";
|
|
181
239
|
|
|
182
240
|
// The channel keys live in their own module so `plugin.ts` can declare
|
|
@@ -650,6 +708,42 @@ function readLiveResource(
|
|
|
650
708
|
};
|
|
651
709
|
}
|
|
652
710
|
|
|
711
|
+
/**
|
|
712
|
+
* The environment variable that asks for each live root's `live-plan -json`
|
|
713
|
+
* document as a file, in a directory the caller owns (behold#924).
|
|
714
|
+
*
|
|
715
|
+
* The overlay keeps chant's verdict per block and drops the rows behind it:
|
|
716
|
+
* each instance's omission reason and detail, `identity_values`, the adoptable
|
|
717
|
+
* row's own fields. A caller that paints ownership from those rows and wants
|
|
718
|
+
* the plan's attributes from the plan depth would otherwise run its own
|
|
719
|
+
* `live-plan` beside this one, two refreshes of the estate that can disagree
|
|
720
|
+
* (choudoufu #1014). With this set to an absolute directory, the document this
|
|
721
|
+
* read used is written there as `<root>.live-plan.json`, so the caller reads
|
|
722
|
+
* the same `live-plan` the verdicts came from.
|
|
723
|
+
*
|
|
724
|
+
* Opt-in, and never inside the root: a relative path is ignored, so nothing
|
|
725
|
+
* lands in the root by accident (#4379). A write that fails is ignored too;
|
|
726
|
+
* the read answers either way, and the caller sees no file.
|
|
727
|
+
*/
|
|
728
|
+
export const TERRAFORM_LIVE_PLAN_DIR_ENV = "CHANT_TERRAFORM_LIVE_PLAN_DIR";
|
|
729
|
+
|
|
730
|
+
/** The file {@link TERRAFORM_LIVE_PLAN_DIR_ENV} names for a root: the root's name, made safe for a file name. */
|
|
731
|
+
export function livePlanDocumentFile(root: string): string {
|
|
732
|
+
return `${root.replace(/[^A-Za-z0-9._-]/g, "_")}.live-plan.json`;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Write a live root's document where {@link TERRAFORM_LIVE_PLAN_DIR_ENV} asks, if it asks. */
|
|
736
|
+
function handLivePlanDocument(root: string, json: unknown): void {
|
|
737
|
+
const out = process.env[TERRAFORM_LIVE_PLAN_DIR_ENV];
|
|
738
|
+
if (!out || !isAbsolute(out)) return;
|
|
739
|
+
try {
|
|
740
|
+
mkdirSync(out, { recursive: true });
|
|
741
|
+
writeFileSync(join(out, livePlanDocumentFile(root)), JSON.stringify(json));
|
|
742
|
+
} catch {
|
|
743
|
+
// Best effort: the read's answer does not depend on the copy.
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
653
747
|
/**
|
|
654
748
|
* One live root's reader: `live-plan -json`, then address matching against
|
|
655
749
|
* the document. `terraform show` is never run here, and there is no state
|
|
@@ -663,6 +757,7 @@ function liveAdapter(root: string, cwd: string | undefined, deps: TerraformReadD
|
|
|
663
757
|
async bind(): Promise<LivePlanIndex> {
|
|
664
758
|
const planned = await deps.livePlan({ root, ...where });
|
|
665
759
|
dir = planned.dir;
|
|
760
|
+
handLivePlanDocument(root, planned.json);
|
|
666
761
|
return indexLivePlan(planned.json);
|
|
667
762
|
},
|
|
668
763
|
|
|
@@ -1010,6 +1105,11 @@ export interface TerraformReadDeps {
|
|
|
1010
1105
|
/** The document alone (chant #2498): this reader indexes it and never reads the human render. */
|
|
1011
1106
|
livePlan: typeof choudoufuLivePlanDocument;
|
|
1012
1107
|
liveLs: typeof choudoufuLiveLs;
|
|
1108
|
+
/**
|
|
1109
|
+
* The plan depth's read (#4358). Optional so a test that never asks for the
|
|
1110
|
+
* plan depth need not stub it; omitted, the real activity runs.
|
|
1111
|
+
*/
|
|
1112
|
+
planRead?: typeof terraformPlanRead;
|
|
1013
1113
|
}
|
|
1014
1114
|
|
|
1015
1115
|
const REAL_DEPS: TerraformReadDeps = {
|
|
@@ -1017,6 +1117,7 @@ const REAL_DEPS: TerraformReadDeps = {
|
|
|
1017
1117
|
show: terraformShow,
|
|
1018
1118
|
livePlan: choudoufuLivePlanDocument,
|
|
1019
1119
|
liveLs: choudoufuLiveLs,
|
|
1120
|
+
planRead: terraformPlanRead,
|
|
1020
1121
|
};
|
|
1021
1122
|
|
|
1022
1123
|
/** A declared entity, plus the root, address and mode `buildRoots()` recorded on it. */
|
|
@@ -1079,7 +1180,11 @@ export function declaredOf(name: string, entity: { entityType: string; props: Re
|
|
|
1079
1180
|
};
|
|
1080
1181
|
}
|
|
1081
1182
|
|
|
1082
|
-
/**
|
|
1183
|
+
/**
|
|
1184
|
+
* One root's reader: `init`, then `show` over state, then address matching.
|
|
1185
|
+
* The entity-level reads are {@link readStateEntity}, which the plan depth's
|
|
1186
|
+
* fallback shares.
|
|
1187
|
+
*/
|
|
1083
1188
|
function adapter(root: string, cwd: string | undefined, deps: TerraformReadDeps): ObserverAdapter<StateIndex> {
|
|
1084
1189
|
const where = cwd ? { cwd } : {};
|
|
1085
1190
|
let dir: string | undefined;
|
|
@@ -1105,58 +1210,308 @@ function adapter(root: string, cwd: string | undefined, deps: TerraformReadDeps)
|
|
|
1105
1210
|
},
|
|
1106
1211
|
|
|
1107
1212
|
async read(index, entity): Promise<EntityObservation> {
|
|
1108
|
-
|
|
1109
|
-
|
|
1213
|
+
return readStateEntity(index, entity, root, `terraform show -json (root "${root}", address "${(entity as TerraformDeclared).address}")`);
|
|
1214
|
+
},
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1110
1217
|
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1218
|
+
/** True when the index carries any row for the block `address`, at any instance key. */
|
|
1219
|
+
function blockRows(address: string, index: StateIndex): StateResourceRow[] {
|
|
1220
|
+
const exact = index.rows.get(address);
|
|
1221
|
+
if (exact) return [exact];
|
|
1222
|
+
return [...index.rows.values()].filter((row) => blockAddressOf(row.address) === address);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* One declared entity against a state index: the state read's whole verdict,
|
|
1227
|
+
* and the plan depth's too, since a plan's `prior_state` is the same shape.
|
|
1228
|
+
* `extra` is what the plan depth adds to a present resource or data block.
|
|
1229
|
+
*/
|
|
1230
|
+
function readStateEntity(
|
|
1231
|
+
index: StateIndex,
|
|
1232
|
+
entity: DeclaredEntity,
|
|
1233
|
+
root: string,
|
|
1234
|
+
queried: string,
|
|
1235
|
+
extra?: (address: string) => Record<string, unknown>,
|
|
1236
|
+
): EntityObservation {
|
|
1237
|
+
const { address } = entity as TerraformDeclared;
|
|
1238
|
+
|
|
1239
|
+
if (entity.type === RESOURCE_TYPE || entity.type === DATA_TYPE) {
|
|
1240
|
+
// The exact row first, which is the state read's rule since #2087; the
|
|
1241
|
+
// plan depth also finds a `count` or `for_each` block by its instances.
|
|
1242
|
+
const rows = extra ? blockRows(address, index) : index.rows.get(address) ? [index.rows.get(address)!] : [];
|
|
1243
|
+
if (rows.length === 0) return { absent: true, queried };
|
|
1244
|
+
const row = rows[0]!;
|
|
1245
|
+
return {
|
|
1246
|
+
present: {
|
|
1247
|
+
type: entity.type,
|
|
1248
|
+
physicalId: rows.length === 1 ? (row.id ?? address) : address,
|
|
1249
|
+
status: row.mode ?? "managed",
|
|
1250
|
+
ownership: "owned",
|
|
1251
|
+
attributes: {
|
|
1252
|
+
address,
|
|
1253
|
+
root,
|
|
1254
|
+
...(row.type ? { resourceType: row.type } : {}),
|
|
1255
|
+
...(row.mode ? { mode: row.mode } : {}),
|
|
1256
|
+
...(row.providerName ? { provider: row.providerName } : {}),
|
|
1257
|
+
...(rows.length > 1 ? { instances: rows.map((r) => r.address).sort() } : {}),
|
|
1258
|
+
...(extra ? extra(address) : {}),
|
|
1259
|
+
},
|
|
1260
|
+
},
|
|
1261
|
+
queried,
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (entity.type === MODULE_TYPE) {
|
|
1266
|
+
if (!moduleIsLive(address, index)) return { absent: true, queried };
|
|
1267
|
+
// The state has resources under this module but no row for the block
|
|
1268
|
+
// itself, so state membership, which is the whole ownership channel
|
|
1269
|
+
// here, has nothing to say about it. See the module doc.
|
|
1270
|
+
return {
|
|
1271
|
+
present: {
|
|
1272
|
+
type: entity.type,
|
|
1273
|
+
physicalId: address,
|
|
1274
|
+
status: "module",
|
|
1275
|
+
ownership: classifyStateOwnership(address, index),
|
|
1276
|
+
attributes: { address, root },
|
|
1277
|
+
},
|
|
1278
|
+
queried,
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return {
|
|
1283
|
+
unobserved: {
|
|
1284
|
+
reason: "unsupported-kind",
|
|
1285
|
+
detail: `${entity.type} has no row in terraform state: only resource and data blocks do`,
|
|
1286
|
+
},
|
|
1287
|
+
queried,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/* ───────────────────────────── the plan depth (#4358) ───────────────────────────── */
|
|
1292
|
+
|
|
1293
|
+
/** Which read answered a plan-depth root. */
|
|
1294
|
+
export type PlanDepthAnswer = "plan" | "state" | "declared";
|
|
1295
|
+
|
|
1296
|
+
/** What a plan-depth root's bind produced: the plan, or the state read it fell back to. */
|
|
1297
|
+
type PlanDepthIndex = { kind: "plan"; plan: PlanIndex; state: StateIndex } | { kind: "state"; state: StateIndex };
|
|
1298
|
+
|
|
1299
|
+
/** Does this text name a credentials or authorization failure? Exported for the fallback tests. */
|
|
1300
|
+
export function planFailureIsCredentials(text: string): boolean {
|
|
1301
|
+
return readsAsCredentials(text) || /no valid credential sources|could not find default credentials|failed to refresh cached credentials|NoCredentialProviders|AADSTS|InvalidAccessKeyId|token has expired/i.test(text);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* One plan-depth root's reader. `bind` is the fallback ladder:
|
|
1306
|
+
*
|
|
1307
|
+
* 1. An uninitialised root (or one that needs `init` again) is refused with
|
|
1308
|
+
* the line to run, and nothing else is read: the state read would need
|
|
1309
|
+
* `init` too, and chant never runs it on a read. Declared only.
|
|
1310
|
+
* 2. The plan read answers: `"plan"`.
|
|
1311
|
+
* 3. The plan read failed reaching the backend: the state read needs the
|
|
1312
|
+
* same backend, so declared only.
|
|
1313
|
+
* 4. The plan read failed any other way, missing provider credentials
|
|
1314
|
+
* first among them: `terraform show -json` over state, with no `init`
|
|
1315
|
+
* (the root is initialised, step 1 checked). `"state"`.
|
|
1316
|
+
* 5. That failed too: declared only.
|
|
1317
|
+
*
|
|
1318
|
+
* `answered` is filled as the ladder runs, for the caller's `sources` and
|
|
1319
|
+
* notes.
|
|
1320
|
+
*/
|
|
1321
|
+
function planAdapter(
|
|
1322
|
+
root: string,
|
|
1323
|
+
cwd: string | undefined,
|
|
1324
|
+
deps: TerraformReadDeps,
|
|
1325
|
+
outcome: { answered: PlanDepthAnswer; note: string; read?: RootRead },
|
|
1326
|
+
): ObserverAdapter<PlanDepthIndex> {
|
|
1327
|
+
const where = cwd ? { cwd } : {};
|
|
1328
|
+
const planRead = deps.planRead ?? terraformPlanRead;
|
|
1329
|
+
let dir: string | undefined;
|
|
1330
|
+
let refusal: { reason: UnobservedReason; detail: string } | undefined;
|
|
1331
|
+
const firstLine = (err: unknown): string => (err instanceof Error ? err.message : String(err)).split("\n")[0]!;
|
|
1332
|
+
|
|
1333
|
+
return {
|
|
1334
|
+
async bind(): Promise<PlanDepthIndex> {
|
|
1335
|
+
outcome.answered = "declared";
|
|
1336
|
+
let planErr: unknown;
|
|
1337
|
+
try {
|
|
1338
|
+
const read = await planRead({ root, ...where });
|
|
1339
|
+
dir = read.dir;
|
|
1340
|
+
const plan = indexPlan(read.json);
|
|
1341
|
+
outcome.answered = "plan";
|
|
1342
|
+
outcome.read = { depth: "plan" };
|
|
1343
|
+
outcome.note = `terraform.roots.${root}: the plan read answered (\`${read.command.replace(read.planDir, "<tmp>")}\`)`;
|
|
1344
|
+
return { kind: "plan", plan, state: indexStateResources({ values: asRecord(plan.priorState).values }) };
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
planErr = err;
|
|
1347
|
+
if (err instanceof TerraformPlanReadError) dir = err.dir;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const kind = planErr instanceof TerraformPlanReadError ? planErr.kind : "plan";
|
|
1351
|
+
if (kind === "not-initialised" || kind === "backend") {
|
|
1352
|
+
refusal = {
|
|
1353
|
+
reason: kind === "backend" && planFailureIsCredentials(String((planErr as Error).message)) ? "no-credentials" : kind === "backend" ? "read-failed" : "no-binding",
|
|
1354
|
+
detail: `terraform.roots.${root}${dir ? ` (${dir})` : ""}: declared only. ${firstLine(planErr)}${kind === "backend" ? " The state read needs the same backend, so it was not tried." : ""}`,
|
|
1129
1355
|
};
|
|
1356
|
+
outcome.read = { depth: "plan", fellBackTo: "declared", reason: kind === "backend" ? "backend-unreachable" : "needs-init" };
|
|
1357
|
+
outcome.note = `terraform.roots.${root}: no read answered, declared only (${kind === "backend" ? "the plan read could not reach the backend" : "the root needs init, which chant never runs"})`;
|
|
1358
|
+
throw planErr;
|
|
1130
1359
|
}
|
|
1131
1360
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
},
|
|
1145
|
-
queried,
|
|
1361
|
+
const why = planFailureIsCredentials(String((planErr as Error)?.message ?? planErr)) ? "no provider credentials" : "the plan failed";
|
|
1362
|
+
try {
|
|
1363
|
+
const shown = await deps.show({ root, ...where });
|
|
1364
|
+
dir = shown.dir;
|
|
1365
|
+
outcome.answered = "state";
|
|
1366
|
+
outcome.read = { depth: "plan", fellBackTo: "state", reason: why === "no provider credentials" ? "no-credentials" : "plan-failed" };
|
|
1367
|
+
outcome.note = `terraform.roots.${root}: the plan read failed (${why}: ${firstLine(planErr)}), so the state read answered; plan attributes are absent`;
|
|
1368
|
+
return { kind: "state", state: indexStateResources(shown.json) };
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
refusal = {
|
|
1371
|
+
reason: "read-failed",
|
|
1372
|
+
detail: `terraform.roots.${root}${dir ? ` (${dir})` : ""}: declared only. The plan read failed (${firstLine(planErr)}) and the state read after it failed too (${firstLine(err)})`,
|
|
1146
1373
|
};
|
|
1374
|
+
outcome.read = { depth: "plan", fellBackTo: "declared", reason: "state-read-failed" };
|
|
1375
|
+
outcome.note = `terraform.roots.${root}: no read answered, declared only (the plan read failed, ${why}, and the state read failed after it)`;
|
|
1376
|
+
throw err;
|
|
1147
1377
|
}
|
|
1378
|
+
},
|
|
1148
1379
|
|
|
1380
|
+
classifyBindFailure(err) {
|
|
1381
|
+
return refusal ?? { reason: "read-failed", detail: `terraform.roots.${root}${dir ? ` (${dir})` : ""}: ${firstLine(err)}` };
|
|
1382
|
+
},
|
|
1383
|
+
|
|
1384
|
+
async read(index, entity): Promise<EntityObservation> {
|
|
1385
|
+
const { address } = entity as TerraformDeclared;
|
|
1386
|
+
if (index.kind === "state") {
|
|
1387
|
+
return readStateEntity(index.state, entity, root, `terraform show -json (root "${root}", address "${address}", after the plan read failed)`);
|
|
1388
|
+
}
|
|
1389
|
+
const plan = index.plan;
|
|
1390
|
+
const seen = readStateEntity(
|
|
1391
|
+
index.state,
|
|
1392
|
+
entity,
|
|
1393
|
+
root,
|
|
1394
|
+
`terraform plan -lock=false + show -json <planfile> (root "${root}", address "${address}")`,
|
|
1395
|
+
(a) => planAttributesFor(a, plan),
|
|
1396
|
+
);
|
|
1397
|
+
// A planned create (#4372): no row in `prior_state`, so absent, which is
|
|
1398
|
+
// what makes `lifecycle plan` propose the create. The plan still says
|
|
1399
|
+
// what it will do and which values stay unknown until apply, so those
|
|
1400
|
+
// ride as annotations, which never make the block present.
|
|
1401
|
+
if ("absent" in seen && (entity.type === RESOURCE_TYPE || entity.type === DATA_TYPE)) {
|
|
1402
|
+
const planned = plannedAttributesFor(address, plan);
|
|
1403
|
+
if (planned) return { ...seen, annotations: planned };
|
|
1404
|
+
}
|
|
1405
|
+
return seen;
|
|
1406
|
+
},
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/** What a live root's plan-depth bind produced: `live-plan`'s index, and the plan when it answered. */
|
|
1411
|
+
type LivePlanDepthIndex = { live: LivePlanIndex; plan?: PlanIndex };
|
|
1412
|
+
|
|
1413
|
+
/** Which reads answered a live root at the plan depth (#4371). */
|
|
1414
|
+
export type LivePlanDepthAnswer = "live+plan" | "live";
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* One live root's reader at the plan depth (#4371): {@link liveAdapter}'s
|
|
1418
|
+
* read, unchanged, with the plan's attributes added to every block it bound.
|
|
1419
|
+
* See "On a live root" in the module doc.
|
|
1420
|
+
*/
|
|
1421
|
+
function livePlanDepthAdapter(
|
|
1422
|
+
root: string,
|
|
1423
|
+
cwd: string | undefined,
|
|
1424
|
+
deps: TerraformReadDeps,
|
|
1425
|
+
outcome: { answered: LivePlanDepthAnswer; note: string; read?: RootRead },
|
|
1426
|
+
): ObserverAdapter<LivePlanDepthIndex> {
|
|
1427
|
+
const where = cwd ? { cwd } : {};
|
|
1428
|
+
const live = liveAdapter(root, cwd, deps);
|
|
1429
|
+
const planRead = deps.planRead ?? terraformPlanRead;
|
|
1430
|
+
const firstLine = (err: unknown): string => (err instanceof Error ? err.message : String(err)).split("\n")[0]!;
|
|
1431
|
+
let command = "";
|
|
1432
|
+
|
|
1433
|
+
return {
|
|
1434
|
+
async bind(): Promise<LivePlanDepthIndex> {
|
|
1435
|
+
outcome.answered = "live";
|
|
1436
|
+
outcome.note = `terraform.roots.${root}: no read answered (\`live-plan -json\` failed, so the plan read was not tried)`;
|
|
1437
|
+
const index = await live.bind();
|
|
1438
|
+
try {
|
|
1439
|
+
const read = await planRead({ root, ...where });
|
|
1440
|
+
command = read.command.replace(read.planDir, "<tmp>");
|
|
1441
|
+
outcome.answered = "live+plan";
|
|
1442
|
+
outcome.read = { depth: "live+plan" };
|
|
1443
|
+
outcome.note = `terraform.roots.${root}: the plan read answered (\`${command}\`), beside \`live-plan -json\`, which answered presence and ownership`;
|
|
1444
|
+
return { live: index, plan: indexPlan(read.json) };
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
const kind = err instanceof TerraformPlanReadError ? err.kind : "plan";
|
|
1447
|
+
const why =
|
|
1448
|
+
kind === "not-initialised"
|
|
1449
|
+
? "the root needs init, which chant never runs"
|
|
1450
|
+
: planFailureIsCredentials(String(err instanceof Error ? err.message : err))
|
|
1451
|
+
? "no provider credentials"
|
|
1452
|
+
: kind === "backend"
|
|
1453
|
+
? "the plan read could not reach the backend"
|
|
1454
|
+
: "the plan failed";
|
|
1455
|
+
outcome.read = {
|
|
1456
|
+
depth: "live+plan",
|
|
1457
|
+
fellBackTo: "live",
|
|
1458
|
+
reason: kind === "not-initialised" ? "needs-init" : kind === "backend" ? "backend-unreachable" : why === "no provider credentials" ? "no-credentials" : "plan-failed",
|
|
1459
|
+
};
|
|
1460
|
+
outcome.note = `terraform.roots.${root}: the plan read failed (${why}: ${firstLine(err)}), so \`live-plan -json\` alone answered; plan attributes are absent`;
|
|
1461
|
+
return { live: index };
|
|
1462
|
+
}
|
|
1463
|
+
},
|
|
1464
|
+
|
|
1465
|
+
classifyBindFailure(err) {
|
|
1466
|
+
return live.classifyBindFailure!(err);
|
|
1467
|
+
},
|
|
1468
|
+
|
|
1469
|
+
async read(index, entity): Promise<EntityObservation> {
|
|
1470
|
+
const observed = await live.read(index.live, entity);
|
|
1471
|
+
const { plan } = index;
|
|
1472
|
+
if (!plan) return observed;
|
|
1473
|
+
const queried = `${observed.queried}; ${command} + show -json <planfile>`;
|
|
1474
|
+
const present = "present" in observed ? observed.present : undefined;
|
|
1475
|
+
if (!present || entity.type !== RESOURCE_TYPE || present.ownership !== "owned") return { ...observed, queried };
|
|
1476
|
+
const { address } = entity as TerraformDeclared;
|
|
1149
1477
|
return {
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
detail: `${entity.type} has no row in terraform state: only resource and data blocks do`,
|
|
1153
|
-
},
|
|
1478
|
+
...observed,
|
|
1479
|
+
present: { ...present, attributes: { ...present.attributes, ...planAttributesFor(address, plan) } },
|
|
1154
1480
|
queried,
|
|
1155
1481
|
};
|
|
1156
1482
|
},
|
|
1157
1483
|
};
|
|
1158
1484
|
}
|
|
1159
1485
|
|
|
1486
|
+
/** The environment variable that overrides every root's observation depth for one run (#4358). */
|
|
1487
|
+
export const TERRAFORM_OBSERVE_ENV = "CHANT_TERRAFORM_OBSERVE";
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* Each root's observation depth, in precedence order: the caller's `depth`,
|
|
1491
|
+
* then {@link TERRAFORM_OBSERVE_ENV}, then the root's own `observe`, then the
|
|
1492
|
+
* namespace's, then `"state"`. The config is read only when neither of the
|
|
1493
|
+
* first two settles it, and a config that cannot be read means `"state"`,
|
|
1494
|
+
* which is what every root read before the plan depth existed.
|
|
1495
|
+
*/
|
|
1496
|
+
async function observeDepths(
|
|
1497
|
+
options: Pick<DescribeResourcesOptions, "depth" | "cwd">,
|
|
1498
|
+
notes: string[],
|
|
1499
|
+
): Promise<(root: string) => TerraformObserveDepth> {
|
|
1500
|
+
if (options.depth) return () => options.depth!;
|
|
1501
|
+
const env = process.env[TERRAFORM_OBSERVE_ENV];
|
|
1502
|
+
if (env === "plan" || env === "state") return () => env;
|
|
1503
|
+
if (env) notes.push(`${TERRAFORM_OBSERVE_ENV}=${JSON.stringify(env)} is neither "state" nor "plan" and was ignored`);
|
|
1504
|
+
try {
|
|
1505
|
+
const { loadChantConfigUpward } = await import("@intentius/chant/config");
|
|
1506
|
+
const { resolve } = await import("node:path");
|
|
1507
|
+
const { config } = await loadChantConfigUpward(resolve(options.cwd ?? process.cwd()));
|
|
1508
|
+
const namespace = (config as { terraform?: { observe?: TerraformObserveDepth; roots?: Record<string, { observe?: TerraformObserveDepth }> } }).terraform;
|
|
1509
|
+
return (root) => namespace?.roots?.[root]?.observe ?? namespace?.observe ?? "state";
|
|
1510
|
+
} catch {
|
|
1511
|
+
return () => "state";
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1160
1515
|
export interface DescribeResourcesOptions {
|
|
1161
1516
|
environment: string;
|
|
1162
1517
|
buildOutput: string;
|
|
@@ -1173,6 +1528,14 @@ export interface DescribeResourcesOptions {
|
|
|
1173
1528
|
* the running process's cwd. Same meaning as `TerraformWatchOpConfig.cwd`.
|
|
1174
1529
|
*/
|
|
1175
1530
|
cwd?: string;
|
|
1531
|
+
/**
|
|
1532
|
+
* Read every stock root at this depth (#4358), over the config's
|
|
1533
|
+
* `terraform.observe` and `terraform.roots.<name>.observe` and the
|
|
1534
|
+
* `CHANT_TERRAFORM_OBSERVE` variable. Not a core option: a caller holding
|
|
1535
|
+
* this lexicon directly (behold) passes it, and the CLI reaches it through
|
|
1536
|
+
* the config or the variable.
|
|
1537
|
+
*/
|
|
1538
|
+
depth?: TerraformObserveDepth;
|
|
1176
1539
|
}
|
|
1177
1540
|
|
|
1178
1541
|
/**
|
|
@@ -1207,22 +1570,50 @@ export async function describeResources(
|
|
|
1207
1570
|
// over state the same green as a live read of the account is the drift lie
|
|
1208
1571
|
// an overlay exists to prevent.
|
|
1209
1572
|
const sources: Record<string, string> = {};
|
|
1573
|
+
const rootReads: Record<string, RootRead> = {};
|
|
1574
|
+
const depthOf = byRoot.size > 0 ? await observeDepths(options, notes) : () => "state" as const;
|
|
1210
1575
|
for (const [root, declared] of byRoot) {
|
|
1211
1576
|
// One fact decides the whole read, and the parse already recorded it on
|
|
1212
1577
|
// every entity of the root (#2103). A root is live when its binary is
|
|
1213
1578
|
// choudoufu AND it declares an estate, so a single entity carrying
|
|
1214
1579
|
// `mode: "live"` settles it for the root.
|
|
1215
1580
|
const live = declared.some((entity) => entity.mode === "live");
|
|
1216
|
-
|
|
1581
|
+
const depth = depthOf(root);
|
|
1217
1582
|
if (live) {
|
|
1218
1583
|
notes.push(
|
|
1219
1584
|
`terraform.roots.${root} is a live root: ownership came from choudoufu's tofu-estate/tofu-address markers via \`live-plan -json\`, not from a state file`,
|
|
1220
1585
|
);
|
|
1586
|
+
if (depth === "plan") {
|
|
1587
|
+
// Both reads (#4371): `live-plan` for ownership, the plan for the
|
|
1588
|
+
// attributes on what it bound. `sources` is known only after the read.
|
|
1589
|
+
const outcome: { answered: LivePlanDepthAnswer; note: string; read?: RootRead } = { answered: "live", note: "" };
|
|
1590
|
+
const read = await observeEntities(declared, livePlanDepthAdapter(root, options.cwd, deps, outcome));
|
|
1591
|
+
for (const entity of declared) sources[entity.name] = outcome.answered;
|
|
1592
|
+
if (outcome.read) rootReads[`terraform.roots.${root}`] = outcome.read;
|
|
1593
|
+
if (outcome.note) notes.push(outcome.note);
|
|
1594
|
+
parts.push(normalizeObservation(read));
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
for (const entity of declared) sources[entity.name] = "live";
|
|
1598
|
+
parts.push(normalizeObservation(await observeEntities(declared, liveAdapter(root, options.cwd, deps))));
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
if (depth === "plan") {
|
|
1602
|
+
// The third depth (#4358). `sources` says which read answered, which on
|
|
1603
|
+
// a plan-depth root is only known after the read: the plan, the state
|
|
1604
|
+
// read it fell back to, or neither, in which case it names the read
|
|
1605
|
+
// that was attempted last.
|
|
1606
|
+
const outcome: { answered: PlanDepthAnswer; note: string; read?: RootRead } = { answered: "declared", note: "" };
|
|
1607
|
+
const read = await observeEntities(declared, planAdapter(root, options.cwd, deps, outcome));
|
|
1608
|
+
const value = outcome.answered === "state" ? "state" : "plan";
|
|
1609
|
+
for (const entity of declared) sources[entity.name] = value;
|
|
1610
|
+
if (outcome.read) rootReads[`terraform.roots.${root}`] = outcome.read;
|
|
1611
|
+
if (outcome.note) notes.push(outcome.note);
|
|
1612
|
+
parts.push(normalizeObservation(read));
|
|
1613
|
+
continue;
|
|
1221
1614
|
}
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1224
|
-
: await observeEntities(declared, adapter(root, options.cwd, deps));
|
|
1225
|
-
parts.push(normalizeObservation(read));
|
|
1615
|
+
for (const entity of declared) sources[entity.name] = "state";
|
|
1616
|
+
parts.push(normalizeObservation(await observeEntities(declared, adapter(root, options.cwd, deps))));
|
|
1226
1617
|
}
|
|
1227
1618
|
|
|
1228
1619
|
const merged = mergeObservations(parts);
|
|
@@ -1253,5 +1644,7 @@ export async function describeResources(
|
|
|
1253
1644
|
}
|
|
1254
1645
|
}
|
|
1255
1646
|
|
|
1256
|
-
|
|
1647
|
+
// A planned create's plan attributes (#4372) ride beside the verdict, never
|
|
1648
|
+
// in `resources`: the block stays absent.
|
|
1649
|
+
return observation(resources, unobserved, merged.queried, merged.notes, undefined, sources, merged.annotations, rootReads);
|
|
1257
1650
|
}
|