@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
|
@@ -0,0 +1,428 @@
|
|
|
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
|
+
|
|
56
|
+
/** What stands in for a value flagged sensitive, or equal to one that was. */
|
|
57
|
+
export const SENSITIVE_VALUE = "(sensitive)";
|
|
58
|
+
|
|
59
|
+
/** OpenTofu's and Terraform's own phrase for a value the plan cannot know yet. */
|
|
60
|
+
export const KNOWN_AFTER_APPLY = "(known after apply)";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every attribute the plan depth adds to an observed block, beside the state
|
|
64
|
+
* read's `address`, `root`, `resourceType`, `mode` and `provider`. A block the
|
|
65
|
+
* plan does not touch carries `planAction: "no-op"` and none of the optional
|
|
66
|
+
* ones.
|
|
67
|
+
*/
|
|
68
|
+
export const PLAN_ATTRIBUTE_NAMES = [
|
|
69
|
+
"planAction",
|
|
70
|
+
"planActions",
|
|
71
|
+
"planActionReason",
|
|
72
|
+
"planReplacePaths",
|
|
73
|
+
"planAfterUnknown",
|
|
74
|
+
"planChanges",
|
|
75
|
+
"planDrift",
|
|
76
|
+
"planDriftAction",
|
|
77
|
+
"planRelevantAttributes",
|
|
78
|
+
] as const;
|
|
79
|
+
|
|
80
|
+
/** One attribute that differs between two sides of a change, at leaf level. */
|
|
81
|
+
export interface PlanValueChange {
|
|
82
|
+
/** Attribute path, see the module doc. */
|
|
83
|
+
path: string;
|
|
84
|
+
/** Before value, or `(sensitive)`. `null` where the side had no value. */
|
|
85
|
+
before: unknown;
|
|
86
|
+
/** After value, `(sensitive)`, or `(known after apply)`. */
|
|
87
|
+
after: unknown;
|
|
88
|
+
/** The instance this change belongs to, when the block has more than one. */
|
|
89
|
+
instance?: string;
|
|
90
|
+
/** On a drift entry: the plan reads this attribute (`relevant_attributes`). */
|
|
91
|
+
relevant?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One `resource_changes[]` entry, projected. */
|
|
95
|
+
export interface PlanInstanceChange {
|
|
96
|
+
address: string;
|
|
97
|
+
actions: string[];
|
|
98
|
+
actionReason?: string;
|
|
99
|
+
replacePaths: string[];
|
|
100
|
+
afterUnknown: string[];
|
|
101
|
+
changes: PlanValueChange[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One `resource_drift[]` entry, projected. */
|
|
105
|
+
export interface PlanInstanceDrift {
|
|
106
|
+
address: string;
|
|
107
|
+
actions: string[];
|
|
108
|
+
changes: PlanValueChange[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A plan document, indexed for the plan depth. */
|
|
112
|
+
export interface PlanIndex {
|
|
113
|
+
/**
|
|
114
|
+
* `prior_state`, as the document carries it. The reader indexes it exactly
|
|
115
|
+
* as the state read indexes state, so presence and ownership come from here.
|
|
116
|
+
*/
|
|
117
|
+
priorState: unknown;
|
|
118
|
+
/** `resource_changes[]`, by instance address. */
|
|
119
|
+
changes: Map<string, PlanInstanceChange>;
|
|
120
|
+
/** `resource_drift[]`, by instance address. */
|
|
121
|
+
drift: Map<string, PlanInstanceDrift>;
|
|
122
|
+
/** `relevant_attributes[]`, by instance address: the attribute paths the plan read. */
|
|
123
|
+
relevant: Map<string, string[]>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type Rec = Record<string, unknown>;
|
|
127
|
+
|
|
128
|
+
function isRecord(value: unknown): value is Rec {
|
|
129
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function asString(value: unknown): string | undefined {
|
|
133
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Render one path segment list as a single string. */
|
|
137
|
+
export function renderPath(segments: ReadonlyArray<string | number>): string {
|
|
138
|
+
let out = "";
|
|
139
|
+
for (const segment of segments) {
|
|
140
|
+
if (typeof segment === "number") out += `[${segment}]`;
|
|
141
|
+
else if (/^[A-Za-z_][A-Za-z0-9_-]*$/.test(segment)) out += out ? `.${segment}` : segment;
|
|
142
|
+
else out += `[${JSON.stringify(segment)}]`;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A path from the document (`replace_paths[]`, `relevant_attributes[].attribute`), rendered. */
|
|
148
|
+
function documentPath(value: unknown): string | undefined {
|
|
149
|
+
if (!Array.isArray(value)) return undefined;
|
|
150
|
+
const segments = value.filter((s): s is string | number => typeof s === "string" || typeof s === "number");
|
|
151
|
+
return segments.length > 0 ? renderPath(segments) : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The child of a parallel boolean tree at `key`. `true` is inherited by every descendant. */
|
|
155
|
+
function child(tree: unknown, key: string | number): unknown {
|
|
156
|
+
if (tree === true) return true;
|
|
157
|
+
if (Array.isArray(tree)) return typeof key === "number" ? tree[key] : undefined;
|
|
158
|
+
if (isRecord(tree)) return tree[String(key)];
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function same(a: unknown, b: unknown): boolean {
|
|
163
|
+
if (a === b) return true;
|
|
164
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((x, i) => same(x, b[i]));
|
|
165
|
+
if (isRecord(a) && isRecord(b)) {
|
|
166
|
+
const ka = Object.keys(a);
|
|
167
|
+
return ka.length === Object.keys(b).length && ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && same(a[k], b[k]));
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Every string leaf under a `true` node of `flags`, into `out`. */
|
|
173
|
+
function collectSensitive(value: unknown, flags: unknown, out: Set<string>): void {
|
|
174
|
+
if (flags === true) {
|
|
175
|
+
const walk = (v: unknown): void => {
|
|
176
|
+
if (typeof v === "string" && v.length > 0) out.add(v);
|
|
177
|
+
else if (Array.isArray(v)) v.forEach(walk);
|
|
178
|
+
else if (isRecord(v)) Object.values(v).forEach(walk);
|
|
179
|
+
};
|
|
180
|
+
walk(value);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (Array.isArray(value)) value.forEach((v, i) => collectSensitive(v, child(flags, i), out));
|
|
184
|
+
else if (isRecord(value)) for (const [k, v] of Object.entries(value)) collectSensitive(v, child(flags, k), out);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Every path where `tree` is `true`, as rendered paths. */
|
|
188
|
+
function truePaths(tree: unknown, at: Array<string | number> = []): string[] {
|
|
189
|
+
if (tree === true) return at.length > 0 ? [renderPath(at)] : ["*"];
|
|
190
|
+
if (Array.isArray(tree)) return tree.flatMap((v, i) => truePaths(v, [...at, i]));
|
|
191
|
+
if (isRecord(tree)) return Object.entries(tree).flatMap(([k, v]) => truePaths(v, [...at, k]));
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A scalar for an emitted change: redacted when flagged or equal to a known sensitive string. */
|
|
196
|
+
function shown(value: unknown, flagged: boolean, secrets: ReadonlySet<string>): unknown {
|
|
197
|
+
if (flagged) return SENSITIVE_VALUE;
|
|
198
|
+
if (value === undefined) return null;
|
|
199
|
+
if (typeof value === "string" && secrets.has(value)) return SENSITIVE_VALUE;
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const isContainer = (v: unknown): boolean => Array.isArray(v) || isRecord(v);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The leaf-level differences between two sides of one change. Exported for
|
|
207
|
+
* the redaction tests; see the module doc for the rules.
|
|
208
|
+
*/
|
|
209
|
+
export function diffChange(
|
|
210
|
+
change: {
|
|
211
|
+
before?: unknown;
|
|
212
|
+
after?: unknown;
|
|
213
|
+
before_sensitive?: unknown;
|
|
214
|
+
after_sensitive?: unknown;
|
|
215
|
+
after_unknown?: unknown;
|
|
216
|
+
},
|
|
217
|
+
secrets: ReadonlySet<string>,
|
|
218
|
+
): PlanValueChange[] {
|
|
219
|
+
const out: PlanValueChange[] = [];
|
|
220
|
+
|
|
221
|
+
const walk = (
|
|
222
|
+
before: unknown,
|
|
223
|
+
after: unknown,
|
|
224
|
+
bs: unknown,
|
|
225
|
+
as: unknown,
|
|
226
|
+
au: unknown,
|
|
227
|
+
at: Array<string | number>,
|
|
228
|
+
): void => {
|
|
229
|
+
const path = renderPath(at) || "*";
|
|
230
|
+
|
|
231
|
+
// Unknown until apply. The before side is still walked to its leaves when
|
|
232
|
+
// it is a container, so each leaf keeps its own sensitivity, and the
|
|
233
|
+
// after side of every leaf is the phrase.
|
|
234
|
+
if (au === true) {
|
|
235
|
+
const flagged = bs === true || as === true;
|
|
236
|
+
if (!flagged && isContainer(before) && Object.keys(before as object).length > 0) {
|
|
237
|
+
const keys = Array.isArray(before) ? before.map((_, i) => i) : Object.keys(before as Rec).sort();
|
|
238
|
+
for (const key of keys) {
|
|
239
|
+
walk((before as Rec)[key as string], undefined, child(bs, key), child(as, key), true, [...at, key]);
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
out.push({ path, before: isContainer(before) && !flagged ? before : shown(before, flagged, secrets), after: KNOWN_AFTER_APPLY });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (same(before, after)) return;
|
|
248
|
+
|
|
249
|
+
// A flagged node is emitted whole, as `(sensitive)`, and never walked:
|
|
250
|
+
// there is nothing under it to show. A container beside a flag on the
|
|
251
|
+
// other side is redacted too rather than walked without its flags.
|
|
252
|
+
// Both sides are redacted when either is flagged; see the module doc for
|
|
253
|
+
// the live root whose `before` is never flagged.
|
|
254
|
+
if (bs === true || as === true) {
|
|
255
|
+
const side = (v: unknown): unknown => (v === undefined || v === null ? null : SENSITIVE_VALUE);
|
|
256
|
+
out.push({ path, before: side(before), after: side(after) });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (Array.isArray(before) || Array.isArray(after)) {
|
|
261
|
+
const b = Array.isArray(before) ? before : [];
|
|
262
|
+
const a = Array.isArray(after) ? after : [];
|
|
263
|
+
for (let i = 0; i < Math.max(b.length, a.length); i++) {
|
|
264
|
+
walk(b[i], a[i], child(bs, i), child(as, i), child(au, i), [...at, i]);
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (isRecord(before) || isRecord(after)) {
|
|
269
|
+
const b = isRecord(before) ? before : {};
|
|
270
|
+
const a = isRecord(after) ? after : {};
|
|
271
|
+
for (const key of [...new Set([...Object.keys(b), ...Object.keys(a)])].sort()) {
|
|
272
|
+
walk(b[key], a[key], child(bs, key), child(as, key), child(au, key), [...at, key]);
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
out.push({ path, before: shown(before, false, secrets), after: shown(after, false, secrets) });
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
walk(change.before, change.after, change.before_sensitive, change.after_sensitive, change.after_unknown, []);
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function actionsOf(change: Rec): string[] {
|
|
285
|
+
return Array.isArray(change.actions) ? change.actions.filter((a): a is string => typeof a === "string") : [];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Index a `show -json <planfile>` document. Unknown fields are ignored and a
|
|
290
|
+
* missing section reads as empty, the same tolerance the state read has.
|
|
291
|
+
*/
|
|
292
|
+
export function indexPlan(document: unknown): PlanIndex {
|
|
293
|
+
const doc = isRecord(document) ? document : {};
|
|
294
|
+
const rawChanges = Array.isArray(doc.resource_changes) ? doc.resource_changes.filter(isRecord) : [];
|
|
295
|
+
const rawDrift = Array.isArray(doc.resource_drift) ? doc.resource_drift.filter(isRecord) : [];
|
|
296
|
+
|
|
297
|
+
// Pass one: every string under a sensitive flag, anywhere in the document.
|
|
298
|
+
// Each side is read under both sides' flags, so a live root's unflagged
|
|
299
|
+
// `before` value at a path `after` flags is a secret too (#4371).
|
|
300
|
+
const secrets = new Set<string>();
|
|
301
|
+
for (const entry of [...rawChanges, ...rawDrift]) {
|
|
302
|
+
const change = isRecord(entry.change) ? entry.change : {};
|
|
303
|
+
for (const flags of [change.before_sensitive, change.after_sensitive]) {
|
|
304
|
+
collectSensitive(change.before, flags, secrets);
|
|
305
|
+
collectSensitive(change.after, flags, secrets);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const relevant = new Map<string, string[]>();
|
|
310
|
+
for (const entry of Array.isArray(doc.relevant_attributes) ? doc.relevant_attributes.filter(isRecord) : []) {
|
|
311
|
+
const address = asString(entry.resource);
|
|
312
|
+
const path = documentPath(entry.attribute);
|
|
313
|
+
if (!address || !path) continue;
|
|
314
|
+
const list = relevant.get(address) ?? [];
|
|
315
|
+
if (!list.includes(path)) list.push(path);
|
|
316
|
+
relevant.set(address, list);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const changes = new Map<string, PlanInstanceChange>();
|
|
320
|
+
for (const entry of rawChanges) {
|
|
321
|
+
const address = asString(entry.address);
|
|
322
|
+
if (!address || !isRecord(entry.change)) continue;
|
|
323
|
+
const change = entry.change;
|
|
324
|
+
const replacePaths = (Array.isArray(change.replace_paths) ? change.replace_paths : [])
|
|
325
|
+
.map(documentPath)
|
|
326
|
+
.filter((p): p is string => p !== undefined);
|
|
327
|
+
const actions = actionsOf(change);
|
|
328
|
+
const updates = actions.includes("update") || (actions.includes("create") && actions.includes("delete"));
|
|
329
|
+
changes.set(address, {
|
|
330
|
+
address,
|
|
331
|
+
actions,
|
|
332
|
+
...(asString(entry.action_reason) ? { actionReason: asString(entry.action_reason) } : {}),
|
|
333
|
+
replacePaths,
|
|
334
|
+
afterUnknown: truePaths(change.after_unknown),
|
|
335
|
+
// Only an update or a replace compares two states of one object. A
|
|
336
|
+
// create has no before and a delete no after: neither is an attribute
|
|
337
|
+
// that moved, and both are already the action itself.
|
|
338
|
+
changes: updates ? diffChange(change, secrets) : [],
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const drift = new Map<string, PlanInstanceDrift>();
|
|
343
|
+
for (const entry of rawDrift) {
|
|
344
|
+
const address = asString(entry.address);
|
|
345
|
+
if (!address || !isRecord(entry.change)) continue;
|
|
346
|
+
const reads = relevant.get(address) ?? [];
|
|
347
|
+
drift.set(address, {
|
|
348
|
+
address,
|
|
349
|
+
actions: actionsOf(entry.change),
|
|
350
|
+
changes: diffChange(entry.change, secrets).map((c) =>
|
|
351
|
+
reads.some((r) => c.path === r || c.path.startsWith(`${r}.`) || c.path.startsWith(`${r}[`))
|
|
352
|
+
? { ...c, relevant: true }
|
|
353
|
+
: c,
|
|
354
|
+
),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return { priorState: doc.prior_state, changes, drift, relevant };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The block address an instance address belongs to: every instance key
|
|
363
|
+
* removed, on the resource and on each module call, so `module.a[0].x.y["k"]`
|
|
364
|
+
* belongs to `module.a.x.y`.
|
|
365
|
+
*/
|
|
366
|
+
export function blockAddressOf(instance: string): string {
|
|
367
|
+
return instance.replace(/\[(?:"(?:[^"\\]|\\.)*"|\d+)\]/g, "");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** One action list as one word: `replace` for either order of delete and create. */
|
|
371
|
+
export function actionWord(actions: readonly string[]): string {
|
|
372
|
+
if (actions.includes("create") && actions.includes("delete")) return "replace";
|
|
373
|
+
if (actions.length === 0) return "no-op";
|
|
374
|
+
return actions.join(",");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The plan attributes of one declared block, aggregated over its instances.
|
|
379
|
+
* `instances` are the block's instance addresses in `prior_state`; a block in
|
|
380
|
+
* the plan that has none there is a planned create, which the reader reports
|
|
381
|
+
* absent and annotates with {@link plannedAttributesFor} instead.
|
|
382
|
+
*/
|
|
383
|
+
export function planAttributesFor(address: string, index: PlanIndex): Record<string, unknown> {
|
|
384
|
+
const mine = <T>(map: Map<string, T>): Array<[string, T]> =>
|
|
385
|
+
[...map].filter(([instance]) => blockAddressOf(instance) === address).sort(([a], [b]) => a.localeCompare(b));
|
|
386
|
+
const changes = mine(index.changes);
|
|
387
|
+
const drift = mine(index.drift);
|
|
388
|
+
const relevant = mine(index.relevant);
|
|
389
|
+
const many = new Set([...changes, ...drift].map(([instance]) => instance)).size > 1;
|
|
390
|
+
const tag = (instance: string, c: PlanValueChange): PlanValueChange =>
|
|
391
|
+
many || instance !== address ? { ...c, instance } : c;
|
|
392
|
+
|
|
393
|
+
const words = [...new Set(changes.map(([, c]) => actionWord(c.actions)))].sort();
|
|
394
|
+
const reasons = [...new Set(changes.map(([, c]) => c.actionReason).filter((r): r is string => !!r))].sort();
|
|
395
|
+
const replacePaths = [...new Set(changes.flatMap(([, c]) => c.replacePaths))].sort();
|
|
396
|
+
const afterUnknown = [...new Set(changes.flatMap(([, c]) => c.afterUnknown))].sort();
|
|
397
|
+
const valueChanges = changes.flatMap(([instance, c]) => c.changes.map((v) => tag(instance, v)));
|
|
398
|
+
const driftChanges = drift.flatMap(([instance, d]) => d.changes.map((v) => tag(instance, v)));
|
|
399
|
+
const driftWords = [...new Set(drift.map(([, d]) => actionWord(d.actions)))].sort();
|
|
400
|
+
const relevantPaths = [...new Set(relevant.flatMap(([, paths]) => paths))].sort();
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
planAction: words.length > 0 ? words.join(",") : "no-op",
|
|
404
|
+
...(changes.length > 0 ? { planActions: Object.fromEntries(changes.map(([i, c]) => [i, c.actions])) } : {}),
|
|
405
|
+
...(reasons.length > 0 ? { planActionReason: reasons.join(",") } : {}),
|
|
406
|
+
...(replacePaths.length > 0 ? { planReplacePaths: replacePaths } : {}),
|
|
407
|
+
...(afterUnknown.length > 0 ? { planAfterUnknown: afterUnknown } : {}),
|
|
408
|
+
...(valueChanges.length > 0 ? { planChanges: valueChanges } : {}),
|
|
409
|
+
...(drift.length > 0 ? { planDriftAction: driftWords.join(","), planDrift: driftChanges } : {}),
|
|
410
|
+
...(relevantPaths.length > 0 ? { planRelevantAttributes: relevantPaths } : {}),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The plan attributes of a block that has no row in `prior_state` (#4372),
|
|
416
|
+
* or undefined when the plan has no change for it either. The usual case is
|
|
417
|
+
* a planned create: `planAction: "create"`, `planActions`, and
|
|
418
|
+
* `planAfterUnknown` naming what stays unknown until apply (its `id` among
|
|
419
|
+
* them). A data block read during apply comes back as `planAction: "read"`.
|
|
420
|
+
*
|
|
421
|
+
* The reader puts these on the observation's `annotations`, not on a
|
|
422
|
+
* `resources` row, so the block stays absent and `lifecycle plan` still
|
|
423
|
+
* proposes the create.
|
|
424
|
+
*/
|
|
425
|
+
export function plannedAttributesFor(address: string, index: PlanIndex): Record<string, unknown> | undefined {
|
|
426
|
+
const planned = [...index.changes.keys()].some((instance) => blockAddressOf(instance) === address);
|
|
427
|
+
return planned ? planAttributesFor(address, index) : undefined;
|
|
428
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan depth against a real binary (#4358).
|
|
3
|
+
*
|
|
4
|
+
* `src/__fixtures__/plan-read/` uses the built-in `terraform_data` resource
|
|
5
|
+
* and a `local` backend, so `init` downloads nothing and no credentials are
|
|
6
|
+
* involved. Each suite copies it to a temp project, initialises and applies it
|
|
7
|
+
* at generation 1 (setup a person would do; the read itself never runs
|
|
8
|
+
* `init`), then reads it at the plan depth with `next.tfvars` (generation 2).
|
|
9
|
+
*
|
|
10
|
+
* Gated per binary on `terraform` / `tofu` being on PATH, and skipped rather
|
|
11
|
+
* than failed without one, like the other acceptance suites
|
|
12
|
+
* (`__fixtures__/ACCEPTANCE.md`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
16
|
+
import { appendFileSync, cpSync, existsSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
21
|
+
import { normalizeObservation } from "@intentius/chant/observation";
|
|
22
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
23
|
+
import { buildChangeSet } from "@intentius/chant/lifecycle/change-set";
|
|
24
|
+
import { describeResources } from "./describe-resources";
|
|
25
|
+
import { renderTerraformRoots } from "./hcl/roots";
|
|
26
|
+
import { PLAN_READ_DIR_PREFIX, terraformPlanRead } from "./op/activities/terraform";
|
|
27
|
+
|
|
28
|
+
const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__", "plan-read");
|
|
29
|
+
|
|
30
|
+
function onPath(cmd: string): boolean {
|
|
31
|
+
return spawnSync(cmd, ["version"], { stdio: "ignore" }).status === 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const workspaces: string[] = [];
|
|
35
|
+
const children: ChildProcess[] = [];
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
for (const child of children) child.kill("SIGKILL");
|
|
38
|
+
for (const dir of workspaces) rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Every entry under `dir`, recursively, with its size and mtime: what "nothing new" compares. */
|
|
42
|
+
function snapshot(dir: string): string[] {
|
|
43
|
+
return readdirSync(dir, { recursive: true })
|
|
44
|
+
.map(String)
|
|
45
|
+
.sort()
|
|
46
|
+
.map((rel) => {
|
|
47
|
+
const st = statSync(join(dir, rel));
|
|
48
|
+
return `${rel} ${st.isDirectory() ? "dir" : `${st.size} ${st.mtimeMs}`}`;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Our own scratch dirs currently under the OS temp directory. */
|
|
53
|
+
function planDirs(): string[] {
|
|
54
|
+
return readdirSync(tmpdir()).filter((n) => n.startsWith(PLAN_READ_DIR_PREFIX)).sort();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function run(binary: string, args: string[], cwd: string): void {
|
|
58
|
+
const r = spawnSync(binary, args, { cwd, encoding: "utf-8", env: { ...process.env, TF_IN_AUTOMATION: "1" } });
|
|
59
|
+
if (r.status !== 0) throw new Error(`${binary} ${args.join(" ")} failed: ${r.stderr}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A temp project around a copy of the fixture; `applied` runs init and apply in it first. */
|
|
63
|
+
function project(binary: string, applied: boolean): { dir: string; root: string } {
|
|
64
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-plan-read-acc-"));
|
|
65
|
+
workspaces.push(dir);
|
|
66
|
+
const root = join(dir, "root");
|
|
67
|
+
cpSync(FIXTURE, root, { recursive: true });
|
|
68
|
+
writeFileSync(
|
|
69
|
+
join(dir, "chant.config.json"),
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
lexicons: ["terraform"],
|
|
72
|
+
terraform: { binary, observe: "plan", roots: { app: { dir: "./root", varFiles: ["next.tfvars"] } } },
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
if (applied) {
|
|
76
|
+
run(binary, ["init", "-input=false"], root);
|
|
77
|
+
run(binary, ["apply", "-auto-approve", "-input=false"], root);
|
|
78
|
+
}
|
|
79
|
+
return { dir, root };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function observe(dir: string) {
|
|
83
|
+
const { entities } = await renderTerraformRoots({ projectRoot: dir, roots: { app: { dir: "./root" } } });
|
|
84
|
+
const map = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
85
|
+
for (const [key, entity] of entities) {
|
|
86
|
+
map.set(key, { entityType: entity.entityType, props: (entity as Declarable & { props: Record<string, unknown> }).props });
|
|
87
|
+
}
|
|
88
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: [...map.keys()].sort(), entities: map, cwd: dir });
|
|
89
|
+
return { raw: result, result: normalizeObservation(result), names: [...map.keys()].sort() };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const binary of ["terraform", "tofu"]) {
|
|
93
|
+
describe.skipIf(!onPath(binary))(`the plan depth against a real ${binary} (#4358)`, () => {
|
|
94
|
+
it("puts the plan on the IR, says \"plan\", writes nothing in the root and leaves no temp dir", async () => {
|
|
95
|
+
const { dir, root } = project(binary, true);
|
|
96
|
+
const before = snapshot(root);
|
|
97
|
+
const tempBefore = planDirs();
|
|
98
|
+
|
|
99
|
+
const { raw, result } = await observe(dir);
|
|
100
|
+
|
|
101
|
+
expect(snapshot(root)).toEqual(before);
|
|
102
|
+
expect(planDirs()).toEqual(tempBefore);
|
|
103
|
+
|
|
104
|
+
expect(new Set(Object.values(result.sources))).toEqual(new Set(["plan"]));
|
|
105
|
+
const attrs = (name: string) => result.resources[`app/${name}`]!.attributes as Record<string, unknown>;
|
|
106
|
+
expect(attrs("terraform_data.replaced")).toMatchObject({
|
|
107
|
+
planAction: "replace",
|
|
108
|
+
planReplacePaths: ["triggers_replace"],
|
|
109
|
+
planActionReason: "replace_because_cannot_update",
|
|
110
|
+
});
|
|
111
|
+
expect(attrs("terraform_data.downstream")).toMatchObject({ planAction: "update" });
|
|
112
|
+
expect(attrs("terraform_data.downstream").planAfterUnknown).toContain("input");
|
|
113
|
+
expect(attrs("terraform_data.updated").planChanges).toEqual(
|
|
114
|
+
expect.arrayContaining([{ path: "input.name", before: "app-1", after: "app-2" }]),
|
|
115
|
+
);
|
|
116
|
+
expect(JSON.stringify(raw)).not.toContain("hunter2");
|
|
117
|
+
}, 120_000);
|
|
118
|
+
|
|
119
|
+
it("a block added after the apply is a planned create: absent, annotated, and still a create (#4372)", async () => {
|
|
120
|
+
const { dir, root } = project(binary, true);
|
|
121
|
+
// A new resource the applied state has never seen, reading another
|
|
122
|
+
// block's new id, so its own id and its input are unknown until apply.
|
|
123
|
+
appendFileSync(join(root, "main.tf"), `\nresource "terraform_data" "created" {\n input = terraform_data.replaced.id\n}\n`);
|
|
124
|
+
|
|
125
|
+
const { result, names } = await observe(dir);
|
|
126
|
+
const CREATED = "app/terraform_data.created";
|
|
127
|
+
expect(names).toContain(CREATED);
|
|
128
|
+
expect(result.resources[CREATED]).toBeUndefined();
|
|
129
|
+
expect(result.unobserved[CREATED]).toBeUndefined();
|
|
130
|
+
expect(result.sources[CREATED]).toBe("plan");
|
|
131
|
+
expect(result.annotations?.[CREATED]).toMatchObject({
|
|
132
|
+
planAction: "create",
|
|
133
|
+
planActions: { "terraform_data.created": ["create"] },
|
|
134
|
+
});
|
|
135
|
+
expect(result.annotations?.[CREATED]?.planAfterUnknown).toEqual(expect.arrayContaining(["id", "input"]));
|
|
136
|
+
expect(Object.keys(result.annotations ?? {})).toEqual([CREATED]);
|
|
137
|
+
|
|
138
|
+
const set = buildChangeSet("prod", {
|
|
139
|
+
declared: new Set(names),
|
|
140
|
+
observedNow: result.resources,
|
|
141
|
+
observedThen: undefined,
|
|
142
|
+
unobserved: result.unobserved,
|
|
143
|
+
});
|
|
144
|
+
expect(set.entries.find((e) => e.name === CREATED)?.action).toBe("create");
|
|
145
|
+
}, 120_000);
|
|
146
|
+
|
|
147
|
+
it("reads while another run holds the state lock, which a plan without -lock=false cannot", async () => {
|
|
148
|
+
const { dir, root } = project(binary, true);
|
|
149
|
+
// An apply waiting at its approval prompt holds the local backend's
|
|
150
|
+
// lock for as long as its stdin stays open.
|
|
151
|
+
const holder = spawn(binary, ["apply", "-var", "generation=3"], { cwd: root, stdio: ["pipe", "ignore", "ignore"] });
|
|
152
|
+
children.push(holder);
|
|
153
|
+
const lockInfo = join(root, ".terraform.tfstate.lock.info");
|
|
154
|
+
for (let i = 0; i < 300 && !existsSync(lockInfo); i++) await new Promise((r) => setTimeout(r, 100));
|
|
155
|
+
expect(existsSync(lockInfo)).toBe(true);
|
|
156
|
+
|
|
157
|
+
// The lock really is held: the same plan without -lock=false fails on it.
|
|
158
|
+
const locked = spawnSync(binary, ["plan", "-input=false", "-lock-timeout=0s", "-var-file=next.tfvars", `-out=${join(dir, "x.tfplan")}`], {
|
|
159
|
+
cwd: root,
|
|
160
|
+
encoding: "utf-8",
|
|
161
|
+
});
|
|
162
|
+
expect(locked.status).not.toBe(0);
|
|
163
|
+
expect(locked.stderr).toMatch(/Error acquiring the state lock/);
|
|
164
|
+
|
|
165
|
+
const read = await terraformPlanRead({ root: "app", cwd: dir });
|
|
166
|
+
expect(read.command).toContain(" -lock=false ");
|
|
167
|
+
expect((read.json as { resource_changes?: unknown[] }).resource_changes?.length).toBeGreaterThan(0);
|
|
168
|
+
|
|
169
|
+
holder.stdin?.end();
|
|
170
|
+
holder.kill("SIGKILL");
|
|
171
|
+
}, 120_000);
|
|
172
|
+
|
|
173
|
+
it("refuses a root that was never initialised with the init line, and writes nothing in it", async () => {
|
|
174
|
+
const { dir, root } = project(binary, false);
|
|
175
|
+
const before = snapshot(root);
|
|
176
|
+
const { result } = await observe(dir);
|
|
177
|
+
expect(snapshot(root)).toEqual(before);
|
|
178
|
+
expect(Object.keys(result.resources)).toEqual([]);
|
|
179
|
+
const replaced = result.unobserved["app/terraform_data.replaced"]!;
|
|
180
|
+
expect(replaced.detail).toContain(`${binary} -chdir=${root} init -input=false`);
|
|
181
|
+
expect(result.notes.join("\n")).toMatch(/declared only/);
|
|
182
|
+
}, 120_000);
|
|
183
|
+
});
|
|
184
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { auditRootName, LOCK_FILENAME, parseTerraformRootContent, RESOURCE_TYPE
|
|
|
18
18
|
import { descendModules } from "./hcl/descend";
|
|
19
19
|
import { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership";
|
|
20
20
|
import { terraformCommands } from "./commands";
|
|
21
|
+
import { terraformCapabilityPack } from "./capabilities/adapter";
|
|
21
22
|
|
|
22
23
|
const loadSkills = createSkillsLoader(import.meta.url, [
|
|
23
24
|
{
|
|
@@ -76,6 +77,7 @@ function looksLikeHcl(text: string): boolean {
|
|
|
76
77
|
export const terraformPlugin: LexiconPlugin = {
|
|
77
78
|
behaviourKinds: terraformBehaviourKinds,
|
|
78
79
|
name: "terraform",
|
|
80
|
+
capabilityPack: terraformCapabilityPack,
|
|
79
81
|
serializer: terraformSerializer,
|
|
80
82
|
configSchema: terraformConfigSchema,
|
|
81
83
|
|