@intentius/chant-lexicon-terraform 0.124.45 → 0.124.46
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
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan depth's projection (#4358): a saved plan's `show -json` document,
|
|
3
|
+
* turned into the attributes an observed terraform block carries.
|
|
4
|
+
*
|
|
5
|
+
* The state read (`terraform show -json` over state) says what the last apply
|
|
6
|
+
* recorded. A plan says what the next apply would do, and four facts exist
|
|
7
|
+
* only there:
|
|
8
|
+
*
|
|
9
|
+
* | plan document | IR attribute |
|
|
10
|
+
* |---|---|
|
|
11
|
+
* | `resource_changes[].change.actions` | `planAction`, `planActions` |
|
|
12
|
+
* | `resource_changes[].change.replace_paths`, `resource_changes[].action_reason` | `planReplacePaths`, `planActionReason` |
|
|
13
|
+
* | `resource_changes[].change.after_unknown` | `planAfterUnknown` |
|
|
14
|
+
* | `resource_drift[]`, `relevant_attributes[]` | `planDrift`, `planDriftAction`, `planRelevantAttributes` |
|
|
15
|
+
*
|
|
16
|
+
* plus `planChanges`, the attribute-level before and after of each planned
|
|
17
|
+
* update or replace. `PLAN_ATTRIBUTE_NAMES` lists them for a consumer.
|
|
18
|
+
*
|
|
19
|
+
* ## Paths
|
|
20
|
+
*
|
|
21
|
+
* Every attribute path is one string: object keys joined with `.`, list
|
|
22
|
+
* indexes and keys that are not identifiers in brackets, so
|
|
23
|
+
* `lifecycle_commands[0].read`, `tags["kubernetes.io/role"]`. A `chant search
|
|
24
|
+
* attr:planReplacePaths=triggers_replace` term matches by substring over that.
|
|
25
|
+
*
|
|
26
|
+
* ## Sensitive values never leave this module
|
|
27
|
+
*
|
|
28
|
+
* `before_sensitive` and `after_sensitive` are trees parallel to `before` and
|
|
29
|
+
* `after`: `true` at a node marks that node and everything under it. A value
|
|
30
|
+
* under a `true` node is replaced with `(sensitive)` before it is put on an
|
|
31
|
+
* attribute, at any depth, so a sensitive value nested in a map is caught
|
|
32
|
+
* exactly like a sensitive top-level attribute. Only leaves are ever emitted,
|
|
33
|
+
* never a whole object, so no unwalked subtree can carry a secret past the
|
|
34
|
+
* check.
|
|
35
|
+
*
|
|
36
|
+
* A flag on either side redacts both sides of that path. On a stock root the
|
|
37
|
+
* two sides are flagged alike, because state records which paths were
|
|
38
|
+
* sensitive. On a choudoufu live root (#4371) they are not: prior state is
|
|
39
|
+
* rebuilt from the account on every run and carries no marks from the
|
|
40
|
+
* configuration, so an attribute made sensitive by a `sensitive` variable is
|
|
41
|
+
* flagged on `after` only, while `before` holds the live value, which is the
|
|
42
|
+
* previous secret. Recorded in `__fixtures__/live-plan-depth/`.
|
|
43
|
+
*
|
|
44
|
+
* Flags alone are not enough, as a real plan shows: `terraform_data` copies
|
|
45
|
+
* `input` to `output`, and the plan flags `input.token` sensitive while
|
|
46
|
+
* leaving `output.token` unflagged with the same value in it. So every string
|
|
47
|
+
* found under a sensitive flag anywhere in the document is collected first,
|
|
48
|
+
* and any emitted value equal to one of them is redacted too. That catches a
|
|
49
|
+
* copy; it cannot catch a secret a provider transformed (hashed, embedded in a
|
|
50
|
+
* longer string), and the observation page says so.
|
|
51
|
+
*
|
|
52
|
+
* Nothing from `prior_state` is surfaced but each row's `id`, which is the
|
|
53
|
+
* state read's rule too.
|
|
54
|
+
*/
|
|
55
|
+
/** What stands in for a value flagged sensitive, or equal to one that was. */
|
|
56
|
+
export const SENSITIVE_VALUE = "(sensitive)";
|
|
57
|
+
/** OpenTofu's and Terraform's own phrase for a value the plan cannot know yet. */
|
|
58
|
+
export const KNOWN_AFTER_APPLY = "(known after apply)";
|
|
59
|
+
/**
|
|
60
|
+
* Every attribute the plan depth adds to an observed block, beside the state
|
|
61
|
+
* read's `address`, `root`, `resourceType`, `mode` and `provider`. A block the
|
|
62
|
+
* plan does not touch carries `planAction: "no-op"` and none of the optional
|
|
63
|
+
* ones.
|
|
64
|
+
*/
|
|
65
|
+
export const PLAN_ATTRIBUTE_NAMES = [
|
|
66
|
+
"planAction",
|
|
67
|
+
"planActions",
|
|
68
|
+
"planActionReason",
|
|
69
|
+
"planReplacePaths",
|
|
70
|
+
"planAfterUnknown",
|
|
71
|
+
"planChanges",
|
|
72
|
+
"planDrift",
|
|
73
|
+
"planDriftAction",
|
|
74
|
+
"planRelevantAttributes",
|
|
75
|
+
];
|
|
76
|
+
function isRecord(value) {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
function asString(value) {
|
|
80
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
81
|
+
}
|
|
82
|
+
/** Render one path segment list as a single string. */
|
|
83
|
+
export function renderPath(segments) {
|
|
84
|
+
let out = "";
|
|
85
|
+
for (const segment of segments) {
|
|
86
|
+
if (typeof segment === "number")
|
|
87
|
+
out += `[${segment}]`;
|
|
88
|
+
else if (/^[A-Za-z_][A-Za-z0-9_-]*$/.test(segment))
|
|
89
|
+
out += out ? `.${segment}` : segment;
|
|
90
|
+
else
|
|
91
|
+
out += `[${JSON.stringify(segment)}]`;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/** A path from the document (`replace_paths[]`, `relevant_attributes[].attribute`), rendered. */
|
|
96
|
+
function documentPath(value) {
|
|
97
|
+
if (!Array.isArray(value))
|
|
98
|
+
return undefined;
|
|
99
|
+
const segments = value.filter((s) => typeof s === "string" || typeof s === "number");
|
|
100
|
+
return segments.length > 0 ? renderPath(segments) : undefined;
|
|
101
|
+
}
|
|
102
|
+
/** The child of a parallel boolean tree at `key`. `true` is inherited by every descendant. */
|
|
103
|
+
function child(tree, key) {
|
|
104
|
+
if (tree === true)
|
|
105
|
+
return true;
|
|
106
|
+
if (Array.isArray(tree))
|
|
107
|
+
return typeof key === "number" ? tree[key] : undefined;
|
|
108
|
+
if (isRecord(tree))
|
|
109
|
+
return tree[String(key)];
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
function same(a, b) {
|
|
113
|
+
if (a === b)
|
|
114
|
+
return true;
|
|
115
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
116
|
+
return a.length === b.length && a.every((x, i) => same(x, b[i]));
|
|
117
|
+
if (isRecord(a) && isRecord(b)) {
|
|
118
|
+
const ka = Object.keys(a);
|
|
119
|
+
return ka.length === Object.keys(b).length && ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && same(a[k], b[k]));
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
/** Every string leaf under a `true` node of `flags`, into `out`. */
|
|
124
|
+
function collectSensitive(value, flags, out) {
|
|
125
|
+
if (flags === true) {
|
|
126
|
+
const walk = (v) => {
|
|
127
|
+
if (typeof v === "string" && v.length > 0)
|
|
128
|
+
out.add(v);
|
|
129
|
+
else if (Array.isArray(v))
|
|
130
|
+
v.forEach(walk);
|
|
131
|
+
else if (isRecord(v))
|
|
132
|
+
Object.values(v).forEach(walk);
|
|
133
|
+
};
|
|
134
|
+
walk(value);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(value))
|
|
138
|
+
value.forEach((v, i) => collectSensitive(v, child(flags, i), out));
|
|
139
|
+
else if (isRecord(value))
|
|
140
|
+
for (const [k, v] of Object.entries(value))
|
|
141
|
+
collectSensitive(v, child(flags, k), out);
|
|
142
|
+
}
|
|
143
|
+
/** Every path where `tree` is `true`, as rendered paths. */
|
|
144
|
+
function truePaths(tree, at = []) {
|
|
145
|
+
if (tree === true)
|
|
146
|
+
return at.length > 0 ? [renderPath(at)] : ["*"];
|
|
147
|
+
if (Array.isArray(tree))
|
|
148
|
+
return tree.flatMap((v, i) => truePaths(v, [...at, i]));
|
|
149
|
+
if (isRecord(tree))
|
|
150
|
+
return Object.entries(tree).flatMap(([k, v]) => truePaths(v, [...at, k]));
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
/** A scalar for an emitted change: redacted when flagged or equal to a known sensitive string. */
|
|
154
|
+
function shown(value, flagged, secrets) {
|
|
155
|
+
if (flagged)
|
|
156
|
+
return SENSITIVE_VALUE;
|
|
157
|
+
if (value === undefined)
|
|
158
|
+
return null;
|
|
159
|
+
if (typeof value === "string" && secrets.has(value))
|
|
160
|
+
return SENSITIVE_VALUE;
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
const isContainer = (v) => Array.isArray(v) || isRecord(v);
|
|
164
|
+
/**
|
|
165
|
+
* The leaf-level differences between two sides of one change. Exported for
|
|
166
|
+
* the redaction tests; see the module doc for the rules.
|
|
167
|
+
*/
|
|
168
|
+
export function diffChange(change, secrets) {
|
|
169
|
+
const out = [];
|
|
170
|
+
const walk = (before, after, bs, as, au, at) => {
|
|
171
|
+
const path = renderPath(at) || "*";
|
|
172
|
+
// Unknown until apply. The before side is still walked to its leaves when
|
|
173
|
+
// it is a container, so each leaf keeps its own sensitivity, and the
|
|
174
|
+
// after side of every leaf is the phrase.
|
|
175
|
+
if (au === true) {
|
|
176
|
+
const flagged = bs === true || as === true;
|
|
177
|
+
if (!flagged && isContainer(before) && Object.keys(before).length > 0) {
|
|
178
|
+
const keys = Array.isArray(before) ? before.map((_, i) => i) : Object.keys(before).sort();
|
|
179
|
+
for (const key of keys) {
|
|
180
|
+
walk(before[key], undefined, child(bs, key), child(as, key), true, [...at, key]);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
out.push({ path, before: isContainer(before) && !flagged ? before : shown(before, flagged, secrets), after: KNOWN_AFTER_APPLY });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (same(before, after))
|
|
188
|
+
return;
|
|
189
|
+
// A flagged node is emitted whole, as `(sensitive)`, and never walked:
|
|
190
|
+
// there is nothing under it to show. A container beside a flag on the
|
|
191
|
+
// other side is redacted too rather than walked without its flags.
|
|
192
|
+
// Both sides are redacted when either is flagged; see the module doc for
|
|
193
|
+
// the live root whose `before` is never flagged.
|
|
194
|
+
if (bs === true || as === true) {
|
|
195
|
+
const side = (v) => (v === undefined || v === null ? null : SENSITIVE_VALUE);
|
|
196
|
+
out.push({ path, before: side(before), after: side(after) });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (Array.isArray(before) || Array.isArray(after)) {
|
|
200
|
+
const b = Array.isArray(before) ? before : [];
|
|
201
|
+
const a = Array.isArray(after) ? after : [];
|
|
202
|
+
for (let i = 0; i < Math.max(b.length, a.length); i++) {
|
|
203
|
+
walk(b[i], a[i], child(bs, i), child(as, i), child(au, i), [...at, i]);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (isRecord(before) || isRecord(after)) {
|
|
208
|
+
const b = isRecord(before) ? before : {};
|
|
209
|
+
const a = isRecord(after) ? after : {};
|
|
210
|
+
for (const key of [...new Set([...Object.keys(b), ...Object.keys(a)])].sort()) {
|
|
211
|
+
walk(b[key], a[key], child(bs, key), child(as, key), child(au, key), [...at, key]);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
out.push({ path, before: shown(before, false, secrets), after: shown(after, false, secrets) });
|
|
216
|
+
};
|
|
217
|
+
walk(change.before, change.after, change.before_sensitive, change.after_sensitive, change.after_unknown, []);
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
function actionsOf(change) {
|
|
221
|
+
return Array.isArray(change.actions) ? change.actions.filter((a) => typeof a === "string") : [];
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Index a `show -json <planfile>` document. Unknown fields are ignored and a
|
|
225
|
+
* missing section reads as empty, the same tolerance the state read has.
|
|
226
|
+
*/
|
|
227
|
+
export function indexPlan(document) {
|
|
228
|
+
const doc = isRecord(document) ? document : {};
|
|
229
|
+
const rawChanges = Array.isArray(doc.resource_changes) ? doc.resource_changes.filter(isRecord) : [];
|
|
230
|
+
const rawDrift = Array.isArray(doc.resource_drift) ? doc.resource_drift.filter(isRecord) : [];
|
|
231
|
+
// Pass one: every string under a sensitive flag, anywhere in the document.
|
|
232
|
+
// Each side is read under both sides' flags, so a live root's unflagged
|
|
233
|
+
// `before` value at a path `after` flags is a secret too (#4371).
|
|
234
|
+
const secrets = new Set();
|
|
235
|
+
for (const entry of [...rawChanges, ...rawDrift]) {
|
|
236
|
+
const change = isRecord(entry.change) ? entry.change : {};
|
|
237
|
+
for (const flags of [change.before_sensitive, change.after_sensitive]) {
|
|
238
|
+
collectSensitive(change.before, flags, secrets);
|
|
239
|
+
collectSensitive(change.after, flags, secrets);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const relevant = new Map();
|
|
243
|
+
for (const entry of Array.isArray(doc.relevant_attributes) ? doc.relevant_attributes.filter(isRecord) : []) {
|
|
244
|
+
const address = asString(entry.resource);
|
|
245
|
+
const path = documentPath(entry.attribute);
|
|
246
|
+
if (!address || !path)
|
|
247
|
+
continue;
|
|
248
|
+
const list = relevant.get(address) ?? [];
|
|
249
|
+
if (!list.includes(path))
|
|
250
|
+
list.push(path);
|
|
251
|
+
relevant.set(address, list);
|
|
252
|
+
}
|
|
253
|
+
const changes = new Map();
|
|
254
|
+
for (const entry of rawChanges) {
|
|
255
|
+
const address = asString(entry.address);
|
|
256
|
+
if (!address || !isRecord(entry.change))
|
|
257
|
+
continue;
|
|
258
|
+
const change = entry.change;
|
|
259
|
+
const replacePaths = (Array.isArray(change.replace_paths) ? change.replace_paths : [])
|
|
260
|
+
.map(documentPath)
|
|
261
|
+
.filter((p) => p !== undefined);
|
|
262
|
+
const actions = actionsOf(change);
|
|
263
|
+
const updates = actions.includes("update") || (actions.includes("create") && actions.includes("delete"));
|
|
264
|
+
changes.set(address, {
|
|
265
|
+
address,
|
|
266
|
+
actions,
|
|
267
|
+
...(asString(entry.action_reason) ? { actionReason: asString(entry.action_reason) } : {}),
|
|
268
|
+
replacePaths,
|
|
269
|
+
afterUnknown: truePaths(change.after_unknown),
|
|
270
|
+
// Only an update or a replace compares two states of one object. A
|
|
271
|
+
// create has no before and a delete no after: neither is an attribute
|
|
272
|
+
// that moved, and both are already the action itself.
|
|
273
|
+
changes: updates ? diffChange(change, secrets) : [],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const drift = new Map();
|
|
277
|
+
for (const entry of rawDrift) {
|
|
278
|
+
const address = asString(entry.address);
|
|
279
|
+
if (!address || !isRecord(entry.change))
|
|
280
|
+
continue;
|
|
281
|
+
const reads = relevant.get(address) ?? [];
|
|
282
|
+
drift.set(address, {
|
|
283
|
+
address,
|
|
284
|
+
actions: actionsOf(entry.change),
|
|
285
|
+
changes: diffChange(entry.change, secrets).map((c) => reads.some((r) => c.path === r || c.path.startsWith(`${r}.`) || c.path.startsWith(`${r}[`))
|
|
286
|
+
? { ...c, relevant: true }
|
|
287
|
+
: c),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return { priorState: doc.prior_state, changes, drift, relevant };
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The block address an instance address belongs to: every instance key
|
|
294
|
+
* removed, on the resource and on each module call, so `module.a[0].x.y["k"]`
|
|
295
|
+
* belongs to `module.a.x.y`.
|
|
296
|
+
*/
|
|
297
|
+
export function blockAddressOf(instance) {
|
|
298
|
+
return instance.replace(/\[(?:"(?:[^"\\]|\\.)*"|\d+)\]/g, "");
|
|
299
|
+
}
|
|
300
|
+
/** One action list as one word: `replace` for either order of delete and create. */
|
|
301
|
+
export function actionWord(actions) {
|
|
302
|
+
if (actions.includes("create") && actions.includes("delete"))
|
|
303
|
+
return "replace";
|
|
304
|
+
if (actions.length === 0)
|
|
305
|
+
return "no-op";
|
|
306
|
+
return actions.join(",");
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The plan attributes of one declared block, aggregated over its instances.
|
|
310
|
+
* `instances` are the block's instance addresses in `prior_state`; a block in
|
|
311
|
+
* the plan that has none there is a planned create, which the reader reports
|
|
312
|
+
* absent and annotates with {@link plannedAttributesFor} instead.
|
|
313
|
+
*/
|
|
314
|
+
export function planAttributesFor(address, index) {
|
|
315
|
+
const mine = (map) => [...map].filter(([instance]) => blockAddressOf(instance) === address).sort(([a], [b]) => a.localeCompare(b));
|
|
316
|
+
const changes = mine(index.changes);
|
|
317
|
+
const drift = mine(index.drift);
|
|
318
|
+
const relevant = mine(index.relevant);
|
|
319
|
+
const many = new Set([...changes, ...drift].map(([instance]) => instance)).size > 1;
|
|
320
|
+
const tag = (instance, c) => many || instance !== address ? { ...c, instance } : c;
|
|
321
|
+
const words = [...new Set(changes.map(([, c]) => actionWord(c.actions)))].sort();
|
|
322
|
+
const reasons = [...new Set(changes.map(([, c]) => c.actionReason).filter((r) => !!r))].sort();
|
|
323
|
+
const replacePaths = [...new Set(changes.flatMap(([, c]) => c.replacePaths))].sort();
|
|
324
|
+
const afterUnknown = [...new Set(changes.flatMap(([, c]) => c.afterUnknown))].sort();
|
|
325
|
+
const valueChanges = changes.flatMap(([instance, c]) => c.changes.map((v) => tag(instance, v)));
|
|
326
|
+
const driftChanges = drift.flatMap(([instance, d]) => d.changes.map((v) => tag(instance, v)));
|
|
327
|
+
const driftWords = [...new Set(drift.map(([, d]) => actionWord(d.actions)))].sort();
|
|
328
|
+
const relevantPaths = [...new Set(relevant.flatMap(([, paths]) => paths))].sort();
|
|
329
|
+
return {
|
|
330
|
+
planAction: words.length > 0 ? words.join(",") : "no-op",
|
|
331
|
+
...(changes.length > 0 ? { planActions: Object.fromEntries(changes.map(([i, c]) => [i, c.actions])) } : {}),
|
|
332
|
+
...(reasons.length > 0 ? { planActionReason: reasons.join(",") } : {}),
|
|
333
|
+
...(replacePaths.length > 0 ? { planReplacePaths: replacePaths } : {}),
|
|
334
|
+
...(afterUnknown.length > 0 ? { planAfterUnknown: afterUnknown } : {}),
|
|
335
|
+
...(valueChanges.length > 0 ? { planChanges: valueChanges } : {}),
|
|
336
|
+
...(drift.length > 0 ? { planDriftAction: driftWords.join(","), planDrift: driftChanges } : {}),
|
|
337
|
+
...(relevantPaths.length > 0 ? { planRelevantAttributes: relevantPaths } : {}),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* The plan attributes of a block that has no row in `prior_state` (#4372),
|
|
342
|
+
* or undefined when the plan has no change for it either. The usual case is
|
|
343
|
+
* a planned create: `planAction: "create"`, `planActions`, and
|
|
344
|
+
* `planAfterUnknown` naming what stays unknown until apply (its `id` among
|
|
345
|
+
* them). A data block read during apply comes back as `planAction: "read"`.
|
|
346
|
+
*
|
|
347
|
+
* The reader puts these on the observation's `annotations`, not on a
|
|
348
|
+
* `resources` row, so the block stays absent and `lifecycle plan` still
|
|
349
|
+
* proposes the create.
|
|
350
|
+
*/
|
|
351
|
+
export function plannedAttributesFor(address, index) {
|
|
352
|
+
const planned = [...index.changes.keys()].some((instance) => blockAddressOf(instance) === address);
|
|
353
|
+
return planned ? planAttributesFor(address, index) : undefined;
|
|
354
|
+
}
|
package/dist/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,aAAa,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAoElF;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,EAAE,aAsR7B,CAAC"}
|
package/dist/plugin.js
CHANGED
|
@@ -15,6 +15,7 @@ import { auditRootName, LOCK_FILENAME, parseTerraformRootContent, RESOURCE_TYPE
|
|
|
15
15
|
import { descendModules } from "./hcl/descend.js";
|
|
16
16
|
import { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership.js";
|
|
17
17
|
import { terraformCommands } from "./commands.js";
|
|
18
|
+
import { terraformCapabilityPack } from "./capabilities/adapter.js";
|
|
18
19
|
const loadSkills = createSkillsLoader(import.meta.url, [
|
|
19
20
|
{
|
|
20
21
|
file: "chant-terraform.md",
|
|
@@ -66,6 +67,7 @@ function looksLikeHcl(text) {
|
|
|
66
67
|
export const terraformPlugin = {
|
|
67
68
|
behaviourKinds: terraformBehaviourKinds,
|
|
68
69
|
name: "terraform",
|
|
70
|
+
capabilityPack: terraformCapabilityPack,
|
|
69
71
|
serializer: terraformSerializer,
|
|
70
72
|
configSchema: terraformConfigSchema,
|
|
71
73
|
// ── Required lifecycle methods ────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-terraform",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.46",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src/",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"access": "public"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@intentius/chant": "^0.124.
|
|
70
|
+
"@intentius/chant": "^0.124.46",
|
|
71
71
|
"zod": "^4.3.6"
|
|
72
72
|
},
|
|
73
73
|
"description": "terraform lexicon for chant, existing Terraform root modules as declarable entities",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# What the gated acceptance suites have actually run against
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Eight `describe` blocks across five files run this lexicon against a real
|
|
4
4
|
binary rather than a fixture, and every one of them skips rather than fails
|
|
5
5
|
when its dependency is absent. A skip is silent by design, so without this
|
|
6
6
|
file nothing in the tree says which of them has ever passed, against what, or
|
|
@@ -211,6 +211,58 @@ With the Init phase back, the same run reaches its gate:
|
|
|
211
211
|
its gate
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
+
### 6. `the plan depth against a real terraform` / `tofu`
|
|
215
|
+
|
|
216
|
+
`../plan-read.acceptance.test.ts`, one block per binary, three tests each, over
|
|
217
|
+
the `plan-read/` fixture (built-in `terraform_data`, `local` backend, so no
|
|
218
|
+
registry, no network and no credentials). The first reads the plan depth
|
|
219
|
+
through `describeResources` and checks the IR attributes, `sources` reading
|
|
220
|
+
`"plan"`, no secret in the result, nothing new or changed in the root, and no
|
|
221
|
+
`chant-terraform-plan-*` directory left in the temp dir. The second holds the
|
|
222
|
+
state lock with an `apply` waiting at its prompt, shows a plan without
|
|
223
|
+
`-lock=false` failing on `Error acquiring the state lock`, and reads through
|
|
224
|
+
it. The third refuses a never-initialised root with the init line and writes
|
|
225
|
+
nothing in it.
|
|
226
|
+
|
|
227
|
+
Gated on `terraform` / `tofu` on PATH, each block on its own binary.
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
npx vitest run lexicons/terraform/src/plan-read.acceptance.test.ts
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Last passed: 2026-10-23, `Terraform v1.15.8` and `OpenTofu v1.12.5` on
|
|
234
|
+
`darwin_arm64`, 6 passed, by chant #4358. Proved red the same day: with
|
|
235
|
+
`-lock=false` taken out of `terraformPlanReadCommand`, both lock tests failed
|
|
236
|
+
on `Error acquiring the state lock`.
|
|
237
|
+
|
|
238
|
+
### 7. `the plan depth on a live root against a real choudoufu`
|
|
239
|
+
|
|
240
|
+
`../live-plan-depth.acceptance.e2e.test.ts` (#4371), one test, in the e2e
|
|
241
|
+
project because it takes about a minute. It copies `live-estate/` under a
|
|
242
|
+
fresh estate name, applies it with three files held back, creates the two
|
|
243
|
+
unowned log groups, drifts a log group's retention and a VPC tag out of band,
|
|
244
|
+
and changes the value of a `sensitive` variable the VPC carries as a tag. Then
|
|
245
|
+
one `describeResources` at the plan depth with the real activities: bound
|
|
246
|
+
blocks carry the plan's attributes (the drift as a planned `update`),
|
|
247
|
+
ownership and the marker still come from `live-plan`, the foreign and
|
|
248
|
+
adoptable blocks carry none, `sources` reads `live+plan`, neither secret
|
|
249
|
+
appears anywhere in the result, and the read adds nothing to the root (the
|
|
250
|
+
plan file and the `live-plan` document included, #4379) or the temp directory.
|
|
251
|
+
|
|
252
|
+
Gated on a `choudoufu` and an `aws` CLI on PATH and on
|
|
253
|
+
`CHOUDOUFU_EMULATOR_ENDPOINT`.
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
npx vitest run --project e2e lexicons/terraform/src/live-plan-depth.acceptance.e2e.test.ts
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Last passed: 2026-10-23, choudoufu v0.30.0 (release binary, verified against
|
|
260
|
+
`SHA256SUMS`) with floci
|
|
261
|
+
`ghcr.io/lex00/floci@sha256:5ec92ae8046445d62c0009590dd09f04560886e5a5ef31248cb3fcc09b2961ec`,
|
|
262
|
+
by chant #4371. Proved red the same day: with `plan-observation.ts`'s
|
|
263
|
+
redaction put back to flagging each side by its own marks, the old secret
|
|
264
|
+
came through as `tags.token`'s `before`.
|
|
265
|
+
|
|
214
266
|
## Running everything that can run on a machine with no choudoufu
|
|
215
267
|
|
|
216
268
|
```
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# The capabilities the ledger doesn't exercise, each with a case that holds
|
|
2
|
+
# and one that doesn't (#4375).
|
|
3
|
+
|
|
4
|
+
# in-default-vpc: an instance in a subnet of the default VPC, and one outside it.
|
|
5
|
+
resource "aws_default_vpc" "default" {}
|
|
6
|
+
resource "aws_subnet" "legacy" {
|
|
7
|
+
vpc_id = aws_default_vpc.default.id
|
|
8
|
+
cidr_block = "172.31.200.0/24"
|
|
9
|
+
}
|
|
10
|
+
resource "aws_vpc" "main" { cidr_block = "10.9.0.0/16" }
|
|
11
|
+
resource "aws_subnet" "main" {
|
|
12
|
+
vpc_id = aws_vpc.main.id
|
|
13
|
+
cidr_block = "10.9.0.0/24"
|
|
14
|
+
}
|
|
15
|
+
resource "aws_instance" "legacy" {
|
|
16
|
+
ami = "ami-12345678"
|
|
17
|
+
instance_type = "t3.micro"
|
|
18
|
+
subnet_id = aws_subnet.legacy.id
|
|
19
|
+
vpc_security_group_ids = [aws_security_group.used.id]
|
|
20
|
+
}
|
|
21
|
+
resource "aws_instance" "current" {
|
|
22
|
+
ami = "ami-12345678"
|
|
23
|
+
instance_type = "t3.micro"
|
|
24
|
+
subnet_id = aws_subnet.main.id
|
|
25
|
+
vpc_security_group_ids = [aws_security_group.used.id]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# attached-to-nothing: a group no instance uses, a policy no role holds, and
|
|
29
|
+
# a subnet nothing sits in.
|
|
30
|
+
resource "aws_security_group" "used" { vpc_id = aws_vpc.main.id }
|
|
31
|
+
resource "aws_security_group" "unused" { vpc_id = aws_vpc.main.id }
|
|
32
|
+
resource "aws_subnet" "empty" {
|
|
33
|
+
vpc_id = aws_vpc.main.id
|
|
34
|
+
cidr_block = "10.9.1.0/24"
|
|
35
|
+
}
|
|
36
|
+
resource "aws_iam_policy" "orphan" {
|
|
37
|
+
policy = jsonencode({
|
|
38
|
+
Version = "2012-10-17"
|
|
39
|
+
Statement = [{ Effect = "Allow", Action = "s3:ListBucket", Resource = aws_s3_bucket.open.arn }]
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# publicly-readable: a bucket policy for principal "*", once with nothing in
|
|
44
|
+
# its way and once behind a public-access block.
|
|
45
|
+
resource "aws_s3_bucket" "open" { bucket = "catalog-open" }
|
|
46
|
+
resource "aws_s3_bucket_policy" "open" {
|
|
47
|
+
bucket = aws_s3_bucket.open.id
|
|
48
|
+
policy = jsonencode({
|
|
49
|
+
Version = "2012-10-17"
|
|
50
|
+
Statement = [{ Effect = "Allow", Principal = "*", Action = "s3:GetObject", Resource = "${aws_s3_bucket.open.arn}/*" }]
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
resource "aws_s3_bucket" "blocked" { bucket = "catalog-blocked" }
|
|
54
|
+
resource "aws_s3_bucket_policy" "blocked" {
|
|
55
|
+
bucket = aws_s3_bucket.blocked.id
|
|
56
|
+
policy = jsonencode({
|
|
57
|
+
Version = "2012-10-17"
|
|
58
|
+
Statement = [{ Effect = "Allow", Principal = "*", Action = "s3:GetObject", Resource = "${aws_s3_bucket.blocked.arn}/*" }]
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
resource "aws_s3_bucket_public_access_block" "blocked" {
|
|
62
|
+
bucket = aws_s3_bucket.blocked.id
|
|
63
|
+
block_public_policy = true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# holds-secret and encrypted-at-rest: a SecureString and a plain parameter,
|
|
67
|
+
# an encrypted file system and an unencrypted database.
|
|
68
|
+
resource "aws_ssm_parameter" "token" {
|
|
69
|
+
name = "/catalog/token"
|
|
70
|
+
type = "SecureString"
|
|
71
|
+
value = "not-a-real-token"
|
|
72
|
+
}
|
|
73
|
+
resource "aws_ssm_parameter" "plain" {
|
|
74
|
+
name = "/catalog/plain"
|
|
75
|
+
type = "String"
|
|
76
|
+
value = "hello"
|
|
77
|
+
}
|
|
78
|
+
resource "aws_efs_file_system" "shared" { encrypted = true }
|
|
79
|
+
resource "aws_db_instance" "plain" {
|
|
80
|
+
engine = "postgres"
|
|
81
|
+
instance_class = "db.t3.micro"
|
|
82
|
+
allocated_storage = 20
|
|
83
|
+
username = "catalog"
|
|
84
|
+
password = "not-a-real-password"
|
|
85
|
+
storage_encrypted = false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# grants: an explicit Deny on the same pair cancels the Allow.
|
|
89
|
+
resource "aws_iam_role" "denied" {
|
|
90
|
+
assume_role_policy = jsonencode({
|
|
91
|
+
Version = "2012-10-17"
|
|
92
|
+
Statement = [{ Effect = "Allow", Principal = { Service = "ec2.amazonaws.com" }, Action = "sts:AssumeRole" }]
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
resource "aws_iam_role_policy" "denied" {
|
|
96
|
+
role = aws_iam_role.denied.id
|
|
97
|
+
policy = jsonencode({
|
|
98
|
+
Version = "2012-10-17"
|
|
99
|
+
Statement = [
|
|
100
|
+
{ Effect = "Allow", Action = "s3:*", Resource = aws_s3_bucket.open.arn },
|
|
101
|
+
{ Effect = "Deny", Action = "s3:*", Resource = aws_s3_bucket.open.arn },
|
|
102
|
+
{ Effect = "Allow", Action = "s3:GetObject", Resource = aws_s3_bucket.blocked.arn },
|
|
103
|
+
]
|
|
104
|
+
})
|
|
105
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ChantConfig } from "@intentius/chant/config";
|
|
2
|
+
import "@intentius/chant-lexicon-terraform";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
lexicons: ["terraform"],
|
|
6
|
+
// The estates hold the findings these rules report on purpose.
|
|
7
|
+
lint: { rules: { TF022: "off", TF030: "off", TF033: "off" } },
|
|
8
|
+
terraform: {
|
|
9
|
+
roots: {
|
|
10
|
+
ledger: { dir: "./ledger" },
|
|
11
|
+
catalog: { dir: "./catalog" },
|
|
12
|
+
findings: { dir: "./findings" },
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
} satisfies ChantConfig;
|