flecto 4.1.1 → 4.2.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 +93 -3
- package/README.md +7 -2
- package/index.js +78 -8
- package/package.json +1 -1
- package/src/alerter.js +6 -1
- package/src/config.js +6 -2
- package/src/differ.js +46 -3
- package/src/parser.js +37 -0
- package/src/positions.js +7 -0
- package/src/secrets.js +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,97 @@ The format is based on [Keep a Changelog], and this project adheres to
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.2.0] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
The first npm release since 4.1.1. The `v4.1.2` tag moved the documented Action
|
|
13
|
+
pins but its npm publish failed (the version was never bumped), so the fixes
|
|
14
|
+
below reach npm for the first time here.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`--new-files added`: a pull request that adds a config file no longer has to
|
|
19
|
+
fail the gate.** `flecto ci --snapshot-ref` stopped with "Failed to resolve
|
|
20
|
+
snapshot baseline" whenever a target was not in the base commit, so any PR
|
|
21
|
+
that added a values file or a manifest matched by the gate's glob went red,
|
|
22
|
+
whatever `--fail-on` said. With `--new-files added` (Action input
|
|
23
|
+
`new-files: added`), such a file is reported as new, every key `added`, and
|
|
24
|
+
policies still run on it.
|
|
25
|
+
|
|
26
|
+
The default is unchanged and still fails closed, and the error now names the
|
|
27
|
+
flag. A renamed file looks exactly like a new one, and would turn every
|
|
28
|
+
`changed` into an `added`, which the Action's `policy,error` does not gate,
|
|
29
|
+
so this is opt-in and refused from `.flectorc`, as `snapshotRef` is.
|
|
30
|
+
|
|
31
|
+
Found by replaying Flecto over a real repository's infra history, where one
|
|
32
|
+
commit in twelve added a values file.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **`KEY=VALUE` lists diff by KEY, so one inserted variable is one addition**
|
|
37
|
+
([#226]). Compose writes `environment`, `labels` and `build.args` as a map or
|
|
38
|
+
as a list of `KEY=VALUE` strings, and means the same by both. The list form
|
|
39
|
+
diffed by position: a real pull request that inserted ten `OIDC_*` variables
|
|
40
|
+
read as 31 `changed` rows, each pairing two unrelated variables. It now reads
|
|
41
|
+
as 9 additions under `environment["OIDC_ISSUER_URL"]` and so on, and the
|
|
42
|
+
language server places diagnostics on the right line.
|
|
43
|
+
|
|
44
|
+
Paths for these lists change from `environment[3]` to `environment["KEY"]`.
|
|
45
|
+
`--no-array-id` restores index paths. A list counts only when every item is a
|
|
46
|
+
`KEY=VALUE` or bare `KEY` with unique keys and at least one `=`, so `command:
|
|
47
|
+
[python, app.py]` and `args: [--a=1]` keep their order-sensitive diff.
|
|
48
|
+
|
|
49
|
+
[#226]: https://github.com/myselfsiddharth/Flecto/issues/226
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- **A connection string whose password is a reference is no longer reported as
|
|
54
|
+
a leaked credential** ([#225]). `postgres://app:$(DB_PASSWORD)@db/app`, the
|
|
55
|
+
Kubernetes form of env-var expansion, and `{{ .Values.x }}` Helm expressions
|
|
56
|
+
were flagged `url-credentials` (error), although `${DB_PASSWORD}` already
|
|
57
|
+
was not. A literal password in the same position is still caught. Found on
|
|
58
|
+
a real repository's ConfigMap during outreach.
|
|
59
|
+
|
|
60
|
+
- **The symlinked-baseline check never fired below the repository root.** It
|
|
61
|
+
asked `git ls-tree` for a root-relative path from inside the file's own
|
|
62
|
+
directory, so for `config/app.yaml` it looked up `config/config/app.yaml`,
|
|
63
|
+
found nothing, and let the baseline through as the link's target path. Only
|
|
64
|
+
files at the root were protected.
|
|
65
|
+
|
|
66
|
+
- **Pointing Flecto at a Helm chart says so, instead of reporting broken YAML**
|
|
67
|
+
([#210]). `flecto ci "helm/**/*.yaml"` is the first thing a Helm user tries,
|
|
68
|
+
and it failed with a syntax error pointing inside a chart template that is
|
|
69
|
+
perfectly valid — sending them to debug their chart rather than their command.
|
|
70
|
+
|
|
71
|
+
A YAML file that **fails to parse** and carries Go template delimiters now
|
|
72
|
+
reports what it is and the two ways to read it: render the chart, or point at
|
|
73
|
+
`values.yaml`. The check runs only after a parse failure, so valid YAML
|
|
74
|
+
holding `{{ ... }}` in a string — a Prometheus alert rule, say — is untouched.
|
|
75
|
+
|
|
76
|
+
Found by running Flecto against real repositories instead of our own fixtures.
|
|
77
|
+
|
|
78
|
+
- **`flecto ci --format human` now explains itself** ([#211], fixed by
|
|
79
|
+
[@DYNOSuprovo](https://github.com/DYNOSuprovo) in [#212]). `human` is the
|
|
80
|
+
default for `plan` and `compare` and what `watch` prints, so reaching for it on
|
|
81
|
+
`ci` is the natural mistake — especially when running `ci` locally to see what
|
|
82
|
+
the gate will say. The old error listed the valid values without saying that
|
|
83
|
+
`human` was deliberately excluded, so it read as a typo or an inconsistency. It
|
|
84
|
+
now names the reason and both ways out: `pr-comment` to read a run, `json` to
|
|
85
|
+
parse it. An actual typo still gets the list.
|
|
86
|
+
|
|
87
|
+
- **A command killed by a signal is a failed delivery, not a success**
|
|
88
|
+
([#185], fixed by [@nova-loop](https://github.com/nova-loop) in [#214]).
|
|
89
|
+
`watch --command` checked only the exit code, which is `null` when a signal
|
|
90
|
+
ends the process, so an OOM-killed or timed-out hook counted as delivered:
|
|
91
|
+
`--on-alert-failure` never fired and at-least-once deliveries were marked done.
|
|
92
|
+
The warning now names the signal.
|
|
93
|
+
|
|
94
|
+
[#210]: https://github.com/myselfsiddharth/Flecto/issues/210
|
|
95
|
+
[#185]: https://github.com/myselfsiddharth/Flecto/issues/185
|
|
96
|
+
[#211]: https://github.com/myselfsiddharth/Flecto/issues/211
|
|
97
|
+
[#212]: https://github.com/myselfsiddharth/Flecto/pull/212
|
|
98
|
+
[#214]: https://github.com/myselfsiddharth/Flecto/pull/214
|
|
99
|
+
[#225]: https://github.com/myselfsiddharth/Flecto/issues/225
|
|
100
|
+
|
|
10
101
|
## [4.1.1] - 2026-09-29
|
|
11
102
|
|
|
12
103
|
### Fixed
|
|
@@ -20,8 +111,6 @@ The format is based on [Keep a Changelog], and this project adheres to
|
|
|
20
111
|
The rest of the pitch — that Flecto never runs `terraform`, `helm`, or `sops`,
|
|
21
112
|
and never decrypts — lives in the README, which has room for it.
|
|
22
113
|
|
|
23
|
-
### Fixed
|
|
24
|
-
|
|
25
114
|
- **Documented Action pins moved from `@v4.0.0` to `@v4.1.0`.** Tags are
|
|
26
115
|
immutable, and the fix that stopped the bundled Actions installing the pre-4.0
|
|
27
116
|
CLI shipped *in* 4.1.0 — so while the examples said `@v4.0.0` they pointed at a
|
|
@@ -1322,7 +1411,8 @@ fixed — those runs were never actually gated — but the failure is new.
|
|
|
1322
1411
|
- Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
|
|
1323
1412
|
continuing with no policies.
|
|
1324
1413
|
|
|
1325
|
-
[Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.
|
|
1414
|
+
[Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.2.0...HEAD
|
|
1415
|
+
[4.2.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.1...v4.2.0
|
|
1326
1416
|
[4.1.1]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.0...v4.1.1
|
|
1327
1417
|
[4.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.0.0...v4.1.0
|
|
1328
1418
|
[4.0.0]: https://github.com/myselfsiddharth/Flecto/compare/v3.1.0...v4.0.0
|
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
8
|
<p align="center">
|
|
9
|
+
<a href="https://github.com/marketplace/actions/flecto-pr-risk"><img alt="GitHub Marketplace" src="https://img.shields.io/badge/marketplace-Flecto%20PR%20Risk-34d399?style=flat-square&logo=github&labelColor=0b1220"/></a>
|
|
9
10
|
<a href="https://www.npmjs.com/package/flecto"><img alt="npm" src="https://img.shields.io/npm/v/flecto?style=flat-square&color=34d399&labelColor=0b1220"/></a>
|
|
10
11
|
<a href="https://github.com/myselfsiddharth/Flecto/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/myselfsiddharth/Flecto/ci.yml?branch=main&style=flat-square&label=CI&labelColor=0b1220"/></a>
|
|
11
12
|
<a href="LICENSE"><img alt="MIT" src="https://img.shields.io/badge/license-MIT-8fa3bf?style=flat-square&labelColor=0b1220"/></a>
|
|
@@ -74,6 +75,10 @@ aws_db_instance.main.#action
|
|
|
74
75
|
|
|
75
76
|
## Add it in 60 seconds
|
|
76
77
|
|
|
78
|
+
Flecto PR Risk is on the
|
|
79
|
+
[GitHub Marketplace](https://github.com/marketplace/actions/flecto-pr-risk), so
|
|
80
|
+
`myselfsiddharth/Flecto@v4.1.2` is the whole reference.
|
|
81
|
+
|
|
77
82
|
**Terraform** — point it at the plan JSON:
|
|
78
83
|
|
|
79
84
|
```yaml
|
|
@@ -86,7 +91,7 @@ steps:
|
|
|
86
91
|
- run: |
|
|
87
92
|
terraform plan -out=tf.plan
|
|
88
93
|
terraform show -json tf.plan > plan.json
|
|
89
|
-
- uses: myselfsiddharth/Flecto
|
|
94
|
+
- uses: myselfsiddharth/Flecto@v4.1.2
|
|
90
95
|
with:
|
|
91
96
|
terraform-plan: plan.json
|
|
92
97
|
fail-on: error
|
|
@@ -104,7 +109,7 @@ steps:
|
|
|
104
109
|
with:
|
|
105
110
|
fetch-depth: 0
|
|
106
111
|
- run: helm template ./chart > rendered.yaml
|
|
107
|
-
- uses: myselfsiddharth/Flecto
|
|
112
|
+
- uses: myselfsiddharth/Flecto@v4.1.2
|
|
108
113
|
with:
|
|
109
114
|
targets: rendered.yaml
|
|
110
115
|
policies: kubernetes
|
package/index.js
CHANGED
|
@@ -385,18 +385,51 @@ function gitRepoRelativePath(filePath) {
|
|
|
385
385
|
* @returns {boolean}
|
|
386
386
|
*/
|
|
387
387
|
function baselineEntryIsSymlink(commit, rel, dir) {
|
|
388
|
+
return baselineEntryMode(commit, rel, dir) === '120000';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The git mode of the entry at this path in this commit: '' when the commit
|
|
393
|
+
* has no such path, null when git could not answer.
|
|
394
|
+
* @param {string} commit
|
|
395
|
+
* @param {string} rel
|
|
396
|
+
* @param {string} dir
|
|
397
|
+
* @returns {string | null}
|
|
398
|
+
*/
|
|
399
|
+
function baselineEntryMode(commit, rel, dir) {
|
|
388
400
|
try {
|
|
389
|
-
|
|
401
|
+
// `rel` is relative to the repository root, and without --full-tree
|
|
402
|
+
// ls-tree resolves it against `dir`: for any file below the root it looked
|
|
403
|
+
// up `sub/sub/file`, found nothing, and the symlink check never fired.
|
|
404
|
+
return execFileSync(
|
|
390
405
|
'git',
|
|
391
|
-
['-C', dir, 'ls-tree', '--format=%(objectmode)', '--end-of-options', commit, '--', rel],
|
|
406
|
+
['-C', dir, 'ls-tree', '--full-tree', '--format=%(objectmode)', '--end-of-options', commit, '--', rel],
|
|
392
407
|
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
393
408
|
).trim();
|
|
394
|
-
return entry === '120000';
|
|
395
409
|
} catch {
|
|
396
|
-
return
|
|
410
|
+
return null;
|
|
397
411
|
}
|
|
398
412
|
}
|
|
399
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Returned in place of a baseline state when `--new-files added` is set and the
|
|
416
|
+
* baseline commit does not contain the file. A distinct value rather than an
|
|
417
|
+
* empty document, so the caller cannot mistake "this file is new" for "this
|
|
418
|
+
* file was empty" (#186), and has to say which one it is reporting.
|
|
419
|
+
*/
|
|
420
|
+
const NEW_FILE = Symbol('new file');
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* An empty baseline of the same shape as the head document, so every key
|
|
424
|
+
* diffs as `added`. Not `parseContent(path, '')`: JSON has no empty document.
|
|
425
|
+
* @param {unknown} after
|
|
426
|
+
*/
|
|
427
|
+
function emptyBaselineLike(after) {
|
|
428
|
+
if (Array.isArray(after)) return [];
|
|
429
|
+
if (after !== null && typeof after === 'object') return {};
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
|
|
400
433
|
/**
|
|
401
434
|
* Resolve symlinks where possible, falling back to the input when the path does
|
|
402
435
|
* not exist on disk.
|
|
@@ -518,7 +551,7 @@ function isExplicitPath(value) {
|
|
|
518
551
|
|| value.startsWith('..\\');
|
|
519
552
|
}
|
|
520
553
|
|
|
521
|
-
function readSnapshotStateFromRef(filePath, snapshotRef, store, snapshotFile) {
|
|
554
|
+
function readSnapshotStateFromRef(filePath, snapshotRef, store, snapshotFile, { newFiles = 'fail' } = {}) {
|
|
522
555
|
if (snapshotRef === '') {
|
|
523
556
|
// The same unset-CI-variable case readSnapshotFile refuses. Falling back to
|
|
524
557
|
// the store here would compare against something the operator did not
|
|
@@ -576,6 +609,19 @@ function readSnapshotStateFromRef(filePath, snapshotRef, store, snapshotFile) {
|
|
|
576
609
|
}
|
|
577
610
|
|
|
578
611
|
const rel = gitRepoRelativePath(filePath);
|
|
612
|
+
// A file the baseline commit does not have is a file this change adds. The
|
|
613
|
+
// default is still to fail closed: a rename reads as a new file too, and
|
|
614
|
+
// turns every `changed` into an `added` that the Action's default --fail-on
|
|
615
|
+
// does not gate. `--new-files added` is the explicit, CLI-only opt-in. The
|
|
616
|
+
// commit is the operator's, so the change under review cannot fake absence.
|
|
617
|
+
if (baselineEntryMode(commit, rel, repoDir) === '') {
|
|
618
|
+
if (newFiles === 'added') return NEW_FILE;
|
|
619
|
+
throw new Error(
|
|
620
|
+
`"${rel}" is not in ${ref}, so there is no baseline to diff it against.`
|
|
621
|
+
+ ' If this change adds the file, pass --new-files added to report every key as added'
|
|
622
|
+
+ ' (policies still run); the default fails closed because a rename looks the same.',
|
|
623
|
+
);
|
|
624
|
+
}
|
|
579
625
|
if (baselineEntryIsSymlink(commit, rel, repoDir)) {
|
|
580
626
|
throw new Error(
|
|
581
627
|
`"${rel}" is a symbolic link in ${ref}, so the baseline there is a path, not a configuration.`
|
|
@@ -1309,6 +1355,7 @@ program
|
|
|
1309
1355
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
1310
1356
|
.option('--snapshot-ref <ref>', 'Baseline git revision (a snapshot path also works if it is path-shaped)')
|
|
1311
1357
|
.option('--snapshot-file <path>', 'Baseline snapshot file, never consulted as a git revision')
|
|
1358
|
+
.option('--new-files <mode>', 'A file not in the --snapshot-ref commit: fail (default) | added (every key reported as added)')
|
|
1312
1359
|
.option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
|
|
1313
1360
|
.option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
|
|
1314
1361
|
.option('--format <type>', 'Output format: json | ndjson | sarif | github-annotations | pr-comment', 'json')
|
|
@@ -1347,9 +1394,23 @@ program
|
|
|
1347
1394
|
|
|
1348
1395
|
const ignorePaths = parseCsv(effective.ignore);
|
|
1349
1396
|
const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
|
|
1397
|
+
const newFiles = String(effective.newFiles ?? 'fail');
|
|
1398
|
+
if (newFiles !== 'fail' && newFiles !== 'added') {
|
|
1399
|
+
throw new Error(`--new-files must be fail or added (got ${newFiles})`);
|
|
1400
|
+
}
|
|
1350
1401
|
const format = String(effective.format ?? 'json');
|
|
1351
|
-
if (
|
|
1352
|
-
throw new Error(
|
|
1402
|
+
if (format === 'human') {
|
|
1403
|
+
throw new Error(
|
|
1404
|
+
'--format human is not available for `ci`, whose output is meant to be consumed by a machine. Use pr-comment to read it yourself, or json to parse it. (human is the default for `plan` and `compare`.)',
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
// Listed once: the check and the message drift apart otherwise, and the
|
|
1408
|
+
// message is the only place a user finds out what is allowed. The wording
|
|
1409
|
+
// is unchanged -- this is a dedup, not a rewording.
|
|
1410
|
+
const CI_FORMATS = ['json', 'ndjson', 'sarif', 'github-annotations', 'pr-comment'];
|
|
1411
|
+
if (!CI_FORMATS.includes(format)) {
|
|
1412
|
+
const listed = `${CI_FORMATS.slice(0, -1).join(', ')}, or ${CI_FORMATS.at(-1)}`;
|
|
1413
|
+
throw new Error(`--format must be ${listed}`);
|
|
1353
1414
|
}
|
|
1354
1415
|
const prCommentPost = Boolean(effective.prCommentPost);
|
|
1355
1416
|
if (effective.prProvider && !PR_PROVIDER_IDS.includes(String(effective.prProvider))) {
|
|
@@ -1421,13 +1482,22 @@ program
|
|
|
1421
1482
|
: alignStateWithStore(parseFile(filepath), snapshotStore);
|
|
1422
1483
|
let before;
|
|
1423
1484
|
try {
|
|
1424
|
-
before = readSnapshotStateFromRef(
|
|
1485
|
+
before = readSnapshotStateFromRef(
|
|
1486
|
+
filepath, effective.snapshotRef, snapshotStore, effective.snapshotFile, { newFiles },
|
|
1487
|
+
);
|
|
1425
1488
|
} catch (err) {
|
|
1426
1489
|
throw new Error(
|
|
1427
1490
|
`Failed to resolve snapshot baseline for "${filepath}"` +
|
|
1428
1491
|
`${effective.snapshotRef ? ` (ref: ${effective.snapshotRef})` : ''}: ${err.message}`
|
|
1429
1492
|
);
|
|
1430
1493
|
}
|
|
1494
|
+
if (before === NEW_FILE) {
|
|
1495
|
+
renderWarn(
|
|
1496
|
+
`${relative(cwd, filepath).replaceAll('\\', '/')} is not in ${effective.snapshotRef}: reporting it as a new file,`
|
|
1497
|
+
+ ' every key added (--new-files added).',
|
|
1498
|
+
);
|
|
1499
|
+
before = emptyBaselineLike(after);
|
|
1500
|
+
}
|
|
1431
1501
|
const events = diffTrees(before, after, dOpts);
|
|
1432
1502
|
const rawFindings = await evaluatePolicies(events, {
|
|
1433
1503
|
cwd,
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"access": "public",
|
|
5
5
|
"provenance": true
|
|
6
6
|
},
|
|
7
|
-
"version": "4.
|
|
7
|
+
"version": "4.2.0",
|
|
8
8
|
"description": "Reads your Terraform plan and Kubernetes changes and posts a plain-English risk summary on every pull request, blocking the dangerous ones",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"keywords": [
|
package/src/alerter.js
CHANGED
|
@@ -204,7 +204,12 @@ export function runCommand(command, envelope) {
|
|
|
204
204
|
renderWarn(`Command failed to start: ${err.message}`);
|
|
205
205
|
settle(false);
|
|
206
206
|
});
|
|
207
|
-
child.on('close', (code) => {
|
|
207
|
+
child.on('close', (code, signal) => {
|
|
208
|
+
if (signal) {
|
|
209
|
+
renderWarn(`Command failed (signal ${signal}): ${command}`);
|
|
210
|
+
settle(false);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
208
213
|
if (code && code !== 0) {
|
|
209
214
|
renderWarn(`Command failed (exit ${code}): ${command}`);
|
|
210
215
|
settle(false);
|
package/src/config.js
CHANGED
|
@@ -519,7 +519,11 @@ export function assertSnapshotRefFromCli(effective, cliOverrides) {
|
|
|
519
519
|
if (rcBaselineAllowed()) return;
|
|
520
520
|
// `snapshotFile` picks the baseline just as directly, so it is gated with it
|
|
521
521
|
// rather than left as the way around it.
|
|
522
|
-
|
|
522
|
+
// `newFiles` decides what a file missing from the baseline is compared
|
|
523
|
+
// against, and a rename is a missing file: from .flectorc it would let a pull
|
|
524
|
+
// request move prod.yaml and have every `changed` reported as `added`.
|
|
525
|
+
const flags = { snapshotRef: 'snapshot-ref', snapshotFile: 'snapshot-file', newFiles: 'new-files' };
|
|
526
|
+
for (const [option, flag] of Object.entries(flags)) {
|
|
523
527
|
if (effective[option] === undefined || cliOverrides[option] !== undefined) continue;
|
|
524
528
|
throw new Error(
|
|
525
529
|
`Refusing "${option}" declared in .flectorc: it chooses the baseline every change is`
|
|
@@ -527,7 +531,7 @@ export function assertSnapshotRefFromCli(effective, cliOverrides) {
|
|
|
527
531
|
+ ' pointing it at "HEAD", or at a file it committed, compares every file against itself'
|
|
528
532
|
+ ' and exits 0.\n'
|
|
529
533
|
+ `Declared: ${JSON.stringify(effective[option])}\n`
|
|
530
|
-
+ `Pass --${
|
|
534
|
+
+ `Pass --${flag} on the command line`
|
|
531
535
|
+ ' instead, or set FLECTO_ALLOW_RC_BASELINE=1 if this config is trusted.',
|
|
532
536
|
);
|
|
533
537
|
}
|
package/src/differ.js
CHANGED
|
@@ -361,6 +361,48 @@ function identityMap(items, idKey) {
|
|
|
361
361
|
return map;
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
// `KEY=VALUE`, or a bare `KEY` (Compose's pass-through from the host).
|
|
365
|
+
export const ASSIGNMENT_RE = /^([A-Za-z_][A-Za-z0-9_.-]*)(?:=|$)/;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Key a list of `KEY=VALUE` strings by KEY, or return null when it is not one.
|
|
369
|
+
*
|
|
370
|
+
* Compose writes `environment`, `labels` and `build.args` either as a map or as
|
|
371
|
+
* a list of assignments, and means the same thing by both. Diffed by position,
|
|
372
|
+
* one inserted variable turned every later line into a `changed` pairing two
|
|
373
|
+
* unrelated variables (#226). At least one item must carry an `=`, so a plain
|
|
374
|
+
* word list such as `command: [python, app.py]` keeps its order-sensitive diff.
|
|
375
|
+
* @param {unknown[]} items
|
|
376
|
+
* @returns {Map<string, { value: unknown, index: number }> | null}
|
|
377
|
+
*/
|
|
378
|
+
function assignmentMap(items) {
|
|
379
|
+
/** @type {Map<string, { value: unknown, index: number }>} */
|
|
380
|
+
const map = new Map();
|
|
381
|
+
for (let i = 0; i < items.length; i++) {
|
|
382
|
+
const item = items[i];
|
|
383
|
+
if (typeof item !== 'string') return null;
|
|
384
|
+
const match = ASSIGNMENT_RE.exec(item);
|
|
385
|
+
if (!match || map.has(match[1])) return null;
|
|
386
|
+
map.set(match[1], { value: item, index: i });
|
|
387
|
+
}
|
|
388
|
+
return map;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Both sides are assignment lists and at least one item assigns a value.
|
|
393
|
+
* @param {unknown[]} before
|
|
394
|
+
* @param {unknown[]} after
|
|
395
|
+
* @returns {{ beforeMap: Map<string, { value: unknown, index: number }>, afterMap: Map<string, { value: unknown, index: number }> } | null}
|
|
396
|
+
*/
|
|
397
|
+
function assignmentMaps(before, after) {
|
|
398
|
+
const beforeMap = assignmentMap(before);
|
|
399
|
+
const afterMap = beforeMap && assignmentMap(after);
|
|
400
|
+
if (!beforeMap || !afterMap) return null;
|
|
401
|
+
const assigns = (item) => typeof item === 'string' && item.includes('=');
|
|
402
|
+
if (!before.some(assigns) && !after.some(assigns)) return null;
|
|
403
|
+
return { beforeMap, afterMap };
|
|
404
|
+
}
|
|
405
|
+
|
|
364
406
|
/**
|
|
365
407
|
* Select a configured identity key, or auto-detect id then name.
|
|
366
408
|
* @param {unknown[]} before
|
|
@@ -425,10 +467,11 @@ function arraySignature(value) {
|
|
|
425
467
|
*/
|
|
426
468
|
function diffArrays(before, after, basePath, events, options = {}, ancestors = newAncestors()) {
|
|
427
469
|
const idKey = resolveArrayIdKey(before, after, options);
|
|
470
|
+
const assignments = !idKey && options.arrayIdentity !== false ? assignmentMaps(before, after) : null;
|
|
428
471
|
|
|
429
|
-
if (idKey) {
|
|
430
|
-
const beforeMap =
|
|
431
|
-
|
|
472
|
+
if (idKey || assignments) {
|
|
473
|
+
const { beforeMap, afterMap } = assignments
|
|
474
|
+
?? { beforeMap: identityMap(before, idKey), afterMap: identityMap(after, idKey) };
|
|
432
475
|
|
|
433
476
|
if (beforeMap && afterMap) {
|
|
434
477
|
for (const [key, afterItem] of afterMap) {
|
package/src/parser.js
CHANGED
|
@@ -435,6 +435,30 @@ export function yamlDocumentKeys(docs) {
|
|
|
435
435
|
* @returns {unknown}
|
|
436
436
|
* @throws {Error} on unsupported format or parse failure
|
|
437
437
|
*/
|
|
438
|
+
/**
|
|
439
|
+
* Whether a file that failed to parse as YAML looks like a Helm chart template.
|
|
440
|
+
*
|
|
441
|
+
* Only ever consulted **after** a parse failure, which is what keeps it from
|
|
442
|
+
* firing on valid YAML that happens to contain braces -- a Prometheus rule
|
|
443
|
+
* holding `{{ $labels.instance }}` in a string parses fine and never reaches
|
|
444
|
+
* here. Go template delimiters in a file that did not parse are close to
|
|
445
|
+
* conclusive, and `{{-`, `nindent`, `include "` and a `templates/` path are the
|
|
446
|
+
* shapes that only a chart has.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} raw
|
|
449
|
+
* @param {string} filepath
|
|
450
|
+
* @returns {boolean}
|
|
451
|
+
*/
|
|
452
|
+
function looksLikeHelmTemplate(raw, filepath) {
|
|
453
|
+
if (!/\{\{/u.test(raw) || !/\}\}/u.test(raw)) return false;
|
|
454
|
+
const path = filepath.replace(/\\/gu, '/');
|
|
455
|
+
return /\/templates\//u.test(path)
|
|
456
|
+
|| /\{\{-/u.test(raw)
|
|
457
|
+
|| /\|\s*nindent\b/u.test(raw)
|
|
458
|
+
|| /\{\{[^}]*\binclude\s+"/u.test(raw)
|
|
459
|
+
|| /\{\{[^}]*\.Values\./u.test(raw);
|
|
460
|
+
}
|
|
461
|
+
|
|
438
462
|
export function parseContent(filepath, raw) {
|
|
439
463
|
const ext = extname(filepath).toLowerCase();
|
|
440
464
|
const envLike = isEnvFilename(filepath);
|
|
@@ -469,6 +493,19 @@ export function parseContent(filepath, raw) {
|
|
|
469
493
|
parsed = TOML.parse(raw);
|
|
470
494
|
}
|
|
471
495
|
} catch (err) {
|
|
496
|
+
// A chart template is not YAML, and saying so beats a column number. Without
|
|
497
|
+
// this, `flecto ci "helm/**/*.yaml"` -- the first thing a Helm user tries --
|
|
498
|
+
// reports a syntax error inside a file that is perfectly valid, and sends
|
|
499
|
+
// them to debug their chart instead of their command.
|
|
500
|
+
if ((ext === '.yaml' || ext === '.yml') && looksLikeHelmTemplate(raw, filepath)) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
`"${filepath}" looks like a Helm template, not YAML.\n` +
|
|
503
|
+
'Flecto reads rendered manifests, so render the chart first:\n' +
|
|
504
|
+
' helm template ./chart > rendered.yaml && flecto ci rendered.yaml\n' +
|
|
505
|
+
'Or point Flecto at the values file, which is plain YAML:\n' +
|
|
506
|
+
' flecto ci chart/values.yaml'
|
|
507
|
+
);
|
|
508
|
+
}
|
|
472
509
|
const lineMatch = err.message?.match(/line (\d+)/i);
|
|
473
510
|
const lineInfo = lineMatch ? ` (line ${lineMatch[1]})` : '';
|
|
474
511
|
throw new Error(
|
package/src/positions.js
CHANGED
|
@@ -3,6 +3,7 @@ import yaml from 'js-yaml';
|
|
|
3
3
|
|
|
4
4
|
import { isArmoredAgeFile } from './encrypted.js';
|
|
5
5
|
import { isEnvFilename, isIniFilename, stripJsonComments, yamlDocumentKeys } from './parser.js';
|
|
6
|
+
import { ASSIGNMENT_RE } from './differ.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Where a config path lives in the source text (#142).
|
|
@@ -247,6 +248,12 @@ function itemByIdentity(items, quoted, arrayIdKey) {
|
|
|
247
248
|
if (String(id) === target) matches.add(item);
|
|
248
249
|
}
|
|
249
250
|
}
|
|
251
|
+
// A `KEY=VALUE` list (Compose `environment`, `labels`) is keyed by KEY.
|
|
252
|
+
if (matches.size === 0 && !arrayIdKey) {
|
|
253
|
+
for (const item of items) {
|
|
254
|
+
if (typeof item.value === 'string' && ASSIGNMENT_RE.exec(item.value)?.[1] === target) matches.add(item);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
250
257
|
return matches.size === 1 ? [...matches][0] : null;
|
|
251
258
|
}
|
|
252
259
|
|
package/src/secrets.js
CHANGED
|
@@ -78,8 +78,13 @@ const URL_CREDENTIALS_RE = /[a-z][a-z0-9+.-]{0,32}:\/\/[^\s/:@]+:([^\s/@]+)@/gi;
|
|
|
78
78
|
* Values that only *reference* a secret. Redacting these adds noise and, worse,
|
|
79
79
|
* would make the policy rule fire on configs that correctly keep secrets out of
|
|
80
80
|
* the file.
|
|
81
|
+
*
|
|
82
|
+
* `$(NAME)` is Kubernetes' env-var expansion in a container's env and args, and
|
|
83
|
+
* `{{ ... }}` a Helm or Jinja template expression: in a connection string such
|
|
84
|
+
* as `postgres://app:$(DB_PASSWORD)@db/app` both are the reference, not the
|
|
85
|
+
* credential.
|
|
81
86
|
*/
|
|
82
|
-
const PLACEHOLDER_RE = /^(?:\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z0-9_]+%|<[^>]*>|\*+)$/;
|
|
87
|
+
const PLACEHOLDER_RE = /^(?:\$\{[^}]*\}|\$\([A-Za-z_][A-Za-z0-9_]*\)|\{\{[^}]*\}\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z0-9_]+%|<[^>]*>|\*+)$/;
|
|
83
88
|
|
|
84
89
|
/**
|
|
85
90
|
* High-entropy fallback gates. Every one of these must pass:
|