@nitra/cfr 0.1.2 → 0.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/README.md +76 -5
- package/bin/cli.mjs +22 -120
- package/lib/check.mjs +135 -0
- package/lib/kcc-inventory.mjs +457 -0
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
# @nitra/cfr
|
|
2
2
|
|
|
3
|
+
A handful of small k8s/GitOps CLI utilities, one `npx`/`bunx` away — no
|
|
4
|
+
install, no dependencies. Two commands so far:
|
|
5
|
+
|
|
6
|
+
- **`check`** (default) — verify a Kustomize directory's `resources:` list
|
|
7
|
+
matches what's actually on disk
|
|
8
|
+
- **`kcc-inventory`** — diff a GCP Config Connector namespace against the
|
|
9
|
+
live project to find drift
|
|
10
|
+
|
|
11
|
+
## `check`
|
|
12
|
+
|
|
3
13
|
Kustomize's `resources:` field is an **explicit list**, not a glob. Add a
|
|
4
14
|
YAML manifest to a directory managed by a [Flux](https://fluxcd.io)
|
|
5
15
|
`Kustomization` without listing it in `resources:`, and
|
|
6
16
|
`kustomize-controller` silently skips it — no error, no warning, the
|
|
7
17
|
object just never reaches the cluster.
|
|
8
18
|
|
|
9
|
-
This
|
|
19
|
+
This command catches that drift before it ships: it compares every
|
|
10
20
|
`*.yaml`/`*.yml` file physically present in a directory against the
|
|
11
21
|
`resources:` list in its `kustomization.yaml`, in both directions.
|
|
12
22
|
|
|
13
|
-
|
|
23
|
+
### Usage
|
|
14
24
|
|
|
15
25
|
```sh
|
|
16
26
|
npx @nitra/cfr [dir-or-kustomization.yaml ...]
|
|
27
|
+
npx @nitra/cfr check [dir-or-kustomization.yaml ...] # same, explicit
|
|
17
28
|
```
|
|
18
29
|
|
|
19
30
|
No arguments checks `.`. Point it at one or more directories (or direct
|
|
@@ -47,7 +58,7 @@ jobs:
|
|
|
47
58
|
- run: npx @nitra/cfr flux/clusters/production
|
|
48
59
|
```
|
|
49
60
|
|
|
50
|
-
|
|
61
|
+
### What it checks
|
|
51
62
|
|
|
52
63
|
For each target directory:
|
|
53
64
|
|
|
@@ -61,13 +72,73 @@ Entries containing `/` (subdirectories, components) or a URL scheme
|
|
|
61
72
|
against the specific footgun of a loose file sitting next to
|
|
62
73
|
`kustomization.yaml` that nobody remembered to list.
|
|
63
74
|
|
|
64
|
-
|
|
75
|
+
### Why this exists
|
|
65
76
|
|
|
66
77
|
A real incident: a PR added two manifests to a Flux cluster directory but
|
|
67
78
|
missed adding them to `resources:`. The PR merged clean, CI was green,
|
|
68
79
|
`git log` showed the files — and Flux applied nothing. No error surfaced
|
|
69
80
|
anywhere; the only symptom was the feature silently not existing in the
|
|
70
|
-
cluster. This
|
|
81
|
+
cluster. This command turns that into a CI failure at PR time instead.
|
|
82
|
+
|
|
83
|
+
## `kcc-inventory`
|
|
84
|
+
|
|
85
|
+
[Config Connector](https://cloud.google.com/config-connector/docs/overview)
|
|
86
|
+
(KCC) lets a Kubernetes namespace declare a GCP project's resources as
|
|
87
|
+
CRs, with a controller reconciling git against reality. It won't tell you
|
|
88
|
+
about the reverse direction: a resource created straight in GCP — by hand,
|
|
89
|
+
by another tool, by a Terraform run nobody ported — that KCC has never
|
|
90
|
+
heard of, and never will until someone points it out.
|
|
91
|
+
|
|
92
|
+
`kcc-inventory` is that someone. Per namespace (any namespace carrying the
|
|
93
|
+
annotation `cnrm.cloud.google.com/project-id`), it compares what's live in
|
|
94
|
+
the GCP project against what's declared under KCC, for
|
|
95
|
+
`IAMServiceAccount`, `IAMServiceAccountKey`, `ArtifactRegistryRepository`,
|
|
96
|
+
`ContainerCluster`, `ContainerNodePool`, `StorageBucket`, `ComputeAddress`,
|
|
97
|
+
`DNSManagedZone`, `DNSRecordSet`, and `IAMPolicyMember`.
|
|
98
|
+
|
|
99
|
+
Read-only — it reports, it doesn't touch anything.
|
|
100
|
+
|
|
101
|
+
### Usage
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
npx @nitra/cfr kcc-inventory <namespace>
|
|
105
|
+
npx @nitra/cfr kcc-inventory --all # every KCC namespace
|
|
106
|
+
npx @nitra/cfr kcc-inventory <namespace|--all> --json
|
|
107
|
+
npx @nitra/cfr kcc-inventory <namespace|--all> --include-system # don't filter GCP-managed noise
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
### namespace nitraai -> проєкт nitraai ###
|
|
112
|
+
== StorageBucket (проєкт nitraai) ==
|
|
113
|
+
DRIFT — є в GCP, немає в KCC:
|
|
114
|
+
old-backups-bucket
|
|
115
|
+
чисто (11 live, 11 kcc)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Requires `gcloud` and `kubectl` on `PATH`, authenticated against the
|
|
119
|
+
target project/cluster. Set `KUBE_CONTEXT` to target a specific
|
|
120
|
+
kubeconfig context explicitly instead of relying on the current one.
|
|
121
|
+
|
|
122
|
+
By default, GCP-managed system noise is filtered out — Google-owned
|
|
123
|
+
service accounts, `gcr.io` shims, GKE-managed node pools and DNS zones,
|
|
124
|
+
legacy bucket ACL entries. Pass `--include-system` to see it anyway.
|
|
125
|
+
|
|
126
|
+
### Two directions
|
|
127
|
+
|
|
128
|
+
- **DRIFT** — live in GCP, not declared under KCC. Either adopt it (give
|
|
129
|
+
it a matching CR with the right `resourceID`) or delete it by hand.
|
|
130
|
+
- **ORPHAN** — declared under KCC, no longer live in GCP. The CR is
|
|
131
|
+
pointing at nothing; safe to remove from git.
|
|
132
|
+
|
|
133
|
+
### A known Cloud Asset Inventory quirk
|
|
134
|
+
|
|
135
|
+
`kcc-inventory` uses `gcloud asset search-all-resources` /
|
|
136
|
+
`search-all-iam-policies` — one or two calls per project instead of a
|
|
137
|
+
`gcloud X list` per resource kind. That index can lag: it has been
|
|
138
|
+
observed returning `DNSRecordSet` and `ComputeAddress` entries for
|
|
139
|
+
resources already deleted in GCP. Both are cross-checked against a direct,
|
|
140
|
+
cheap `gcloud` call before being reported, and any stale entry found this
|
|
141
|
+
way is counted and noted separately — never silently folded into DRIFT.
|
|
71
142
|
|
|
72
143
|
## License
|
|
73
144
|
|
package/bin/cli.mjs
CHANGED
|
@@ -1,137 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// kustomize-controller silently skips it — no error, no warning, the
|
|
5
|
-
// resource just never reaches the cluster. This CLI catches that drift
|
|
6
|
-
// before it ships.
|
|
7
|
-
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
8
|
-
import { basename, dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { run as runCheck } from '../lib/check.mjs';
|
|
3
|
+
import { run as runKccInventory } from '../lib/kcc-inventory.mjs';
|
|
9
4
|
|
|
10
|
-
const
|
|
5
|
+
const TOP_HELP = `cfr (@nitra/cfr) — a handful of small k8s/GitOps CLI utilities
|
|
11
6
|
|
|
12
7
|
Usage:
|
|
13
8
|
npx @nitra/cfr [dir-or-kustomization.yaml ...]
|
|
9
|
+
npx @nitra/cfr <command> [args...]
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
kustomization.
|
|
17
|
-
argument
|
|
11
|
+
Commands:
|
|
12
|
+
check Verify kustomization.yaml resources: match the directory
|
|
13
|
+
(default when the first argument isn't a known command)
|
|
14
|
+
kcc-inventory GCP Config Connector (KCC) drift inventory
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
- every *.yaml/*.yml file physically present in the directory
|
|
21
|
-
- every plain-filename entry under the top-level "resources:" list
|
|
22
|
-
|
|
23
|
-
and reports both directions: files on disk missing from resources: (Flux
|
|
24
|
-
will never apply them), and resources: entries with no matching file
|
|
25
|
-
(dead reference). Entries containing "/" or a URL scheme (subdirectories,
|
|
26
|
-
components, remote bases) are out of scope and skipped.
|
|
27
|
-
|
|
28
|
-
Exits 0 when every target is consistent, 1 otherwise.
|
|
29
|
-
|
|
30
|
-
Options:
|
|
31
|
-
-h, --help Show this help and exit.
|
|
16
|
+
Run "npx @nitra/cfr <command> --help" for command-specific help.
|
|
32
17
|
`;
|
|
33
18
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
stat = statSync(abs);
|
|
39
|
-
} catch {
|
|
40
|
-
return { error: `${target}: no such file or directory` };
|
|
41
|
-
}
|
|
42
|
-
if (stat.isFile()) {
|
|
43
|
-
return { dir: dirname(abs), file: abs };
|
|
44
|
-
}
|
|
45
|
-
for (const name of ['kustomization.yaml', 'kustomization.yml']) {
|
|
46
|
-
const candidate = join(abs, name);
|
|
47
|
-
try {
|
|
48
|
-
if (statSync(candidate).isFile()) return { dir: abs, file: candidate };
|
|
49
|
-
} catch {
|
|
50
|
-
// try next
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return { error: `${target}: no kustomization.yaml (or .yml) found` };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function extractResources(text) {
|
|
57
|
-
const lines = text.split('\n');
|
|
58
|
-
const entries = [];
|
|
59
|
-
let inBlock = false;
|
|
60
|
-
for (const line of lines) {
|
|
61
|
-
if (!inBlock) {
|
|
62
|
-
if (/^resources:\s*(#.*)?$/.test(line)) inBlock = true;
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
if (line.trim() === '') continue;
|
|
66
|
-
const item = line.match(/^\s+-\s*(.+?)\s*(#.*)?$/);
|
|
67
|
-
if (!item) break; // dedented past the list — block is over
|
|
68
|
-
let value = item[1].trim();
|
|
69
|
-
if (
|
|
70
|
-
(value.startsWith('"') && value.endsWith('"')) ||
|
|
71
|
-
(value.startsWith("'") && value.endsWith("'"))
|
|
72
|
-
) {
|
|
73
|
-
value = value.slice(1, -1);
|
|
74
|
-
}
|
|
75
|
-
entries.push(value);
|
|
76
|
-
}
|
|
77
|
-
return entries;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function isLocalYamlFilename(entry) {
|
|
81
|
-
return /^[^/]+\.ya?ml$/i.test(entry) && !/^[a-z]+:\/\//i.test(entry);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function checkTarget(target) {
|
|
85
|
-
const found = findKustomization(target);
|
|
86
|
-
if (found.error) return { target, ok: false, error: found.error };
|
|
87
|
-
|
|
88
|
-
const { dir, file } = found;
|
|
89
|
-
const onDisk = new Set(
|
|
90
|
-
readdirSync(dir).filter((name) => /\.ya?ml$/i.test(name) && join(dir, name) !== file),
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
const listedAll = extractResources(readFileSync(file, 'utf8'));
|
|
94
|
-
const listedLocal = new Set(listedAll.filter(isLocalYamlFilename));
|
|
95
|
-
|
|
96
|
-
const missing = [...onDisk].filter((name) => !listedLocal.has(name)).sort();
|
|
97
|
-
const dangling = [...listedLocal].filter((name) => !onDisk.has(name)).sort();
|
|
98
|
-
|
|
99
|
-
return { target, ok: missing.length === 0 && dangling.length === 0, file, missing, dangling };
|
|
100
|
-
}
|
|
19
|
+
const COMMANDS = {
|
|
20
|
+
check: runCheck,
|
|
21
|
+
'kcc-inventory': runKccInventory,
|
|
22
|
+
};
|
|
101
23
|
|
|
102
24
|
function main(argv) {
|
|
103
|
-
if (argv
|
|
104
|
-
process.stdout.write(
|
|
25
|
+
if (argv[0] === '-h' || argv[0] === '--help') {
|
|
26
|
+
process.stdout.write(TOP_HELP);
|
|
105
27
|
return 0;
|
|
106
28
|
}
|
|
107
29
|
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
exitCode = 1;
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
if (result.ok) {
|
|
119
|
-
console.log(`✓ ${result.file}`);
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
exitCode = 1;
|
|
123
|
-
console.error(`✗ ${result.file}`);
|
|
124
|
-
if (result.missing.length > 0) {
|
|
125
|
-
console.error(' on disk but missing from resources: (Flux will not apply them):');
|
|
126
|
-
for (const name of result.missing) console.error(` - ${name}`);
|
|
127
|
-
}
|
|
128
|
-
if (result.dangling.length > 0) {
|
|
129
|
-
console.error(' listed in resources: but missing on disk:');
|
|
130
|
-
for (const name of result.dangling) console.error(` - ${name}`);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return exitCode;
|
|
30
|
+
const [first, ...rest] = argv;
|
|
31
|
+
const command = COMMANDS[first];
|
|
32
|
+
// No recognized subcommand name in front — treat the whole argv as
|
|
33
|
+
// arguments to the default command (`check`), same as before subcommands
|
|
34
|
+
// existed: `npx @nitra/cfr flux/clusters/prod` still just works.
|
|
35
|
+
if (!command) return runCheck(argv);
|
|
36
|
+
return command(rest);
|
|
135
37
|
}
|
|
136
38
|
|
|
137
39
|
process.exit(main(process.argv.slice(2)));
|
package/lib/check.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Kustomize's `resources:` field is an explicit list, not a glob. Add a
|
|
2
|
+
// YAML file to a Kustomize directory without listing it there and
|
|
3
|
+
// kustomize-controller silently skips it — no error, no warning, the
|
|
4
|
+
// resource just never reaches the cluster. This command catches that
|
|
5
|
+
// drift before it ships.
|
|
6
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const HELP = `cfr check — verify kustomization.yaml resources: match the directory
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
npx @nitra/cfr [dir-or-kustomization.yaml ...]
|
|
13
|
+
npx @nitra/cfr check [dir-or-kustomization.yaml ...]
|
|
14
|
+
|
|
15
|
+
Each argument is either a directory containing a kustomization.yaml (or
|
|
16
|
+
kustomization.yml), or a direct path to one. Defaults to "." when no
|
|
17
|
+
argument is given.
|
|
18
|
+
|
|
19
|
+
For each target, compares:
|
|
20
|
+
- every *.yaml/*.yml file physically present in the directory
|
|
21
|
+
- every plain-filename entry under the top-level "resources:" list
|
|
22
|
+
|
|
23
|
+
and reports both directions: files on disk missing from resources: (Flux
|
|
24
|
+
will never apply them), and resources: entries with no matching file
|
|
25
|
+
(dead reference). Entries containing "/" or a URL scheme (subdirectories,
|
|
26
|
+
components, remote bases) are out of scope and skipped.
|
|
27
|
+
|
|
28
|
+
Exits 0 when every target is consistent, 1 otherwise.
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
-h, --help Show this help and exit.
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
function findKustomization(target) {
|
|
35
|
+
const abs = resolve(target);
|
|
36
|
+
let stat;
|
|
37
|
+
try {
|
|
38
|
+
stat = statSync(abs);
|
|
39
|
+
} catch {
|
|
40
|
+
return { error: `${target}: no such file or directory` };
|
|
41
|
+
}
|
|
42
|
+
if (stat.isFile()) {
|
|
43
|
+
return { dir: dirname(abs), file: abs };
|
|
44
|
+
}
|
|
45
|
+
for (const name of ['kustomization.yaml', 'kustomization.yml']) {
|
|
46
|
+
const candidate = join(abs, name);
|
|
47
|
+
try {
|
|
48
|
+
if (statSync(candidate).isFile()) return { dir: abs, file: candidate };
|
|
49
|
+
} catch {
|
|
50
|
+
// try next
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { error: `${target}: no kustomization.yaml (or .yml) found` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractResources(text) {
|
|
57
|
+
const lines = text.split('\n');
|
|
58
|
+
const entries = [];
|
|
59
|
+
let inBlock = false;
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
if (!inBlock) {
|
|
62
|
+
if (/^resources:\s*(#.*)?$/.test(line)) inBlock = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (line.trim() === '') continue;
|
|
66
|
+
const item = line.match(/^\s+-\s*(.+?)\s*(#.*)?$/);
|
|
67
|
+
if (!item) break; // dedented past the list — block is over
|
|
68
|
+
let value = item[1].trim();
|
|
69
|
+
if (
|
|
70
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
71
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
72
|
+
) {
|
|
73
|
+
value = value.slice(1, -1);
|
|
74
|
+
}
|
|
75
|
+
entries.push(value);
|
|
76
|
+
}
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isLocalYamlFilename(entry) {
|
|
81
|
+
return /^[^/]+\.ya?ml$/i.test(entry) && !/^[a-z]+:\/\//i.test(entry);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkTarget(target) {
|
|
85
|
+
const found = findKustomization(target);
|
|
86
|
+
if (found.error) return { target, ok: false, error: found.error };
|
|
87
|
+
|
|
88
|
+
const { dir, file } = found;
|
|
89
|
+
const onDisk = new Set(
|
|
90
|
+
readdirSync(dir).filter((name) => /\.ya?ml$/i.test(name) && join(dir, name) !== file),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const listedAll = extractResources(readFileSync(file, 'utf8'));
|
|
94
|
+
const listedLocal = new Set(listedAll.filter(isLocalYamlFilename));
|
|
95
|
+
|
|
96
|
+
const missing = [...onDisk].filter((name) => !listedLocal.has(name)).sort();
|
|
97
|
+
const dangling = [...listedLocal].filter((name) => !onDisk.has(name)).sort();
|
|
98
|
+
|
|
99
|
+
return { target, ok: missing.length === 0 && dangling.length === 0, file, missing, dangling };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function run(argv) {
|
|
103
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
104
|
+
process.stdout.write(HELP);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const targets = argv.length > 0 ? argv : ['.'];
|
|
109
|
+
let exitCode = 0;
|
|
110
|
+
|
|
111
|
+
for (const target of targets) {
|
|
112
|
+
const result = checkTarget(target);
|
|
113
|
+
if (result.error) {
|
|
114
|
+
console.error(`✗ ${result.target}: ${result.error}`);
|
|
115
|
+
exitCode = 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (result.ok) {
|
|
119
|
+
console.log(`✓ ${result.file}`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
exitCode = 1;
|
|
123
|
+
console.error(`✗ ${result.file}`);
|
|
124
|
+
if (result.missing.length > 0) {
|
|
125
|
+
console.error(' on disk but missing from resources: (Flux will not apply them):');
|
|
126
|
+
for (const name of result.missing) console.error(` - ${name}`);
|
|
127
|
+
}
|
|
128
|
+
if (result.dangling.length > 0) {
|
|
129
|
+
console.error(' listed in resources: but missing on disk:');
|
|
130
|
+
for (const name of result.dangling) console.error(` - ${name}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return exitCode;
|
|
135
|
+
}
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// Інвентаризація: що з GCP-ресурсів проєкту реально живе під KCC (Config
|
|
2
|
+
// Connector, описано в git), а що — "чуже" (створено повз KCC). Суто
|
|
3
|
+
// читання, нічого не видаляє — вхідний контракт для окремого
|
|
4
|
+
// cleanup-рішення, яке вирішує, що з DRIFT можна прибрати.
|
|
5
|
+
//
|
|
6
|
+
// Проєкт береться з анотації namespace (cnrm.cloud.google.com/project-id),
|
|
7
|
+
// не з константи — той самий скрипт працює для будь-якого namespace, що
|
|
8
|
+
// веде GCP-проєкт через KCC у namespaced mode.
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
export const HELP = `cfr kcc-inventory — GCP Config Connector (KCC) drift inventory
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
npx @nitra/cfr kcc-inventory <namespace>
|
|
15
|
+
npx @nitra/cfr kcc-inventory --all
|
|
16
|
+
npx @nitra/cfr kcc-inventory <namespace|--all> [--json] [--include-system]
|
|
17
|
+
|
|
18
|
+
Compares, per KCC namespace (namespace carrying the annotation
|
|
19
|
+
cnrm.cloud.google.com/project-id), what actually exists in the GCP project
|
|
20
|
+
against what's declared as Config Connector custom resources in that
|
|
21
|
+
namespace — for IAMServiceAccount, IAMServiceAccountKey,
|
|
22
|
+
ArtifactRegistryRepository, ContainerCluster, ContainerNodePool,
|
|
23
|
+
StorageBucket, ComputeAddress, DNSManagedZone, DNSRecordSet, and
|
|
24
|
+
IAMPolicyMember.
|
|
25
|
+
|
|
26
|
+
Reports two directions per kind:
|
|
27
|
+
DRIFT — live in GCP, not declared under KCC (adopt it or delete it)
|
|
28
|
+
ORPHAN — declared under KCC, no longer live in GCP
|
|
29
|
+
|
|
30
|
+
By default GCP-managed system noise is filtered out (Google-owned service
|
|
31
|
+
accounts, gcr.io shims, GKE-managed node pools/DNS zones, legacy bucket
|
|
32
|
+
ACL entries, ...) — pass --include-system to see it anyway.
|
|
33
|
+
|
|
34
|
+
Requires \`gcloud\` and \`kubectl\` on PATH, authenticated against the target
|
|
35
|
+
project/cluster. Set KUBE_CONTEXT to target a specific kubeconfig context
|
|
36
|
+
explicitly instead of relying on the current one.
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--json Emit a JSON array of {kind, id, project, status} instead
|
|
40
|
+
of the human-readable report.
|
|
41
|
+
--include-system Don't filter out GCP-managed system resources.
|
|
42
|
+
-h, --help Show this help and exit.
|
|
43
|
+
|
|
44
|
+
Exits 0 on a completed scan (DRIFT/ORPHAN findings are not failures — this
|
|
45
|
+
is a report, not a gate), 2 on a usage error.
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
function kubectlBase() {
|
|
49
|
+
const ctx = process.env.KUBE_CONTEXT;
|
|
50
|
+
return ctx ? ['kubectl', '--context', ctx] : ['kubectl'];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// maxBuffer: дефолтні 1MB замалі для search-all-resources на проєкті з
|
|
54
|
+
// сотнями активів — spawnSync мовчки падає в ENOBUFS, і виклик, що його не
|
|
55
|
+
// перевіряє (як тут), бачить порожній результат замість помилки.
|
|
56
|
+
function runCmd(cmd, args) {
|
|
57
|
+
return spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 100 * 1024 * 1024 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function kubectlJson(...args) {
|
|
61
|
+
const [bin, ...base] = kubectlBase();
|
|
62
|
+
const proc = runCmd(bin, [...base, ...args, '-o', 'json']);
|
|
63
|
+
if (proc.status !== 0 || !proc.stdout || !proc.stdout.trim()) return null;
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(proc.stdout);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function kubectlField(crd, namespace, fieldFn) {
|
|
72
|
+
const data = kubectlJson('get', crd, '-n', namespace);
|
|
73
|
+
if (!data) return [];
|
|
74
|
+
return (data.items || []).map(fieldFn).filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function gcloudJson(...args) {
|
|
78
|
+
const proc = runCmd('gcloud', [...args, '--format=json']);
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(proc.stdout || '[]');
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function gcloudLines(...args) {
|
|
87
|
+
const proc = runCmd('gcloud', args);
|
|
88
|
+
return (proc.stdout || '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stripPrefix(s, prefix) {
|
|
92
|
+
return s.startsWith(prefix) ? s.slice(prefix.length) : s;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- фільтри GCP-системного шуму (див. --include-system) ------------------
|
|
96
|
+
|
|
97
|
+
const SA_SYSTEM = [
|
|
98
|
+
/^[0-9]+-compute@developer\.gserviceaccount\.com$/,
|
|
99
|
+
/@appspot\.gserviceaccount\.com$/,
|
|
100
|
+
/@cloudservices\.gserviceaccount\.com$/,
|
|
101
|
+
/^firebase-adminsdk-/,
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const AR_SYSTEM = [/(^|\.)gcr\.io$/, /^gcf-artifacts$/];
|
|
105
|
+
|
|
106
|
+
const NODEPOOL_SYSTEM = /^nap-/;
|
|
107
|
+
|
|
108
|
+
function isSystemBucket(name, project) {
|
|
109
|
+
return name.endsWith('.appspot.com') || name.startsWith('gcf-sources-') || name === `${project}_cloudbuild`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// projectEditor:/projectOwner:/projectViewer: — legacy bucket ACL, GCP
|
|
113
|
+
// навішує на кожен бакет сам; решта — Google-керовані service agents.
|
|
114
|
+
const IAM_SYSTEM = [
|
|
115
|
+
/^project(Editor|Owner|Viewer):/,
|
|
116
|
+
/@system\.gserviceaccount\.com$/,
|
|
117
|
+
/serviceAccount:service-[0-9]+@/,
|
|
118
|
+
/@gcp-sa-[a-z0-9-]+\.iam\.gserviceaccount\.com$/,
|
|
119
|
+
/@(cloudbuild|cloudservices|developer|appspot)\.gserviceaccount\.com$/,
|
|
120
|
+
/@(container-engine-robot|serverless-robot-prod|gcf-admin-robot|firebase-rules|firebase-sa-management)\.iam\.gserviceaccount\.com$/,
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
function isSystem(patterns, value) {
|
|
124
|
+
return patterns.some((p) => p.test(value));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- report: sort+diff двох списків, людський звіт і/або JSON -------------
|
|
128
|
+
|
|
129
|
+
function report(kind, liveIds, kccIds, project, jsonMode, results) {
|
|
130
|
+
const live = [...new Set(liveIds.filter(Boolean))].sort();
|
|
131
|
+
const kcc = [...new Set(kccIds.filter(Boolean))].sort();
|
|
132
|
+
const kccSet = new Set(kcc);
|
|
133
|
+
const liveSet = new Set(live);
|
|
134
|
+
const drift = live.filter((x) => !kccSet.has(x)).sort();
|
|
135
|
+
const orphan = kcc.filter((x) => !liveSet.has(x)).sort();
|
|
136
|
+
|
|
137
|
+
if (!jsonMode) {
|
|
138
|
+
console.log(`== ${kind} (проєкт ${project}) ==`);
|
|
139
|
+
if (drift.length) {
|
|
140
|
+
console.log(' DRIFT — є в GCP, немає в KCC:');
|
|
141
|
+
for (const x of drift) console.log(` ${x}`);
|
|
142
|
+
}
|
|
143
|
+
if (orphan.length) {
|
|
144
|
+
console.log(' ORPHAN — є в KCC, зникло з GCP:');
|
|
145
|
+
for (const x of orphan) console.log(` ${x}`);
|
|
146
|
+
}
|
|
147
|
+
if (!drift.length && !orphan.length) {
|
|
148
|
+
console.log(` чисто (${live.length} live, ${kcc.length} kcc)`);
|
|
149
|
+
}
|
|
150
|
+
console.log('');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const x of drift) results.push({ kind, id: x, project, status: 'drift' });
|
|
154
|
+
for (const x of orphan) results.push({ kind, id: x, project, status: 'orphan' });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- IAMPolicyMember: розбір id ресурсу з обох боків -----------------------
|
|
158
|
+
|
|
159
|
+
function assetResId(entry, project) {
|
|
160
|
+
const assetType = entry.assetType || '';
|
|
161
|
+
const res = entry.resource || '';
|
|
162
|
+
if (res.endsWith(`/projects/${project}`)) return `project/${project}`;
|
|
163
|
+
const mSa = res.match(/\/serviceAccounts\/([^/]+)$/);
|
|
164
|
+
if (mSa && assetType === 'iam.googleapis.com/ServiceAccount') return `sa/${mSa[1]}`;
|
|
165
|
+
const mAr = res.match(/\/repositories\/([^/]+)$/);
|
|
166
|
+
if (mAr && assetType === 'artifactregistry.googleapis.com/Repository') return `ar/${mAr[1]}`;
|
|
167
|
+
if (assetType === 'storage.googleapis.com/Bucket') return 'bucket/' + stripPrefix(res, '//storage.googleapis.com/');
|
|
168
|
+
return 'other/' + stripPrefix(res, '//');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function kccArId(ref) {
|
|
172
|
+
const ext = ref.external || '';
|
|
173
|
+
const m = ext.match(/\/repositories\/([^/]+)$/);
|
|
174
|
+
if (m) return m[1];
|
|
175
|
+
if (ref.name) return ref.name;
|
|
176
|
+
return '?';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function kccSaId(ref, project) {
|
|
180
|
+
const ext = ref.external || '';
|
|
181
|
+
const m = ext.match(/\/serviceAccounts\/([^/]+)$/);
|
|
182
|
+
if (m) return m[1];
|
|
183
|
+
if (ext) return ext;
|
|
184
|
+
if (ref.name) return `${ref.name}@${project}.iam.gserviceaccount.com`;
|
|
185
|
+
return '?';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function kccPolicyMemberId(item, project) {
|
|
189
|
+
const ref = (item.spec && item.spec.resourceRef) || {};
|
|
190
|
+
switch (ref.kind) {
|
|
191
|
+
case 'Project':
|
|
192
|
+
return `project/${project}`;
|
|
193
|
+
case 'IAMServiceAccount':
|
|
194
|
+
return 'sa/' + kccSaId(ref, project);
|
|
195
|
+
case 'ArtifactRegistryRepository':
|
|
196
|
+
return 'ar/' + kccArId(ref);
|
|
197
|
+
case 'StorageBucket':
|
|
198
|
+
return 'bucket/' + (ref.external || ref.name || '?');
|
|
199
|
+
default:
|
|
200
|
+
return 'other/' + (ref.external || ref.name || '?');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- по одному namespace ----------------------------------------------
|
|
205
|
+
|
|
206
|
+
function scanNamespace(namespace, includeSystem, jsonMode, results) {
|
|
207
|
+
const ns = kubectlJson('get', 'namespace', namespace);
|
|
208
|
+
const project = ns && ns.metadata && ns.metadata.annotations
|
|
209
|
+
&& ns.metadata.annotations['cnrm.cloud.google.com/project-id'];
|
|
210
|
+
if (!project) {
|
|
211
|
+
console.error(`namespace ${namespace} не має cnrm.cloud.google.com/project-id`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!jsonMode) console.log(`### namespace ${namespace} -> проєкт ${project} ###`);
|
|
216
|
+
|
|
217
|
+
const assets = gcloudJson('asset', 'search-all-resources', `--scope=projects/${project}`);
|
|
218
|
+
const byType = (...types) => assets.filter((a) => types.includes(a.assetType));
|
|
219
|
+
|
|
220
|
+
// --- IAMServiceAccount ------------------------------------------------
|
|
221
|
+
const allLiveSa = byType('iam.googleapis.com/ServiceAccount')
|
|
222
|
+
.map((a) => a.additionalAttributes && a.additionalAttributes.email)
|
|
223
|
+
.filter(Boolean);
|
|
224
|
+
let liveSa = allLiveSa;
|
|
225
|
+
if (!includeSystem) liveSa = liveSa.filter((e) => !isSystem(SA_SYSTEM, e));
|
|
226
|
+
const kccSa = kubectlField(
|
|
227
|
+
'iamserviceaccounts.iam.cnrm.cloud.google.com', namespace,
|
|
228
|
+
(i) => i.status && i.status.email,
|
|
229
|
+
);
|
|
230
|
+
report('IAMServiceAccount', liveSa, kccSa, project, jsonMode, results);
|
|
231
|
+
|
|
232
|
+
// --- IAMServiceAccountKey (тільки user-managed) ------------------------
|
|
233
|
+
// Живі SA для циклу ключів беремо БЕЗ фільтра system — дешевше не
|
|
234
|
+
// ускладнювати, ключ системного SA все одно рідкість.
|
|
235
|
+
const liveKey = [];
|
|
236
|
+
for (const email of allLiveSa) {
|
|
237
|
+
liveKey.push(...gcloudLines(
|
|
238
|
+
'iam', 'service-accounts', 'keys', 'list',
|
|
239
|
+
`--iam-account=${email}`, '--managed-by=user',
|
|
240
|
+
`--project=${project}`, '--format=value(name)',
|
|
241
|
+
));
|
|
242
|
+
}
|
|
243
|
+
const kccKey = kubectlField(
|
|
244
|
+
'iamserviceaccountkeys.iam.cnrm.cloud.google.com', namespace,
|
|
245
|
+
(i) => i.status && i.status.name,
|
|
246
|
+
).map((n) => n.split('/').pop());
|
|
247
|
+
report('IAMServiceAccountKey', liveKey, kccKey, project, jsonMode, results);
|
|
248
|
+
|
|
249
|
+
// --- ArtifactRegistryRepository -----------------------------------------
|
|
250
|
+
let liveAr = byType('artifactregistry.googleapis.com/Repository')
|
|
251
|
+
.map((a) => {
|
|
252
|
+
const m = (a.name || '').match(/\/repositories\/([^/]+)$/);
|
|
253
|
+
return m && m[1];
|
|
254
|
+
})
|
|
255
|
+
.filter(Boolean);
|
|
256
|
+
if (!includeSystem) liveAr = liveAr.filter((r) => !isSystem(AR_SYSTEM, r));
|
|
257
|
+
const kccAr = kubectlField(
|
|
258
|
+
'artifactregistryrepositories.artifactregistry.cnrm.cloud.google.com', namespace,
|
|
259
|
+
(i) => i.spec && i.spec.resourceID,
|
|
260
|
+
);
|
|
261
|
+
report('ArtifactRegistryRepository', liveAr, kccAr, project, jsonMode, results);
|
|
262
|
+
|
|
263
|
+
// --- ContainerCluster + ContainerNodePool -------------------------------
|
|
264
|
+
const liveCluster = byType('container.googleapis.com/Cluster').map((a) => a.displayName);
|
|
265
|
+
const kccCluster = kubectlField(
|
|
266
|
+
'containerclusters.container.cnrm.cloud.google.com', namespace,
|
|
267
|
+
(i) => i.spec && i.spec.resourceID,
|
|
268
|
+
);
|
|
269
|
+
report('ContainerCluster', liveCluster, kccCluster, project, jsonMode, results);
|
|
270
|
+
|
|
271
|
+
const livePool = [];
|
|
272
|
+
for (const a of byType('container.googleapis.com/NodePool')) {
|
|
273
|
+
const m = (a.name || '').match(/\/clusters\/([^/]+)\/nodePools\/([^/]+)$/);
|
|
274
|
+
if (!m) continue;
|
|
275
|
+
const [, cluster, pool] = m;
|
|
276
|
+
if (includeSystem || !NODEPOOL_SYSTEM.test(pool)) livePool.push(`${cluster}/${pool}`);
|
|
277
|
+
}
|
|
278
|
+
const kccPool = kubectlField(
|
|
279
|
+
'containernodepools.container.cnrm.cloud.google.com', namespace,
|
|
280
|
+
(i) => {
|
|
281
|
+
const ref = (i.spec && i.spec.clusterRef) || {};
|
|
282
|
+
return `${ref.name || ref.external}/${i.spec && i.spec.resourceID}`;
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
report('ContainerNodePool', livePool, kccPool, project, jsonMode, results);
|
|
286
|
+
|
|
287
|
+
// --- StorageBucket -------------------------------------------------------
|
|
288
|
+
let liveBucket = byType('storage.googleapis.com/Bucket').map((a) => a.displayName);
|
|
289
|
+
if (!includeSystem) liveBucket = liveBucket.filter((b) => !isSystemBucket(b, project));
|
|
290
|
+
const kccBucket = kubectlField(
|
|
291
|
+
'storagebuckets.storage.cnrm.cloud.google.com', namespace,
|
|
292
|
+
(i) => i.spec && i.spec.resourceID,
|
|
293
|
+
);
|
|
294
|
+
report('StorageBucket', liveBucket, kccBucket, project, jsonMode, results);
|
|
295
|
+
|
|
296
|
+
// --- ComputeAddress (регіональні й глобальні) -----------------------------
|
|
297
|
+
// Asset Inventory інколи повертає вже видалені адреси (перевірено
|
|
298
|
+
// емпірично на azovmemo: search-all-resources показав адресу без
|
|
299
|
+
// читабельного імені, якої `gcloud compute addresses describe` вже не
|
|
300
|
+
// знаходить — той самий клас застарілого кешу, що й ResourceRecordSet).
|
|
301
|
+
// Два дешевих прямих виклики (regional + global), не по одному на
|
|
302
|
+
// ресурс, — фільтруємо ними AR-подібні привиди CAI.
|
|
303
|
+
const liveAddrIds = new Set([
|
|
304
|
+
...gcloudLines('compute', 'addresses', 'list', `--project=${project}`, '--format=csv[no-heading](name,region.basename())'),
|
|
305
|
+
...gcloudLines('compute', 'addresses', 'list', '--global', `--project=${project}`, '--format=value(name)').map((n) => `${n},`),
|
|
306
|
+
].map((row) => {
|
|
307
|
+
const [name, region] = row.split(',');
|
|
308
|
+
return `${name}/${region || 'global'}`;
|
|
309
|
+
}));
|
|
310
|
+
const staleAddrCount0 = byType('compute.googleapis.com/Address', 'compute.googleapis.com/GlobalAddress').length;
|
|
311
|
+
const liveAddr = byType('compute.googleapis.com/Address', 'compute.googleapis.com/GlobalAddress')
|
|
312
|
+
.map((a) => `${a.displayName}/${a.location === 'global' ? 'global' : a.location}`)
|
|
313
|
+
.filter((id) => liveAddrIds.has(id));
|
|
314
|
+
const staleAddrCount = staleAddrCount0 - liveAddr.length;
|
|
315
|
+
if (staleAddrCount) {
|
|
316
|
+
if (jsonMode) {
|
|
317
|
+
results.push({ kind: 'ComputeAddress', id: `stale_asset_inventory_cache:${staleAddrCount}`, project, status: 'ignored' });
|
|
318
|
+
} else {
|
|
319
|
+
console.log(`== ComputeAddress: проігноровано ${staleAddrCount} запис(ів), яких уже нема в GCP (застарілий кеш Asset Inventory) ==`);
|
|
320
|
+
console.log('');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const kccAddr = kubectlField(
|
|
324
|
+
'computeaddresses.compute.cnrm.cloud.google.com', namespace,
|
|
325
|
+
(i) => `${i.spec && i.spec.resourceID}/${(i.spec && i.spec.location) || 'global'}`,
|
|
326
|
+
);
|
|
327
|
+
report('ComputeAddress', liveAddr, kccAddr, project, jsonMode, results);
|
|
328
|
+
|
|
329
|
+
// --- DNSManagedZone + DNSRecordSet ---------------------------------------
|
|
330
|
+
// Зони, які GKE заводить і веде сам (label goog-gke-node) — не в diff
|
|
331
|
+
// узагалі: не KCC-кандидат, і рекордсетів там сотні на Service.
|
|
332
|
+
const zones = byType('dns.googleapis.com/ManagedZone');
|
|
333
|
+
const gkeZoneNames = new Set(
|
|
334
|
+
zones.filter((z) => z.labels && z.labels['goog-gke-node'] !== undefined).map((z) => z.displayName),
|
|
335
|
+
);
|
|
336
|
+
const liveZone = zones.map((z) => z.displayName).filter((n) => !gkeZoneNames.has(n));
|
|
337
|
+
if (gkeZoneNames.size && !jsonMode) {
|
|
338
|
+
console.log('== DNSManagedZone: пропущено (goog-gke-node, керує сам GKE) ==');
|
|
339
|
+
for (const z of [...gkeZoneNames].sort()) console.log(` ${z}`);
|
|
340
|
+
console.log('');
|
|
341
|
+
}
|
|
342
|
+
const kccZone = kubectlField(
|
|
343
|
+
'dnsmanagedzones.dns.cnrm.cloud.google.com', namespace,
|
|
344
|
+
(i) => i.spec && i.spec.resourceID,
|
|
345
|
+
);
|
|
346
|
+
report('DNSManagedZone', liveZone, kccZone, project, jsonMode, results);
|
|
347
|
+
|
|
348
|
+
// rrset .name містить числовий id зони, не читабельне ім'я — мапа
|
|
349
|
+
// id->displayName будується з тих самих ManagedZone-записів.
|
|
350
|
+
const zoneNameById = new Map();
|
|
351
|
+
for (const z of zones) {
|
|
352
|
+
const m = (z.name || '').match(/\/managedZones\/([^/]+)$/);
|
|
353
|
+
if (m) zoneNameById.set(m[1], z.displayName);
|
|
354
|
+
}
|
|
355
|
+
// Cloud Asset Inventory інколи повертає ResourceRecordSet від зон, яких
|
|
356
|
+
// уже немає (перевірено емпірично на azovmemo: rrsets посилались на три
|
|
357
|
+
// різні managedZone-id, жоден з яких не збігався з id живої зони —
|
|
358
|
+
// застарілий кеш після пересоздання GKE-зони). Запис без резолву в
|
|
359
|
+
// zoneNameById — не дрейф, а сміття CAI, тому пропускаємо мовчки, а не
|
|
360
|
+
// фолбечимось на сирий id (інакше він ніколи не збіжиться з
|
|
361
|
+
// gkeZoneNames і завжди рахувався б за DRIFT).
|
|
362
|
+
let staleRrsetCount = 0;
|
|
363
|
+
const liveRrset = [];
|
|
364
|
+
for (const a of byType('dns.googleapis.com/ResourceRecordSet')) {
|
|
365
|
+
const mZid = (a.parentFullResourceName || '').match(/\/managedZones\/([^/]+)$/);
|
|
366
|
+
if (!mZid) continue;
|
|
367
|
+
const zname = zoneNameById.get(mZid[1]);
|
|
368
|
+
if (zname === undefined) {
|
|
369
|
+
staleRrsetCount += 1;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (gkeZoneNames.has(zname)) continue;
|
|
373
|
+
const mRr = (a.name || '').match(/\/rrsets\/(.+)\/([A-Za-z]+)$/);
|
|
374
|
+
if (!mRr) continue;
|
|
375
|
+
liveRrset.push(`${zname}/${mRr[1]}/${mRr[2]}`);
|
|
376
|
+
}
|
|
377
|
+
if (staleRrsetCount) {
|
|
378
|
+
if (jsonMode) {
|
|
379
|
+
results.push({
|
|
380
|
+
kind: 'DNSRecordSet', id: `stale_asset_inventory_cache:${staleRrsetCount}`,
|
|
381
|
+
project, status: 'ignored',
|
|
382
|
+
});
|
|
383
|
+
} else {
|
|
384
|
+
console.log(`== DNSRecordSet: проігноровано ${staleRrsetCount} запис(ів) від зон, яких уже нема (застарілий кеш Asset Inventory) ==`);
|
|
385
|
+
console.log('');
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const kccRrset = kubectlField(
|
|
389
|
+
'dnsrecordsets.dns.cnrm.cloud.google.com', namespace,
|
|
390
|
+
(i) => {
|
|
391
|
+
const zref = (i.spec && i.spec.managedZoneRef) || {};
|
|
392
|
+
return `${zref.name}/${i.spec && i.spec.name}/${i.spec && i.spec.type}`;
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
report('DNSRecordSet', liveRrset, kccRrset, project, jsonMode, results);
|
|
396
|
+
|
|
397
|
+
// --- IAMPolicyMember -----------------------------------------------------
|
|
398
|
+
// search-all-iam-policies — усі біндинги проєкту одразу, на будь-якому
|
|
399
|
+
// типі ресурсу, не тільки на трьох раніше підтримуваних (Project/SA/AR).
|
|
400
|
+
const iamPolicies = gcloudJson('asset', 'search-all-iam-policies', `--scope=projects/${project}`);
|
|
401
|
+
const liveIam = [];
|
|
402
|
+
for (const entry of iamPolicies) {
|
|
403
|
+
const rid = assetResId(entry, project);
|
|
404
|
+
for (const binding of (entry.policy && entry.policy.bindings) || []) {
|
|
405
|
+
for (const member of binding.members || []) {
|
|
406
|
+
if (includeSystem || !isSystem(IAM_SYSTEM, member)) {
|
|
407
|
+
liveIam.push(`${rid}/${binding.role}/${member}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const kccPolicyItems = kubectlJson('get', 'iampolicymembers.iam.cnrm.cloud.google.com', '-n', namespace);
|
|
414
|
+
const kccIam = ((kccPolicyItems && kccPolicyItems.items) || []).map((item) => {
|
|
415
|
+
const rid = kccPolicyMemberId(item, project);
|
|
416
|
+
const spec = item.spec || {};
|
|
417
|
+
return `${rid}/${spec.role}/${spec.member}`;
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
report('IAMPolicyMember', liveIam, kccIam, project, jsonMode, results);
|
|
421
|
+
if (!jsonMode) console.log('');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function run(argv) {
|
|
425
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
426
|
+
process.stdout.write(HELP);
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const target = argv[0];
|
|
431
|
+
if (!target) {
|
|
432
|
+
console.error('usage: cfr kcc-inventory <namespace|--all> [--json] [--include-system] (KUBE_CONTEXT=... для явного контексту)');
|
|
433
|
+
return 2;
|
|
434
|
+
}
|
|
435
|
+
const jsonMode = argv.includes('--json');
|
|
436
|
+
const includeSystem = argv.includes('--include-system');
|
|
437
|
+
|
|
438
|
+
let namespaces;
|
|
439
|
+
if (target === '--all') {
|
|
440
|
+
const data = kubectlJson('get', 'namespace');
|
|
441
|
+
namespaces = ((data && data.items) || [])
|
|
442
|
+
.filter((i) => i.metadata && i.metadata.annotations && i.metadata.annotations['cnrm.cloud.google.com/project-id'])
|
|
443
|
+
.map((i) => i.metadata.name);
|
|
444
|
+
if (!namespaces.length) {
|
|
445
|
+
console.error('жоден namespace не має cnrm.cloud.google.com/project-id');
|
|
446
|
+
return 2;
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
namespaces = [target];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const results = [];
|
|
453
|
+
for (const ns of namespaces) scanNamespace(ns, includeSystem, jsonMode, results);
|
|
454
|
+
|
|
455
|
+
if (jsonMode) console.log(JSON.stringify(results, null, 2));
|
|
456
|
+
return 0;
|
|
457
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitra/cfr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A handful of small k8s/GitOps CLI utilities: `check` verifies every YAML file in a Kustomize directory is listed in kustomization.yaml's explicit resources: (and vice versa); `kcc-inventory` diffs a GCP Config Connector namespace against the live project to find drift.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kustomize",
|
|
7
7
|
"flux",
|
|
8
8
|
"fluxcd",
|
|
9
9
|
"kubernetes",
|
|
10
10
|
"gitops",
|
|
11
|
-
"lint"
|
|
11
|
+
"lint",
|
|
12
|
+
"config-connector",
|
|
13
|
+
"kcc",
|
|
14
|
+
"gcp"
|
|
12
15
|
],
|
|
13
16
|
"homepage": "https://github.com/nitra/cfr#readme",
|
|
14
17
|
"bugs": "https://github.com/nitra/cfr/issues",
|
|
@@ -26,7 +29,8 @@
|
|
|
26
29
|
"cfr": "./bin/cli.mjs"
|
|
27
30
|
},
|
|
28
31
|
"files": [
|
|
29
|
-
"bin"
|
|
32
|
+
"bin",
|
|
33
|
+
"lib"
|
|
30
34
|
],
|
|
31
35
|
"publishConfig": {
|
|
32
36
|
"access": "public"
|