@nitra/cfr 0.1.2 → 0.3.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 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 CLI catches that drift before it ships: it compares every
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
- ## Usage
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
- ## What it checks
61
+ ### What it checks
51
62
 
52
63
  For each target directory:
53
64
 
@@ -61,13 +72,79 @@ 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
- ## Why this exists
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 tool turns that into a CI failure at PR time instead.
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 `kubectl` on `PATH`, pointed at the target cluster. Set
119
+ `KUBE_CONTEXT` to target a specific kubeconfig context explicitly instead
120
+ of relying on the current one. The GCP side talks to Cloud Asset
121
+ Inventory, IAM, and Compute Engine directly over REST — no `gcloud` CLI
122
+ needed, just [Application Default
123
+ Credentials](https://cloud.google.com/docs/authentication/application-default-credentials)
124
+ (`gcloud auth application-default login` locally, a service account key
125
+ via `GOOGLE_APPLICATION_CREDENTIALS`, or the ambient credentials on
126
+ GCE/GKE/Cloud Build).
127
+
128
+ By default, GCP-managed system noise is filtered out — Google-owned
129
+ service accounts, `gcr.io` shims, GKE-managed node pools and DNS zones,
130
+ legacy bucket ACL entries. Pass `--include-system` to see it anyway.
131
+
132
+ ### Two directions
133
+
134
+ - **DRIFT** — live in GCP, not declared under KCC. Either adopt it (give
135
+ it a matching CR with the right `resourceID`) or delete it by hand.
136
+ - **ORPHAN** — declared under KCC, no longer live in GCP. The CR is
137
+ pointing at nothing; safe to remove from git.
138
+
139
+ ### A known Cloud Asset Inventory quirk
140
+
141
+ `kcc-inventory` calls `searchAllResources`/`searchAllIamPolicies` on the
142
+ Cloud Asset API — one or two paginated calls per project instead of a
143
+ list call per resource kind. That index can lag: it has been observed
144
+ returning `DNSRecordSet` and `ComputeAddress` entries for resources
145
+ already deleted in GCP. Both are cross-checked against a direct Compute
146
+ Engine call before being reported, and any stale entry found this way is
147
+ counted and noted separately — never silently folded into DRIFT.
71
148
 
72
149
  ## License
73
150
 
package/bin/cli.mjs CHANGED
@@ -1,137 +1,48 @@
1
1
  #!/usr/bin/env node
2
- // Kustomize's `resources:` field is an explicit list, not a glob. Add a
3
- // YAML file to a Kustomize directory without listing it there and
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 HELP = `cfr (@nitra/cfr) — verify kustomization.yaml resources: match the directory
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
- 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.
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
- 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.
16
+ Run "npx @nitra/cfr <command> --help" for command-specific help.
32
17
  `;
33
18
 
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
- function main(argv) {
103
- if (argv.includes('-h') || argv.includes('--help')) {
104
- process.stdout.write(HELP);
19
+ const COMMANDS = {
20
+ check: runCheck,
21
+ 'kcc-inventory': runKccInventory,
22
+ };
23
+
24
+ // check is synchronous (local filesystem only); kcc-inventory is async
25
+ // (talks to GCP/kubectl over the network) awaiting a plain number is a
26
+ // no-op, so this works for either.
27
+ async function main(argv) {
28
+ if (argv[0] === '-h' || argv[0] === '--help') {
29
+ process.stdout.write(TOP_HELP);
105
30
  return 0;
106
31
  }
107
32
 
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;
33
+ const [first, ...rest] = argv;
34
+ const command = COMMANDS[first];
35
+ // No recognized subcommand name in front — treat the whole argv as
36
+ // arguments to the default command (`check`), same as before subcommands
37
+ // existed: `npx @nitra/cfr flux/clusters/prod` still just works.
38
+ if (!command) return runCheck(argv);
39
+ return command(rest);
135
40
  }
136
41
 
137
- process.exit(main(process.argv.slice(2)));
42
+ main(process.argv.slice(2)).then(
43
+ (code) => process.exit(code),
44
+ (err) => {
45
+ console.error(`✗ ${err.message || err}`);
46
+ process.exit(1);
47
+ },
48
+ );
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,43 @@
1
+ // Тонкий REST-транспорт до Google Cloud API — заміна шелл-аутів у gcloud
2
+ // CLI. Автентифікація через Application Default Credentials (той самий
3
+ // механізм, що й gcloud: `gcloud auth application-default login` локально,
4
+ // service account key через GOOGLE_APPLICATION_CREDENTIALS, метадата-сервер
5
+ // на GCE/GKE) — жодного окремого налаштування не додає, лише прибирає
6
+ // залежність від встановленого `gcloud` на PATH і повільний старт
7
+ // Python-процесу на кожен виклик.
8
+ import { GoogleAuth } from 'google-auth-library';
9
+
10
+ let authClientPromise;
11
+ function client() {
12
+ if (!authClientPromise) {
13
+ const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'] });
14
+ authClientPromise = auth.getClient();
15
+ }
16
+ return authClientPromise;
17
+ }
18
+
19
+ /**
20
+ * GET-запит, повертає розпарсений JSON. Помилку не ковтає мовчки — на
21
+ * відміну від gcloud-версії (де collectNamespace просто бачив би порожній
22
+ * результат), кидає далі, щоб причина (401/403/мережа) була видна.
23
+ */
24
+ export async function getJson(url, params) {
25
+ const c = await client();
26
+ const res = await c.request({ url, params });
27
+ return res.data;
28
+ }
29
+
30
+ /**
31
+ * Пагінація за nextPageToken/pageToken — спільна для Asset Inventory
32
+ * (results[]) і Compute globalAddresses (items[]).
33
+ */
34
+ export async function paginate(url, params, itemsKey) {
35
+ const out = [];
36
+ let pageToken;
37
+ do {
38
+ const data = await getJson(url, { ...params, pageToken });
39
+ out.push(...(data[itemsKey] || []));
40
+ pageToken = data.nextPageToken;
41
+ } while (pageToken);
42
+ return out;
43
+ }
@@ -0,0 +1,160 @@
1
+ // Diff і звіт над сирим списком з kcc-resources.mjs: що з GCP-ресурсів
2
+ // проєкту реально живе під KCC (описано в git), а що — "чуже" (створено
3
+ // повз KCC). Суто читання, нічого не видаляє — вхідний контракт для
4
+ // окремого cleanup-рішення, яке вирішує, що з DRIFT можна прибрати.
5
+ import { collectNamespace, listKccNamespaces, KIND_ORDER } from './kcc-resources.mjs';
6
+
7
+ export const HELP = `cfr kcc-inventory — GCP Config Connector (KCC) drift inventory
8
+
9
+ Usage:
10
+ npx @nitra/cfr kcc-inventory <namespace>
11
+ npx @nitra/cfr kcc-inventory --all
12
+ npx @nitra/cfr kcc-inventory <namespace|--all> [--json] [--include-system]
13
+
14
+ Compares, per KCC namespace (namespace carrying the annotation
15
+ cnrm.cloud.google.com/project-id), what actually exists in the GCP project
16
+ against what's declared as Config Connector custom resources in that
17
+ namespace — for IAMServiceAccount, IAMServiceAccountKey,
18
+ ArtifactRegistryRepository, ContainerCluster, ContainerNodePool,
19
+ StorageBucket, ComputeAddress, DNSManagedZone, DNSRecordSet, and
20
+ IAMPolicyMember.
21
+
22
+ Reports two directions per kind:
23
+ DRIFT — live in GCP, not declared under KCC (adopt it or delete it)
24
+ ORPHAN — declared under KCC, no longer live in GCP
25
+
26
+ By default GCP-managed system noise is filtered out (Google-owned service
27
+ accounts, gcr.io shims, GKE-managed node pools/DNS zones, legacy bucket
28
+ ACL entries, ...) — pass --include-system to see it anyway.
29
+
30
+ Requires \`kubectl\` on PATH, pointed at the target cluster (set KUBE_CONTEXT
31
+ to target a specific kubeconfig context explicitly instead of relying on
32
+ the current one). The GCP side talks to Cloud Asset Inventory, IAM, and
33
+ Compute Engine directly over REST — no \`gcloud\` CLI needed, just
34
+ Application Default Credentials (\`gcloud auth application-default login\`,
35
+ a service account key via GOOGLE_APPLICATION_CREDENTIALS, or the ambient
36
+ credentials on GCE/GKE/Cloud Build).
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
+ // --- diff+звіт по одному kind: sort+diff двох списків, людський звіт і/або JSON
49
+
50
+ function report(kind, liveIds, kccIds, project, jsonMode, results) {
51
+ const live = [...new Set(liveIds.filter(Boolean))].sort();
52
+ const kcc = [...new Set(kccIds.filter(Boolean))].sort();
53
+ const kccSet = new Set(kcc);
54
+ const liveSet = new Set(live);
55
+ const drift = live.filter((x) => !kccSet.has(x)).sort();
56
+ const orphan = kcc.filter((x) => !liveSet.has(x)).sort();
57
+
58
+ if (!jsonMode) {
59
+ console.log(`== ${kind} (проєкт ${project}) ==`);
60
+ if (drift.length) {
61
+ console.log(' DRIFT — є в GCP, немає в KCC:');
62
+ for (const x of drift) console.log(` ${x}`);
63
+ }
64
+ if (orphan.length) {
65
+ console.log(' ORPHAN — є в KCC, зникло з GCP:');
66
+ for (const x of orphan) console.log(` ${x}`);
67
+ }
68
+ if (!drift.length && !orphan.length) {
69
+ console.log(` чисто (${live.length} live, ${kcc.length} kcc)`);
70
+ }
71
+ console.log('');
72
+ }
73
+
74
+ for (const x of drift) results.push({ kind, id: x, project, status: 'drift' });
75
+ for (const x of orphan) results.push({ kind, id: x, project, status: 'orphan' });
76
+ }
77
+
78
+ function printDiagnostic(diag, jsonMode, results, project) {
79
+ if (diag.type === 'stale-cache') {
80
+ const label = diag.kind === 'ComputeAddress'
81
+ ? `проігноровано ${diag.count} запис(ів), яких уже нема в GCP (застарілий кеш Asset Inventory)`
82
+ : `проігноровано ${diag.count} запис(ів) від зон, яких уже нема (застарілий кеш Asset Inventory)`;
83
+ if (jsonMode) {
84
+ results.push({ kind: diag.kind, id: `stale_asset_inventory_cache:${diag.count}`, project, status: 'ignored' });
85
+ } else {
86
+ console.log(`== ${diag.kind}: ${label} ==`);
87
+ console.log('');
88
+ }
89
+ } else if (diag.type === 'gke-managed-skip' && !jsonMode) {
90
+ console.log(`== ${diag.kind}: пропущено (goog-gke-node, керує сам GKE) ==`);
91
+ for (const z of diag.zones) console.log(` ${z}`);
92
+ console.log('');
93
+ }
94
+ }
95
+
96
+ // Groups the flat resources list from kcc-resources.mjs back into
97
+ // per-kind {live, kcc} arrays, diffs and prints each kind in KIND_ORDER,
98
+ // interleaving each kind's diagnostics (if any) right before its report —
99
+ // same order a human reading top to bottom expects.
100
+ function diffAndReport(collected, jsonMode, results) {
101
+ const { project, resources, diagnostics } = collected;
102
+ const byKind = new Map(KIND_ORDER.map((k) => [k, { live: [], kcc: [] }]));
103
+ for (const { kind, id, source } of resources) {
104
+ const bucket = byKind.get(kind);
105
+ if (!bucket) continue;
106
+ bucket[source === 'kcc' ? 'kcc' : 'live'].push(id);
107
+ }
108
+ const diagByKind = new Map();
109
+ for (const d of diagnostics) diagByKind.set(d.kind, d);
110
+
111
+ for (const kind of KIND_ORDER) {
112
+ const diag = diagByKind.get(kind);
113
+ if (diag) printDiagnostic(diag, jsonMode, results, project);
114
+ const { live, kcc } = byKind.get(kind);
115
+ report(kind, live, kcc, project, jsonMode, results);
116
+ }
117
+ }
118
+
119
+ async function scanNamespace(namespace, includeSystem, jsonMode, results) {
120
+ const collected = await collectNamespace(namespace, { includeSystem });
121
+ if (!collected.project) {
122
+ console.error(`namespace ${namespace} не має cnrm.cloud.google.com/project-id`);
123
+ return;
124
+ }
125
+ if (!jsonMode) console.log(`### namespace ${namespace} -> проєкт ${collected.project} ###`);
126
+ diffAndReport(collected, jsonMode, results);
127
+ if (!jsonMode) console.log('');
128
+ }
129
+
130
+ export async function run(argv) {
131
+ if (argv.includes('-h') || argv.includes('--help')) {
132
+ process.stdout.write(HELP);
133
+ return 0;
134
+ }
135
+
136
+ const target = argv[0];
137
+ if (!target) {
138
+ console.error('usage: cfr kcc-inventory <namespace|--all> [--json] [--include-system] (KUBE_CONTEXT=... для явного контексту)');
139
+ return 2;
140
+ }
141
+ const jsonMode = argv.includes('--json');
142
+ const includeSystem = argv.includes('--include-system');
143
+
144
+ let namespaces;
145
+ if (target === '--all') {
146
+ namespaces = listKccNamespaces();
147
+ if (!namespaces.length) {
148
+ console.error('жоден namespace не має cnrm.cloud.google.com/project-id');
149
+ return 2;
150
+ }
151
+ } else {
152
+ namespaces = [target];
153
+ }
154
+
155
+ const results = [];
156
+ for (const ns of namespaces) await scanNamespace(ns, includeSystem, jsonMode, results);
157
+
158
+ if (jsonMode) console.log(JSON.stringify(results, null, 2));
159
+ return 0;
160
+ }
@@ -0,0 +1,406 @@
1
+ // Сирий збір фактів для одного KCC namespace: що є "живого" в GCP-проєкті і
2
+ // що заявлено як Config Connector CR у цьому namespace — для кожного з 10
3
+ // видів ресурсів. Жодного diff-у, жодного форматування, жодного I/O в
4
+ // консоль: тільки звернення до GCP REST API (через gcp-rest.mjs) і
5
+ // kubectl, і нормалізація результату в плаский список. Diff і звіт —
6
+ // робота kcc-inventory.mjs, який споживає цей список.
7
+ //
8
+ // GCP-бік іде напряму через REST (Application Default Credentials), не
9
+ // через `gcloud` CLI: без залежності від встановленого на PATH gcloud, без
10
+ // повільного старту Python-процесу на кожен виклик (сам по собі
11
+ // перевірений внесок у затримку — один search-all-resources на проєкті з
12
+ // сотнями активів під gcloud займав ~100с), і з реальною помилкою замість
13
+ // мовчазного порожнього результату при збої автентифікації.
14
+ import { spawnSync } from 'node:child_process';
15
+ import { getJson, paginate } from './gcp-rest.mjs';
16
+
17
+ export const KIND_ORDER = [
18
+ 'IAMServiceAccount',
19
+ 'IAMServiceAccountKey',
20
+ 'ArtifactRegistryRepository',
21
+ 'ContainerCluster',
22
+ 'ContainerNodePool',
23
+ 'StorageBucket',
24
+ 'ComputeAddress',
25
+ 'DNSManagedZone',
26
+ 'DNSRecordSet',
27
+ 'IAMPolicyMember',
28
+ ];
29
+
30
+ function kubectlBase() {
31
+ const ctx = process.env.KUBE_CONTEXT;
32
+ return ctx ? ['kubectl', '--context', ctx] : ['kubectl'];
33
+ }
34
+
35
+ // maxBuffer: дефолтні 1MB замалі для kubectl get на namespace з великою
36
+ // кількістю CR — spawnSync мовчки падає в ENOBUFS, і виклик, що його не
37
+ // перевіряє (як тут), бачить порожній результат замість помилки. Той самий
38
+ // клас бага, що раніше ловився на gcloud-виклику search-all-resources,
39
+ // поки той не переїхав на пагінований REST (див. gcp-rest.mjs).
40
+ function runCmd(cmd, args) {
41
+ return spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 100 * 1024 * 1024 });
42
+ }
43
+
44
+ function kubectlJson(...args) {
45
+ const [bin, ...base] = kubectlBase();
46
+ const proc = runCmd(bin, [...base, ...args, '-o', 'json']);
47
+ if (proc.status !== 0 || !proc.stdout || !proc.stdout.trim()) return null;
48
+ try {
49
+ return JSON.parse(proc.stdout);
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function kubectlField(crd, namespace, fieldFn) {
56
+ const data = kubectlJson('get', crd, '-n', namespace);
57
+ if (!data) return [];
58
+ return (data.items || []).map(fieldFn).filter(Boolean);
59
+ }
60
+
61
+ const ASSET_API = 'https://cloudasset.googleapis.com/v1';
62
+ const IAM_API = 'https://iam.googleapis.com/v1';
63
+ const COMPUTE_API = 'https://compute.googleapis.com/compute/v1';
64
+
65
+ function searchAllResources(project) {
66
+ return paginate(`${ASSET_API}/projects/${project}:searchAllResources`, { pageSize: 500 }, 'results');
67
+ }
68
+
69
+ function searchAllIamPolicies(project) {
70
+ return paginate(`${ASSET_API}/projects/${project}:searchAllIamPolicies`, { pageSize: 500 }, 'results');
71
+ }
72
+
73
+ // projects/-/serviceAccounts/{email} — валідний шлях в IAM API, project
74
+ // беремо з листа `-`: не треба окремо передавати project поруч з email.
75
+ async function listUserManagedKeyIds(email) {
76
+ const data = await getJson(`${IAM_API}/projects/-/serviceAccounts/${email}/keys`, { keyTypes: 'USER_MANAGED' });
77
+ return (data.keys || []).map((k) => k.name.split('/').pop());
78
+ }
79
+
80
+ // aggregatedList: один виклик на всі регіони одразу, а не по одному на
81
+ // регіон. items — мапа "regions/{region}" -> {addresses: [...]}, не
82
+ // плаский масив під nextPageToken, тому власна пагінація, не paginate().
83
+ async function listRegionalAddressIds(project) {
84
+ const out = [];
85
+ let pageToken;
86
+ do {
87
+ const data = await getJson(`${COMPUTE_API}/projects/${project}/aggregated/addresses`, { pageSize: 500, pageToken });
88
+ for (const [scopeKey, val] of Object.entries(data.items || {})) {
89
+ if (!val.addresses) continue;
90
+ const region = scopeKey.replace(/^regions\//, '');
91
+ for (const a of val.addresses) out.push(`${a.name}/${region}`);
92
+ }
93
+ pageToken = data.nextPageToken;
94
+ } while (pageToken);
95
+ return out;
96
+ }
97
+
98
+ async function listGlobalAddressIds(project) {
99
+ const items = await paginate(`${COMPUTE_API}/projects/${project}/global/addresses`, { pageSize: 500 }, 'items');
100
+ return items.map((a) => `${a.name}/global`);
101
+ }
102
+
103
+ function stripPrefix(s, prefix) {
104
+ return s.startsWith(prefix) ? s.slice(prefix.length) : s;
105
+ }
106
+
107
+ // --- фільтри GCP-системного шуму (див. includeSystem) ----------------------
108
+
109
+ const SA_SYSTEM = [
110
+ /^[0-9]+-compute@developer\.gserviceaccount\.com$/,
111
+ /@appspot\.gserviceaccount\.com$/,
112
+ /@cloudservices\.gserviceaccount\.com$/,
113
+ /^firebase-adminsdk-/,
114
+ ];
115
+
116
+ const AR_SYSTEM = [/(^|\.)gcr\.io$/, /^gcf-artifacts$/];
117
+
118
+ const NODEPOOL_SYSTEM = /^nap-/;
119
+
120
+ function isSystemBucket(name, project) {
121
+ return name.endsWith('.appspot.com') || name.startsWith('gcf-sources-') || name === `${project}_cloudbuild`;
122
+ }
123
+
124
+ // projectEditor:/projectOwner:/projectViewer: — legacy bucket ACL, GCP
125
+ // навішує на кожен бакет сам; решта — Google-керовані service agents.
126
+ const IAM_SYSTEM = [
127
+ /^project(Editor|Owner|Viewer):/,
128
+ /@system\.gserviceaccount\.com$/,
129
+ /serviceAccount:service-[0-9]+@/,
130
+ /@gcp-sa-[a-z0-9-]+\.iam\.gserviceaccount\.com$/,
131
+ /@(cloudbuild|cloudservices|developer|appspot)\.gserviceaccount\.com$/,
132
+ /@(container-engine-robot|serverless-robot-prod|gcf-admin-robot|firebase-rules|firebase-sa-management)\.iam\.gserviceaccount\.com$/,
133
+ ];
134
+
135
+ function isSystem(patterns, value) {
136
+ return patterns.some((p) => p.test(value));
137
+ }
138
+
139
+ // --- IAMPolicyMember: розбір id ресурсу з обох боків -----------------------
140
+
141
+ function assetResId(entry, project) {
142
+ const assetType = entry.assetType || '';
143
+ const res = entry.resource || '';
144
+ if (res.endsWith(`/projects/${project}`)) return `project/${project}`;
145
+ const mSa = res.match(/\/serviceAccounts\/([^/]+)$/);
146
+ if (mSa && assetType === 'iam.googleapis.com/ServiceAccount') return `sa/${mSa[1]}`;
147
+ const mAr = res.match(/\/repositories\/([^/]+)$/);
148
+ if (mAr && assetType === 'artifactregistry.googleapis.com/Repository') return `ar/${mAr[1]}`;
149
+ if (assetType === 'storage.googleapis.com/Bucket') return 'bucket/' + stripPrefix(res, '//storage.googleapis.com/');
150
+ return 'other/' + stripPrefix(res, '//');
151
+ }
152
+
153
+ function kccArId(ref) {
154
+ const ext = ref.external || '';
155
+ const m = ext.match(/\/repositories\/([^/]+)$/);
156
+ if (m) return m[1];
157
+ if (ref.name) return ref.name;
158
+ return '?';
159
+ }
160
+
161
+ function kccSaId(ref, project) {
162
+ const ext = ref.external || '';
163
+ const m = ext.match(/\/serviceAccounts\/([^/]+)$/);
164
+ if (m) return m[1];
165
+ if (ext) return ext;
166
+ if (ref.name) return `${ref.name}@${project}.iam.gserviceaccount.com`;
167
+ return '?';
168
+ }
169
+
170
+ function kccPolicyMemberId(item, project) {
171
+ const ref = (item.spec && item.spec.resourceRef) || {};
172
+ switch (ref.kind) {
173
+ case 'Project':
174
+ return `project/${project}`;
175
+ case 'IAMServiceAccount':
176
+ return 'sa/' + kccSaId(ref, project);
177
+ case 'ArtifactRegistryRepository':
178
+ return 'ar/' + kccArId(ref);
179
+ case 'StorageBucket':
180
+ return 'bucket/' + (ref.external || ref.name || '?');
181
+ default:
182
+ return 'other/' + (ref.external || ref.name || '?');
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Проєкт GCP, прив'язаний до namespace, або null, якщо анотація відсутня.
188
+ */
189
+ export function projectForNamespace(namespace) {
190
+ const ns = kubectlJson('get', 'namespace', namespace);
191
+ return (ns && ns.metadata && ns.metadata.annotations
192
+ && ns.metadata.annotations['cnrm.cloud.google.com/project-id']) || null;
193
+ }
194
+
195
+ /**
196
+ * Усі namespace з анотацією cnrm.cloud.google.com/project-id.
197
+ */
198
+ export function listKccNamespaces() {
199
+ const data = kubectlJson('get', 'namespace');
200
+ return ((data && data.items) || [])
201
+ .filter((i) => i.metadata && i.metadata.annotations && i.metadata.annotations['cnrm.cloud.google.com/project-id'])
202
+ .map((i) => i.metadata.name);
203
+ }
204
+
205
+ /**
206
+ * Збирає сирий список ресурсів одного namespace: усе, що живе в GCP
207
+ * (source: 'gcp'), і все, що заявлено під KCC (source: 'kcc'), для кожного
208
+ * з KIND_ORDER. Дублікати не прибираються — це відповідальність споживача.
209
+ *
210
+ * diagnostics — записи, які не є ні "живим", ні "заявленим" ресурсом, а
211
+ * приміткою про сам збір: застарілий кеш Cloud Asset Inventory, зони, якими
212
+ * керує сам GKE.
213
+ *
214
+ * Повертає { namespace, project, resources, diagnostics }. project — null,
215
+ * якщо namespace не має анотації cnrm.cloud.google.com/project-id
216
+ * (resources і diagnostics тоді порожні).
217
+ */
218
+ export async function collectNamespace(namespace, { includeSystem = false } = {}) {
219
+ const project = projectForNamespace(namespace);
220
+ if (!project) return { namespace, project: null, resources: [], diagnostics: [] };
221
+
222
+ const resources = [];
223
+ const diagnostics = [];
224
+ const push = (kind, ids, source) => {
225
+ for (const id of ids) if (id) resources.push({ kind, id, source });
226
+ };
227
+
228
+ const assets = await searchAllResources(project);
229
+ const byType = (...types) => assets.filter((a) => types.includes(a.assetType));
230
+
231
+ // --- IAMServiceAccount ---------------------------------------------------
232
+ const allLiveSa = byType('iam.googleapis.com/ServiceAccount')
233
+ .map((a) => a.additionalAttributes && a.additionalAttributes.email)
234
+ .filter(Boolean);
235
+ let liveSa = allLiveSa;
236
+ if (!includeSystem) liveSa = liveSa.filter((e) => !isSystem(SA_SYSTEM, e));
237
+ push('IAMServiceAccount', liveSa, 'gcp');
238
+ push('IAMServiceAccount', kubectlField(
239
+ 'iamserviceaccounts.iam.cnrm.cloud.google.com', namespace,
240
+ (i) => i.status && i.status.email,
241
+ ), 'kcc');
242
+
243
+ // --- IAMServiceAccountKey (тільки user-managed) ---------------------------
244
+ // Живі SA для циклу ключів беремо БЕЗ фільтра system — дешевше не
245
+ // ускладнювати, ключ системного SA все одно рідкість. Один REST-виклик
246
+ // на SA, усі паралельно — раніше це був послідовний цикл окремих
247
+ // процесів gcloud, найповільніша частина всього скану.
248
+ const liveKey = (await Promise.all(allLiveSa.map(listUserManagedKeyIds))).flat();
249
+ push('IAMServiceAccountKey', liveKey, 'gcp');
250
+ push('IAMServiceAccountKey', kubectlField(
251
+ 'iamserviceaccountkeys.iam.cnrm.cloud.google.com', namespace,
252
+ (i) => i.status && i.status.name,
253
+ ).map((n) => n.split('/').pop()), 'kcc');
254
+
255
+ // --- ArtifactRegistryRepository -------------------------------------------
256
+ let liveAr = byType('artifactregistry.googleapis.com/Repository')
257
+ .map((a) => {
258
+ const m = (a.name || '').match(/\/repositories\/([^/]+)$/);
259
+ return m && m[1];
260
+ })
261
+ .filter(Boolean);
262
+ if (!includeSystem) liveAr = liveAr.filter((r) => !isSystem(AR_SYSTEM, r));
263
+ push('ArtifactRegistryRepository', liveAr, 'gcp');
264
+ push('ArtifactRegistryRepository', kubectlField(
265
+ 'artifactregistryrepositories.artifactregistry.cnrm.cloud.google.com', namespace,
266
+ (i) => i.spec && i.spec.resourceID,
267
+ ), 'kcc');
268
+
269
+ // --- ContainerCluster + ContainerNodePool ---------------------------------
270
+ push('ContainerCluster', byType('container.googleapis.com/Cluster').map((a) => a.displayName), 'gcp');
271
+ push('ContainerCluster', kubectlField(
272
+ 'containerclusters.container.cnrm.cloud.google.com', namespace,
273
+ (i) => i.spec && i.spec.resourceID,
274
+ ), 'kcc');
275
+
276
+ const livePool = [];
277
+ for (const a of byType('container.googleapis.com/NodePool')) {
278
+ const m = (a.name || '').match(/\/clusters\/([^/]+)\/nodePools\/([^/]+)$/);
279
+ if (!m) continue;
280
+ const [, cluster, pool] = m;
281
+ if (includeSystem || !NODEPOOL_SYSTEM.test(pool)) livePool.push(`${cluster}/${pool}`);
282
+ }
283
+ push('ContainerNodePool', livePool, 'gcp');
284
+ push('ContainerNodePool', kubectlField(
285
+ 'containernodepools.container.cnrm.cloud.google.com', namespace,
286
+ (i) => {
287
+ const ref = (i.spec && i.spec.clusterRef) || {};
288
+ return `${ref.name || ref.external}/${i.spec && i.spec.resourceID}`;
289
+ },
290
+ ), 'kcc');
291
+
292
+ // --- StorageBucket ---------------------------------------------------------
293
+ let liveBucket = byType('storage.googleapis.com/Bucket').map((a) => a.displayName);
294
+ if (!includeSystem) liveBucket = liveBucket.filter((b) => !isSystemBucket(b, project));
295
+ push('StorageBucket', liveBucket, 'gcp');
296
+ push('StorageBucket', kubectlField(
297
+ 'storagebuckets.storage.cnrm.cloud.google.com', namespace,
298
+ (i) => i.spec && i.spec.resourceID,
299
+ ), 'kcc');
300
+
301
+ // --- ComputeAddress (регіональні й глобальні) -------------------------------
302
+ // Asset Inventory інколи повертає вже видалені адреси (перевірено
303
+ // емпірично на azovmemo: search-all-resources показав адресу без
304
+ // читабельного імені, якої `gcloud compute addresses describe` вже не
305
+ // знаходить — той самий клас застарілого кешу, що й ResourceRecordSet).
306
+ // Два дешевих прямих виклики (regional + global), не по одному на
307
+ // ресурс, — фільтруємо ними ці привиди CAI.
308
+ const [regionalAddrIds, globalAddrIds] = await Promise.all([
309
+ listRegionalAddressIds(project),
310
+ listGlobalAddressIds(project),
311
+ ]);
312
+ const liveAddrIds = new Set([...regionalAddrIds, ...globalAddrIds]);
313
+ const staleAddrCount0 = byType('compute.googleapis.com/Address', 'compute.googleapis.com/GlobalAddress').length;
314
+ const liveAddr = byType('compute.googleapis.com/Address', 'compute.googleapis.com/GlobalAddress')
315
+ .map((a) => `${a.displayName}/${a.location === 'global' ? 'global' : a.location}`)
316
+ .filter((id) => liveAddrIds.has(id));
317
+ const staleAddrCount = staleAddrCount0 - liveAddr.length;
318
+ if (staleAddrCount) diagnostics.push({ kind: 'ComputeAddress', type: 'stale-cache', count: staleAddrCount });
319
+ push('ComputeAddress', liveAddr, 'gcp');
320
+ push('ComputeAddress', kubectlField(
321
+ 'computeaddresses.compute.cnrm.cloud.google.com', namespace,
322
+ (i) => `${i.spec && i.spec.resourceID}/${(i.spec && i.spec.location) || 'global'}`,
323
+ ), 'kcc');
324
+
325
+ // --- DNSManagedZone + DNSRecordSet -------------------------------------------
326
+ // Зони, які GKE заводить і веде сам (label goog-gke-node) — не в diff
327
+ // узагалі: не KCC-кандидат, і рекордсетів там сотні на Service.
328
+ const zones = byType('dns.googleapis.com/ManagedZone');
329
+ const gkeZoneNames = new Set(
330
+ zones.filter((z) => z.labels && z.labels['goog-gke-node'] !== undefined).map((z) => z.displayName),
331
+ );
332
+ const liveZone = zones.map((z) => z.displayName).filter((n) => !gkeZoneNames.has(n));
333
+ if (gkeZoneNames.size) {
334
+ diagnostics.push({ kind: 'DNSManagedZone', type: 'gke-managed-skip', zones: [...gkeZoneNames].sort() });
335
+ }
336
+ push('DNSManagedZone', liveZone, 'gcp');
337
+ push('DNSManagedZone', kubectlField(
338
+ 'dnsmanagedzones.dns.cnrm.cloud.google.com', namespace,
339
+ (i) => i.spec && i.spec.resourceID,
340
+ ), 'kcc');
341
+
342
+ // rrset .name містить числовий id зони, не читабельне ім'я — мапа
343
+ // id->displayName будується з тих самих ManagedZone-записів.
344
+ const zoneNameById = new Map();
345
+ for (const z of zones) {
346
+ const m = (z.name || '').match(/\/managedZones\/([^/]+)$/);
347
+ if (m) zoneNameById.set(m[1], z.displayName);
348
+ }
349
+ // Cloud Asset Inventory інколи повертає ResourceRecordSet від зон, яких
350
+ // уже немає (перевірено емпірично на azovmemo: rrsets посилались на три
351
+ // різні managedZone-id, жоден з яких не збігався з id живої зони —
352
+ // застарілий кеш після пересоздання GKE-зони). Запис без резолву в
353
+ // zoneNameById — не дрейф, а сміття CAI, тому пропускаємо мовчки, а не
354
+ // фолбечимось на сирий id (інакше він ніколи не збіжиться з
355
+ // gkeZoneNames і завжди рахувався б за DRIFT).
356
+ let staleRrsetCount = 0;
357
+ const liveRrset = [];
358
+ for (const a of byType('dns.googleapis.com/ResourceRecordSet')) {
359
+ const mZid = (a.parentFullResourceName || '').match(/\/managedZones\/([^/]+)$/);
360
+ if (!mZid) continue;
361
+ const zname = zoneNameById.get(mZid[1]);
362
+ if (zname === undefined) {
363
+ staleRrsetCount += 1;
364
+ continue;
365
+ }
366
+ if (gkeZoneNames.has(zname)) continue;
367
+ const mRr = (a.name || '').match(/\/rrsets\/(.+)\/([A-Za-z]+)$/);
368
+ if (!mRr) continue;
369
+ liveRrset.push(`${zname}/${mRr[1]}/${mRr[2]}`);
370
+ }
371
+ if (staleRrsetCount) diagnostics.push({ kind: 'DNSRecordSet', type: 'stale-cache', count: staleRrsetCount });
372
+ push('DNSRecordSet', liveRrset, 'gcp');
373
+ push('DNSRecordSet', kubectlField(
374
+ 'dnsrecordsets.dns.cnrm.cloud.google.com', namespace,
375
+ (i) => {
376
+ const zref = (i.spec && i.spec.managedZoneRef) || {};
377
+ return `${zref.name}/${i.spec && i.spec.name}/${i.spec && i.spec.type}`;
378
+ },
379
+ ), 'kcc');
380
+
381
+ // --- IAMPolicyMember ---------------------------------------------------------
382
+ // search-all-iam-policies — усі біндинги проєкту одразу, на будь-якому
383
+ // типі ресурсу, не тільки на трьох раніше підтримуваних (Project/SA/AR).
384
+ const iamPolicies = await searchAllIamPolicies(project);
385
+ const liveIam = [];
386
+ for (const entry of iamPolicies) {
387
+ const rid = assetResId(entry, project);
388
+ for (const binding of (entry.policy && entry.policy.bindings) || []) {
389
+ for (const member of binding.members || []) {
390
+ if (includeSystem || !isSystem(IAM_SYSTEM, member)) {
391
+ liveIam.push(`${rid}/${binding.role}/${member}`);
392
+ }
393
+ }
394
+ }
395
+ }
396
+ push('IAMPolicyMember', liveIam, 'gcp');
397
+
398
+ const kccPolicyItems = kubectlJson('get', 'iampolicymembers.iam.cnrm.cloud.google.com', '-n', namespace);
399
+ push('IAMPolicyMember', ((kccPolicyItems && kccPolicyItems.items) || []).map((item) => {
400
+ const rid = kccPolicyMemberId(item, project);
401
+ const spec = item.spec || {};
402
+ return `${rid}/${spec.role}/${spec.member}`;
403
+ }), 'kcc');
404
+
405
+ return { namespace, project, resources, diagnostics };
406
+ }
package/package.json CHANGED
@@ -1,14 +1,17 @@
1
1
  {
2
2
  "name": "@nitra/cfr",
3
- "version": "0.1.2",
4
- "description": "Verify that every YAML file in a Kustomize directory is listed in kustomization.yaml's explicit resources: (and vice versa) catches files Flux silently ignores.",
3
+ "version": "0.3.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,12 +29,16 @@
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"
33
37
  },
34
38
  "scripts": {
35
39
  "test": "node --test test/cli.test.mjs"
40
+ },
41
+ "dependencies": {
42
+ "google-auth-library": "^10.9.1"
36
43
  }
37
44
  }