flecto 2.0.0 → 3.0.0
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/CHANGELOG.md +533 -0
- package/README.md +345 -211
- package/index.js +826 -77
- package/package.json +9 -7
- package/schemas/flecto-policy-pack-2.0.json +129 -0
- package/src/alerter.js +24 -6
- package/src/config.js +135 -9
- package/src/differ.js +154 -47
- package/src/documents.js +106 -0
- package/src/encrypted.js +573 -0
- package/src/notifiers.js +430 -0
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +23 -1
- package/src/packs/kubernetes.json +112 -0
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/sops.json +61 -0
- package/src/packs/strict-prod.json +11 -1
- package/src/packs/terraform.json +120 -0
- package/src/parser.js +189 -20
- package/src/policy-test.js +124 -0
- package/src/policy.js +815 -30
- package/src/pr-comment.js +480 -0
- package/src/renderer.js +75 -18
- package/src/report.js +653 -0
- package/src/secrets.js +316 -0
- package/src/terraform.js +500 -0
- package/src/watcher.js +27 -15
package/src/terraform.js
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { diffTrees } from './differ.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Terraform plan JSON → Flecto change events.
|
|
6
|
+
*
|
|
7
|
+
* Flecto never runs the `terraform` binary. It reads the JSON Terraform itself
|
|
8
|
+
* produces:
|
|
9
|
+
*
|
|
10
|
+
* terraform plan -out plan.tfplan
|
|
11
|
+
* terraform show -json plan.tfplan > plan.json
|
|
12
|
+
*
|
|
13
|
+
* The conversion flattens each `resource_changes[]` entry into a map of leaf
|
|
14
|
+
* path → value for the "before" and "after" sides, keyed by the resource
|
|
15
|
+
* `address`, then hands both sides to the same {@link diffTrees} every other
|
|
16
|
+
* Flecto command uses. That is what makes `aws_security_group.web` produce
|
|
17
|
+
* paths such as `aws_security_group.web.ingress[0].cidr_blocks[0]`, and it is
|
|
18
|
+
* why `--ignore` behaves identically here and on config files.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {import('./differ.js').ChangeEvent} ChangeEvent
|
|
21
|
+
*
|
|
22
|
+
* @typedef {'create' | 'read' | 'update' | 'delete' | 'replace' | 'no-op'} TerraformAction
|
|
23
|
+
*
|
|
24
|
+
* @typedef {{
|
|
25
|
+
* create: number,
|
|
26
|
+
* update: number,
|
|
27
|
+
* delete: number,
|
|
28
|
+
* replace: number,
|
|
29
|
+
* read: number,
|
|
30
|
+
* noop: number,
|
|
31
|
+
* other: number,
|
|
32
|
+
* resources: number
|
|
33
|
+
* }} TerraformPlanSummary
|
|
34
|
+
*
|
|
35
|
+
* @typedef {{
|
|
36
|
+
* changes: ChangeEvent[],
|
|
37
|
+
* summary: TerraformPlanSummary,
|
|
38
|
+
* formatVersion: string | null,
|
|
39
|
+
* terraformVersion: string | null,
|
|
40
|
+
* warnings: string[]
|
|
41
|
+
* }} TerraformPlanDiff
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Stand-in for a value Terraform cannot resolve until apply. Terraform's own
|
|
46
|
+
* CLI prints this exact phrase, so a Flecto diff reads the same way a
|
|
47
|
+
* `terraform plan` does.
|
|
48
|
+
*/
|
|
49
|
+
export const UNKNOWN_VALUE = '(known after apply)';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Stand-in for a value Terraform marked sensitive. Substituted at parse time —
|
|
53
|
+
* before the policy engine, the envelope, or any formatter can see it — so a
|
|
54
|
+
* value Terraform refuses to print can never leak through Flecto either.
|
|
55
|
+
*/
|
|
56
|
+
export const SENSITIVE_VALUE = '(sensitive value)';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pseudo-attribute carrying the planned action for a resource. `#` cannot occur
|
|
60
|
+
* in a Terraform attribute name, so this can never collide with a real one, and
|
|
61
|
+
* it gives resource-level policy rules a single event to match instead of one
|
|
62
|
+
* per attribute.
|
|
63
|
+
*/
|
|
64
|
+
const ACTION_KEY = '#action';
|
|
65
|
+
|
|
66
|
+
/** Plan format versions this converter has been written against. */
|
|
67
|
+
const SUPPORTED_FORMAT_MAJOR = 1;
|
|
68
|
+
|
|
69
|
+
const GENERATE_HINT = 'Generate one with:\n'
|
|
70
|
+
+ '\n'
|
|
71
|
+
+ ' terraform plan -out plan.tfplan\n'
|
|
72
|
+
+ ' terraform show -json plan.tfplan > plan.json\n'
|
|
73
|
+
+ '\n'
|
|
74
|
+
+ 'Flecto never runs the terraform binary — it only reads the JSON terraform writes.';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {unknown} value
|
|
78
|
+
* @returns {value is Record<string, unknown>}
|
|
79
|
+
*/
|
|
80
|
+
function isPlainObject(value) {
|
|
81
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read one position out of an `after_unknown` / `*_sensitive` mirror.
|
|
86
|
+
*
|
|
87
|
+
* A mirror is `true` (everything at and below this position is unknown or
|
|
88
|
+
* sensitive), `false`/absent, or a nested object/array shaped like the value.
|
|
89
|
+
* `true` propagates down so marking a whole map sensitive marks every leaf.
|
|
90
|
+
* @param {unknown} mirror
|
|
91
|
+
* @param {string | number} key
|
|
92
|
+
* @returns {unknown}
|
|
93
|
+
*/
|
|
94
|
+
function childMirror(mirror, key) {
|
|
95
|
+
if (mirror === true) return true;
|
|
96
|
+
if (mirror && typeof mirror === 'object') {
|
|
97
|
+
return Object.hasOwn(mirror, key) ? mirror[key] : undefined;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {unknown} mirror
|
|
104
|
+
* @returns {string[]}
|
|
105
|
+
*/
|
|
106
|
+
function mirrorKeys(mirror) {
|
|
107
|
+
return isPlainObject(mirror) ? Object.keys(mirror) : [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {unknown} mirror
|
|
112
|
+
* @returns {number}
|
|
113
|
+
*/
|
|
114
|
+
function mirrorLength(mirror) {
|
|
115
|
+
return Array.isArray(mirror) ? mirror.length : 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @typedef {{
|
|
120
|
+
* entries: Array<[string, unknown]>,
|
|
121
|
+
* unknownPaths: Set<string>,
|
|
122
|
+
* sensitivePaths: Set<string>
|
|
123
|
+
* }} FlattenContext
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Flatten one side of a resource change into leaf `path → value` entries.
|
|
128
|
+
*
|
|
129
|
+
* `null` and absent values are dropped rather than emitted, matching how
|
|
130
|
+
* Terraform renders a plan: an optional attribute that is simply unset is not a
|
|
131
|
+
* change. Unsetting an attribute therefore shows up as a removal, and setting
|
|
132
|
+
* one as an addition.
|
|
133
|
+
* @param {unknown} value
|
|
134
|
+
* @param {unknown} unknown the matching `after_unknown` position
|
|
135
|
+
* @param {unknown} sensitive the matching `*_sensitive` position
|
|
136
|
+
* @param {string} path
|
|
137
|
+
* @param {FlattenContext} ctx
|
|
138
|
+
*/
|
|
139
|
+
function flatten(value, unknown, sensitive, path, ctx) {
|
|
140
|
+
if (unknown === true) {
|
|
141
|
+
ctx.entries.push([path, UNKNOWN_VALUE]);
|
|
142
|
+
ctx.unknownPaths.add(path);
|
|
143
|
+
if (sensitive === true) ctx.sensitivePaths.add(path);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const objectShaped = isPlainObject(value)
|
|
148
|
+
|| (value == null && (isPlainObject(unknown) || isPlainObject(sensitive)));
|
|
149
|
+
if (objectShaped) {
|
|
150
|
+
const object = isPlainObject(value) ? value : {};
|
|
151
|
+
const keys = new Set([...Object.keys(object), ...mirrorKeys(unknown), ...mirrorKeys(sensitive)]);
|
|
152
|
+
if (keys.size === 0) {
|
|
153
|
+
pushLeaf(path, isPlainObject(value) ? value : undefined, sensitive, ctx);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
for (const key of keys) {
|
|
157
|
+
flatten(
|
|
158
|
+
Object.hasOwn(object, key) ? object[key] : undefined,
|
|
159
|
+
childMirror(unknown, key),
|
|
160
|
+
childMirror(sensitive, key),
|
|
161
|
+
`${path}.${key}`,
|
|
162
|
+
ctx,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const arrayShaped = Array.isArray(value)
|
|
169
|
+
|| (value == null && (Array.isArray(unknown) || Array.isArray(sensitive)));
|
|
170
|
+
if (arrayShaped) {
|
|
171
|
+
const list = Array.isArray(value) ? value : [];
|
|
172
|
+
const length = Math.max(list.length, mirrorLength(unknown), mirrorLength(sensitive));
|
|
173
|
+
if (length === 0) {
|
|
174
|
+
pushLeaf(path, Array.isArray(value) ? value : undefined, sensitive, ctx);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (let index = 0; index < length; index += 1) {
|
|
178
|
+
flatten(
|
|
179
|
+
list[index],
|
|
180
|
+
childMirror(unknown, index),
|
|
181
|
+
childMirror(sensitive, index),
|
|
182
|
+
`${path}[${index}]`,
|
|
183
|
+
ctx,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
pushLeaf(path, value, sensitive, ctx);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {string} path
|
|
194
|
+
* @param {unknown} value
|
|
195
|
+
* @param {unknown} sensitive
|
|
196
|
+
* @param {FlattenContext} ctx
|
|
197
|
+
*/
|
|
198
|
+
function pushLeaf(path, value, sensitive, ctx) {
|
|
199
|
+
// An attribute that is null or absent on this side is not a value; see
|
|
200
|
+
// flatten() for why that is deliberate.
|
|
201
|
+
if (value === undefined || value === null) return;
|
|
202
|
+
ctx.entries.push([path, value]);
|
|
203
|
+
if (sensitive === true) ctx.sensitivePaths.add(path);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Collapse Terraform's action array into a single verb.
|
|
208
|
+
*
|
|
209
|
+
* `["delete","create"]` and `["create","delete"]` are both a replace — the
|
|
210
|
+
* ordering only says whether the new resource is created before the old one is
|
|
211
|
+
* destroyed. Anything unrecognized is returned verbatim so a future Terraform
|
|
212
|
+
* action is reported rather than silently dropped.
|
|
213
|
+
* @param {unknown} actions
|
|
214
|
+
* @returns {TerraformAction | string}
|
|
215
|
+
*/
|
|
216
|
+
export function normalizeAction(actions) {
|
|
217
|
+
const list = Array.isArray(actions) ? actions.map(String) : [];
|
|
218
|
+
if (list.length === 0) return 'no-op';
|
|
219
|
+
if (list.length === 1) return list[0];
|
|
220
|
+
if (list.length === 2 && list.includes('create') && list.includes('delete')) return 'replace';
|
|
221
|
+
return list.join(',');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {unknown} replacePaths
|
|
226
|
+
* @returns {string[]}
|
|
227
|
+
*/
|
|
228
|
+
function formatReplacePaths(replacePaths) {
|
|
229
|
+
if (!Array.isArray(replacePaths)) return [];
|
|
230
|
+
return replacePaths
|
|
231
|
+
.map((entry) => (Array.isArray(entry) ? entry.map(String).join('.') : String(entry ?? '')))
|
|
232
|
+
.filter(Boolean);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Plain-English sentence describing what Terraform will do to a resource.
|
|
237
|
+
* @param {string} action
|
|
238
|
+
* @param {string} address
|
|
239
|
+
* @param {unknown} replacePaths
|
|
240
|
+
* @returns {string}
|
|
241
|
+
*/
|
|
242
|
+
function actionNote(action, address, replacePaths) {
|
|
243
|
+
if (action === 'create') return `terraform will create ${address}`;
|
|
244
|
+
if (action === 'update') return `terraform will update ${address} in place`;
|
|
245
|
+
if (action === 'delete') return `terraform will destroy ${address}`;
|
|
246
|
+
if (action === 'replace') {
|
|
247
|
+
const forced = formatReplacePaths(replacePaths);
|
|
248
|
+
const because = forced.length > 0 ? ` (forced by: ${forced.join(', ')})` : '';
|
|
249
|
+
return `terraform will destroy and recreate ${address}${because}`;
|
|
250
|
+
}
|
|
251
|
+
return `terraform will apply "${action}" to ${address}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The `#action` marker values for a planned action.
|
|
256
|
+
*
|
|
257
|
+
* `create` becomes an addition, `delete` a removal, `update` a change — and
|
|
258
|
+
* `replace` is deliberately a **removal** carrying the value `"replace"`. A
|
|
259
|
+
* replace destroys the resource before (or right after) recreating it, so
|
|
260
|
+
* representing it as a change would let it pass a reviewer's eye, and any
|
|
261
|
+
* `--fail-on removed` gate as well. Modelling the destruction is the honest
|
|
262
|
+
* reading: the recreated resource is a new object, with a new id, and anything
|
|
263
|
+
* held only on the old one is gone.
|
|
264
|
+
* @param {string} action
|
|
265
|
+
* @returns {{ before?: string, after?: string }}
|
|
266
|
+
*/
|
|
267
|
+
function actionMarker(action) {
|
|
268
|
+
if (action === 'create') return { after: 'create' };
|
|
269
|
+
if (action === 'delete') return { before: 'delete' };
|
|
270
|
+
if (action === 'replace') return { before: 'replace' };
|
|
271
|
+
if (action === 'update') return { before: 'no-op', after: 'update' };
|
|
272
|
+
return { before: 'no-op', after: action };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Unique key for a resource change. Terraform addresses already carry `count` /
|
|
277
|
+
* `for_each` keys, so only a deposed instance can collide with its live one.
|
|
278
|
+
* @param {Record<string, unknown>} change
|
|
279
|
+
* @param {number} index
|
|
280
|
+
* @returns {string}
|
|
281
|
+
*/
|
|
282
|
+
function resourceAddress(change, index) {
|
|
283
|
+
const address = typeof change.address === 'string' && change.address.trim()
|
|
284
|
+
? change.address.trim()
|
|
285
|
+
: `resource_changes[${index}]`;
|
|
286
|
+
const deposed = typeof change.deposed === 'string' && change.deposed.trim()
|
|
287
|
+
? change.deposed.trim()
|
|
288
|
+
: null;
|
|
289
|
+
return deposed ? `${address} (deposed ${deposed})` : address;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {string} existing
|
|
294
|
+
* @param {string} addition
|
|
295
|
+
* @returns {string}
|
|
296
|
+
*/
|
|
297
|
+
function joinNote(existing, addition) {
|
|
298
|
+
return existing ? `${existing}; ${addition}` : addition;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Convert one `resource_changes[]` entry into change events.
|
|
303
|
+
* @param {Record<string, unknown>} resourceChange
|
|
304
|
+
* @param {number} index
|
|
305
|
+
* @param {{ ignorePaths?: string[] }} options
|
|
306
|
+
* @returns {{ action: string, events: ChangeEvent[] }}
|
|
307
|
+
*/
|
|
308
|
+
function resourceChangeToEvents(resourceChange, index, options) {
|
|
309
|
+
const change = isPlainObject(resourceChange.change) ? resourceChange.change : {};
|
|
310
|
+
const action = normalizeAction(change.actions);
|
|
311
|
+
if (action === 'no-op' || action === 'read') return { action, events: [] };
|
|
312
|
+
|
|
313
|
+
const address = resourceAddress(resourceChange, index);
|
|
314
|
+
const marker = actionMarker(action);
|
|
315
|
+
|
|
316
|
+
/** @type {FlattenContext} */
|
|
317
|
+
const beforeCtx = { entries: [], unknownPaths: new Set(), sensitivePaths: new Set() };
|
|
318
|
+
/** @type {FlattenContext} */
|
|
319
|
+
const afterCtx = { entries: [], unknownPaths: new Set(), sensitivePaths: new Set() };
|
|
320
|
+
|
|
321
|
+
// A create has no prior state and a delete has no planned state, so only the
|
|
322
|
+
// populated side is flattened. There is no `before_unknown`: prior state is
|
|
323
|
+
// always fully known.
|
|
324
|
+
if (action !== 'create') {
|
|
325
|
+
flatten(change.before, undefined, change.before_sensitive, address, beforeCtx);
|
|
326
|
+
}
|
|
327
|
+
if (action !== 'delete') {
|
|
328
|
+
flatten(change.after, change.after_unknown, change.after_sensitive, address, afterCtx);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (marker.before !== undefined) beforeCtx.entries.push([`${address}.${ACTION_KEY}`, marker.before]);
|
|
332
|
+
if (marker.after !== undefined) afterCtx.entries.push([`${address}.${ACTION_KEY}`, marker.after]);
|
|
333
|
+
|
|
334
|
+
const sensitivePaths = new Set([...beforeCtx.sensitivePaths, ...afterCtx.sensitivePaths]);
|
|
335
|
+
const unknownPaths = afterCtx.unknownPaths;
|
|
336
|
+
|
|
337
|
+
// The diff runs on the raw values so an equal-looking pair of redacted
|
|
338
|
+
// sensitive values cannot hide a real change; redaction happens immediately
|
|
339
|
+
// afterwards, before anything leaves this module.
|
|
340
|
+
const events = diffTrees(
|
|
341
|
+
Object.fromEntries(beforeCtx.entries),
|
|
342
|
+
Object.fromEntries(afterCtx.entries),
|
|
343
|
+
{ ignorePaths: options.ignorePaths ?? [] },
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const note = actionNote(action, address, change.replace_paths);
|
|
347
|
+
/** @type {ChangeEvent[]} */
|
|
348
|
+
const out = [];
|
|
349
|
+
for (const event of events) {
|
|
350
|
+
// "This computed attribute will be known after apply" says nothing when the
|
|
351
|
+
// attribute is brand new. The same value replacing a known one does, so
|
|
352
|
+
// only pure additions are dropped.
|
|
353
|
+
if (event.type === 'added' && event.after === UNKNOWN_VALUE) continue;
|
|
354
|
+
|
|
355
|
+
/** @type {ChangeEvent} */
|
|
356
|
+
const next = { ...event };
|
|
357
|
+
if (sensitivePaths.has(event.path)) {
|
|
358
|
+
if (Object.hasOwn(next, 'before')) next.before = SENSITIVE_VALUE;
|
|
359
|
+
if (Object.hasOwn(next, 'after')) next.after = SENSITIVE_VALUE;
|
|
360
|
+
next.note = joinNote(next.note ?? '', 'sensitive');
|
|
361
|
+
}
|
|
362
|
+
if (unknownPaths.has(event.path)) {
|
|
363
|
+
next.note = joinNote(next.note ?? '', 'known after apply');
|
|
364
|
+
}
|
|
365
|
+
if (event.path === `${address}.${ACTION_KEY}`) {
|
|
366
|
+
next.note = joinNote(next.note ?? '', note);
|
|
367
|
+
}
|
|
368
|
+
out.push(next);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return { action, events: out };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* True when a parsed JSON value looks like `terraform show -json <planfile>`
|
|
376
|
+
* output.
|
|
377
|
+
* @param {unknown} value
|
|
378
|
+
* @returns {boolean}
|
|
379
|
+
*/
|
|
380
|
+
export function isTerraformPlan(value) {
|
|
381
|
+
return isPlainObject(value)
|
|
382
|
+
&& typeof value.format_version === 'string'
|
|
383
|
+
&& Array.isArray(value.resource_changes);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* True when a parsed JSON value is Terraform *state* rather than a plan —
|
|
388
|
+
* `terraform show -json` with no plan file argument.
|
|
389
|
+
* @param {unknown} value
|
|
390
|
+
* @returns {boolean}
|
|
391
|
+
*/
|
|
392
|
+
function isTerraformState(value) {
|
|
393
|
+
return isPlainObject(value)
|
|
394
|
+
&& typeof value.format_version === 'string'
|
|
395
|
+
&& !Array.isArray(value.resource_changes)
|
|
396
|
+
&& isPlainObject(value.values);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Throw a message that explains how to produce the file Flecto wants.
|
|
401
|
+
* @param {unknown} value
|
|
402
|
+
* @param {string} label
|
|
403
|
+
* @returns {asserts value is Record<string, unknown>}
|
|
404
|
+
*/
|
|
405
|
+
export function assertTerraformPlan(value, label) {
|
|
406
|
+
if (isTerraformPlan(value)) return;
|
|
407
|
+
if (isTerraformState(value)) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`"${label}" is Terraform state, not a plan: it has "values" but no "resource_changes".\n`
|
|
410
|
+
+ `${GENERATE_HINT}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
throw new Error(
|
|
414
|
+
`"${label}" is not Terraform plan JSON: expected top-level "format_version" and "resource_changes".\n`
|
|
415
|
+
+ `${GENERATE_HINT}`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Read and validate a Terraform plan JSON file.
|
|
421
|
+
* @param {string} filepath
|
|
422
|
+
* @returns {Record<string, unknown>}
|
|
423
|
+
*/
|
|
424
|
+
export function readTerraformPlanFile(filepath) {
|
|
425
|
+
let raw;
|
|
426
|
+
try {
|
|
427
|
+
raw = readFileSync(filepath, 'utf8');
|
|
428
|
+
} catch (error) {
|
|
429
|
+
throw new Error(`Cannot read Terraform plan "${filepath}": ${error.message}`);
|
|
430
|
+
}
|
|
431
|
+
let parsed;
|
|
432
|
+
try {
|
|
433
|
+
parsed = JSON.parse(raw);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`Could not parse "${filepath}" as JSON: ${error.message}\n${GENERATE_HINT}`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
assertTerraformPlan(parsed, filepath);
|
|
440
|
+
return parsed;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Convert a parsed Terraform plan into Flecto change events.
|
|
445
|
+
*
|
|
446
|
+
* Only `ignorePaths` is honored from the usual diff options: a plan is already
|
|
447
|
+
* flattened onto Terraform's own indexed addressing (`ingress[0]`), so array
|
|
448
|
+
* identity matching and order-insensitive comparison have nothing to act on.
|
|
449
|
+
* @param {unknown} plan parsed `terraform show -json` output
|
|
450
|
+
* @param {{ ignorePaths?: string[] }} [options]
|
|
451
|
+
* @returns {TerraformPlanDiff}
|
|
452
|
+
*/
|
|
453
|
+
export function diffTerraformPlan(plan, options = {}) {
|
|
454
|
+
assertTerraformPlan(plan, 'plan');
|
|
455
|
+
|
|
456
|
+
/** @type {ChangeEvent[]} */
|
|
457
|
+
const changes = [];
|
|
458
|
+
const summary = {
|
|
459
|
+
create: 0, update: 0, delete: 0, replace: 0, read: 0, noop: 0, other: 0, resources: 0,
|
|
460
|
+
};
|
|
461
|
+
/** @type {string[]} */
|
|
462
|
+
const warnings = [];
|
|
463
|
+
|
|
464
|
+
const formatVersion = typeof plan.format_version === 'string' ? plan.format_version : null;
|
|
465
|
+
const terraformVersion = typeof plan.terraform_version === 'string' ? plan.terraform_version : null;
|
|
466
|
+
const major = Number.parseInt(String(formatVersion ?? '').split('.')[0], 10);
|
|
467
|
+
if (Number.isFinite(major) && major > SUPPORTED_FORMAT_MAJOR) {
|
|
468
|
+
warnings.push(
|
|
469
|
+
`Plan format version ${formatVersion} is newer than the ${SUPPORTED_FORMAT_MAJOR}.x format Flecto understands. `
|
|
470
|
+
+ 'Attributes may be misread; upgrade Flecto if the diff looks wrong.',
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const resourceChanges = /** @type {unknown[]} */ (plan.resource_changes);
|
|
475
|
+
for (const [index, entry] of resourceChanges.entries()) {
|
|
476
|
+
if (!isPlainObject(entry)) {
|
|
477
|
+
warnings.push(`Skipping resource_changes[${index}]: expected an object.`);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
summary.resources += 1;
|
|
481
|
+
const { action, events } = resourceChangeToEvents(entry, index, options);
|
|
482
|
+
if (action === 'no-op') summary.noop += 1;
|
|
483
|
+
else if (action === 'read') summary.read += 1;
|
|
484
|
+
else if (Object.hasOwn(summary, action)) summary[action] += 1;
|
|
485
|
+
else summary.other += 1;
|
|
486
|
+
changes.push(...events);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return { changes, summary, formatVersion, terraformVersion, warnings };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* One-line summary in Terraform's own wording.
|
|
494
|
+
* @param {TerraformPlanSummary} summary
|
|
495
|
+
* @returns {string}
|
|
496
|
+
*/
|
|
497
|
+
export function formatPlanSummary(summary) {
|
|
498
|
+
return `Plan: ${summary.create} to add, ${summary.update} to change, `
|
|
499
|
+
+ `${summary.delete} to destroy, ${summary.replace} to replace.`;
|
|
500
|
+
}
|
package/src/watcher.js
CHANGED
|
@@ -5,6 +5,8 @@ import { renderWarn, renderInfo } from './renderer.js';
|
|
|
5
5
|
|
|
6
6
|
/** @typedef {import('./differ.js').ChangeEvent} ChangeEvent */
|
|
7
7
|
|
|
8
|
+
const NO_STATE = Symbol('no-state');
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* @typedef {Object} WatcherOptions
|
|
10
12
|
* @property {number} [interval] Polling fallback interval in ms (default: 100)
|
|
@@ -12,6 +14,7 @@ import { renderWarn, renderInfo } from './renderer.js';
|
|
|
12
14
|
* @property {string} [mode] Output mode: 'compact' | 'verbose'
|
|
13
15
|
* @property {string[]} [ignorePaths] Key paths to suppress in diffs
|
|
14
16
|
* @property {string | null} [arrayIdKey]
|
|
17
|
+
* @property {boolean} [arrayIdentity]
|
|
15
18
|
* @property {boolean} [arrayIgnoreOrder]
|
|
16
19
|
*/
|
|
17
20
|
|
|
@@ -20,7 +23,7 @@ import { renderWarn, renderInfo } from './renderer.js';
|
|
|
20
23
|
*
|
|
21
24
|
* @param {string} filepath
|
|
22
25
|
* @param {WatcherOptions} options
|
|
23
|
-
* @param {(event: { kind: 'changes', filepath: string, events: ChangeEvent[] } | { kind: 'lifecycle', filepath: string, lifecycle: { type: string, message: string } }) => void} onEvent
|
|
26
|
+
* @param {(event: { kind: 'changes', filepath: string, events: ChangeEvent[] } | { kind: 'lifecycle', filepath: string, lifecycle: { type: string, message: string } }) => void | Promise<void>} onEvent
|
|
24
27
|
* @returns {import('chokidar').FSWatcher}
|
|
25
28
|
*/
|
|
26
29
|
export function startWatcher(filepath, options = {}, onEvent) {
|
|
@@ -30,11 +33,12 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
30
33
|
const diffOpts = {
|
|
31
34
|
ignorePaths,
|
|
32
35
|
arrayIdKey: options.arrayIdKey ?? null,
|
|
36
|
+
arrayIdentity: options.arrayIdentity !== false,
|
|
33
37
|
arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
|
|
34
38
|
};
|
|
35
39
|
|
|
36
|
-
/** @type {unknown |
|
|
37
|
-
let lastGoodState =
|
|
40
|
+
/** @type {unknown | typeof NO_STATE} */
|
|
41
|
+
let lastGoodState = NO_STATE;
|
|
38
42
|
|
|
39
43
|
// Attempt initial parse so we have a baseline before the first write
|
|
40
44
|
try {
|
|
@@ -42,7 +46,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
42
46
|
} catch (err) {
|
|
43
47
|
renderWarn(`Could not parse initial state of "${filepath}": ${err.message}`);
|
|
44
48
|
renderWarn('Watching anyway — will use first successful parse as baseline.');
|
|
45
|
-
onEvent
|
|
49
|
+
safelyEmit(onEvent, {
|
|
46
50
|
kind: 'lifecycle',
|
|
47
51
|
filepath,
|
|
48
52
|
lifecycle: { type: 'initial-parse-failed', message: err.message },
|
|
@@ -68,16 +72,16 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
68
72
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
69
73
|
debounceTimer = setTimeout(() => {
|
|
70
74
|
handleChange(filepath, diffOpts, lastGoodState, (newState, events, lifecycle) => {
|
|
71
|
-
if (newState !==
|
|
75
|
+
if (newState !== NO_STATE) {
|
|
72
76
|
lastGoodState = newState;
|
|
73
77
|
}
|
|
74
78
|
if (lifecycle) {
|
|
75
|
-
onEvent
|
|
79
|
+
safelyEmit(onEvent, { kind: 'lifecycle', filepath, lifecycle });
|
|
76
80
|
}
|
|
77
81
|
if (events.length > 0) {
|
|
78
|
-
onEvent
|
|
82
|
+
safelyEmit(onEvent, { kind: 'changes', filepath, events });
|
|
79
83
|
} else if (reason === 'add') {
|
|
80
|
-
onEvent
|
|
84
|
+
safelyEmit(onEvent, {
|
|
81
85
|
kind: 'lifecycle',
|
|
82
86
|
filepath,
|
|
83
87
|
lifecycle: { type: 'file-restored', message: 'File content reloaded after add event.' },
|
|
@@ -96,7 +100,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
96
100
|
watcher.on('unlink', () => {
|
|
97
101
|
// File temporarily missing; keep last good state and wait for add.
|
|
98
102
|
renderWarn(`File disappeared: "${filepath}" (waiting for it to reappear)`);
|
|
99
|
-
onEvent
|
|
103
|
+
safelyEmit(onEvent, {
|
|
100
104
|
kind: 'lifecycle',
|
|
101
105
|
filepath,
|
|
102
106
|
lifecycle: { type: 'file-missing', message: 'File disappeared; waiting for restore.' },
|
|
@@ -105,7 +109,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
105
109
|
|
|
106
110
|
watcher.on('error', (err) => {
|
|
107
111
|
renderWarn(`Watcher error: ${err.message}`);
|
|
108
|
-
onEvent
|
|
112
|
+
safelyEmit(onEvent, {
|
|
109
113
|
kind: 'lifecycle',
|
|
110
114
|
filepath,
|
|
111
115
|
lifecycle: { type: 'watcher-error', message: err.message },
|
|
@@ -115,12 +119,20 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
115
119
|
return watcher;
|
|
116
120
|
}
|
|
117
121
|
|
|
122
|
+
function safelyEmit(onEvent, event) {
|
|
123
|
+
Promise.resolve()
|
|
124
|
+
.then(() => onEvent(event))
|
|
125
|
+
.catch((err) => {
|
|
126
|
+
renderWarn(`Watcher event handler error: ${err?.message ?? String(err)}`);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
/**
|
|
119
131
|
* Internal: re-parse the file and diff against the previous state.
|
|
120
132
|
* @param {string} filepath
|
|
121
|
-
* @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} diffOpts
|
|
122
|
-
* @param {unknown |
|
|
123
|
-
* @param {(newState: unknown |
|
|
133
|
+
* @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} diffOpts
|
|
134
|
+
* @param {unknown | typeof NO_STATE} lastGoodState
|
|
135
|
+
* @param {(newState: unknown | typeof NO_STATE, events: ChangeEvent[], lifecycle: { type: string, message: string } | null) => void} callback
|
|
124
136
|
*/
|
|
125
137
|
function handleChange(filepath, diffOpts, lastGoodState, callback) {
|
|
126
138
|
let newState;
|
|
@@ -128,11 +140,11 @@ function handleChange(filepath, diffOpts, lastGoodState, callback) {
|
|
|
128
140
|
newState = parseFile(filepath);
|
|
129
141
|
} catch (err) {
|
|
130
142
|
renderWarn(`Parse error — keeping last valid state. ${err.message}`);
|
|
131
|
-
callback(
|
|
143
|
+
callback(NO_STATE, [], { type: 'parse-error', message: err.message });
|
|
132
144
|
return; // don't update lastGoodState
|
|
133
145
|
}
|
|
134
146
|
|
|
135
|
-
if (lastGoodState ===
|
|
147
|
+
if (lastGoodState === NO_STATE) {
|
|
136
148
|
// First successful parse — record as baseline, no diff to show yet
|
|
137
149
|
renderInfo(`Baseline established for "${filepath}".`);
|
|
138
150
|
callback(newState, [], { type: 'baseline-created', message: 'First valid state recorded.' });
|