@nitra/cfr 0.4.0 → 0.5.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
@@ -117,16 +117,24 @@ npx @nitra/cfr kcc-inventory <namespace|--all> --include-system # don't filter
117
117
  чисто (11 live, 11 kcc)
118
118
  ```
119
119
 
120
- Requires `kubectl` on `PATH`, pointed at the target cluster. Set
121
- `KUBE_CONTEXT` to target a specific kubeconfig context explicitly instead
122
- of relying on the current one. The GCP side talks to Cloud Asset
123
- Inventory, IAM, and Compute Engine directly over REST — no `gcloud` CLI
124
- needed, just [Application Default
120
+ No `gcloud` or `kubectl` CLI needed on `PATH` the GCP side (Cloud Asset
121
+ Inventory, IAM, Compute Engine) and the cluster side both talk REST
122
+ directly, authenticated with [Application Default
125
123
  Credentials](https://cloud.google.com/docs/authentication/application-default-credentials)
126
124
  (`gcloud auth application-default login` locally, a service account key
127
125
  via `GOOGLE_APPLICATION_CREDENTIALS`, or the ambient credentials on
128
126
  GCE/GKE/Cloud Build).
129
127
 
128
+ Cluster connection details still come from your kubeconfig (`KUBECONFIG`,
129
+ default `~/.kube/config`) — that part isn't going anywhere, it's the only
130
+ place the cluster's API server address and CA certificate live. Set
131
+ `KUBE_CONTEXT` to target a specific context explicitly instead of relying
132
+ on `current-context`. The GCP access token is used directly as the
133
+ cluster bearer token (the same trick `gke-gcloud-auth-plugin` performs
134
+ under `kubectl`), so this only works against **GKE** clusters — a
135
+ kubeconfig using client certs, a static token, or a non-GCP exec plugin
136
+ (EKS, AKS, ...) won't authenticate.
137
+
130
138
  By default, GCP-managed system noise is filtered out — Google-owned
131
139
  service accounts, `gcr.io` shims, GKE-managed node pools and DNS zones,
132
140
  legacy bucket ACL entries. Pass `--include-system` to see it anyway.
@@ -178,9 +186,8 @@ npx @nitra/cfr get-resources <namespace|--all> --include-system
178
186
  diagnostics: [...]}` — `source` is `"gcp"` or `"kcc"`, `diagnostics`
179
187
  carries the same stale-cache/GKE-managed notes described above.
180
188
 
181
- Same requirements as `kcc-inventory`: `kubectl` on `PATH`, Application
182
- Default Credentials for the GCP side, `--include-system` to see
183
- GCP-managed noise.
189
+ Same requirements as `kcc-inventory` — no `gcloud`/`kubectl` needed, GKE
190
+ only, `--include-system` to see GCP-managed noise.
184
191
 
185
192
  ## License
186
193
 
package/bin/cli.mjs CHANGED
@@ -24,9 +24,9 @@ const COMMANDS = {
24
24
  'get-resources': runGetResources,
25
25
  };
26
26
 
27
- // check is synchronous (local filesystem only); kcc-inventory is async
28
- // (talks to GCP/kubectl over the network) — awaiting a plain number is a
29
- // no-op, so this works for either.
27
+ // check is synchronous (local filesystem only); the other two are async
28
+ // (talk to GCP/Kubernetes over the network) — awaiting a plain number is
29
+ // a no-op, so this works for either.
30
30
  async function main(argv) {
31
31
  if (argv[0] === '-h' || argv[0] === '--help') {
32
32
  process.stdout.write(TOP_HELP);
package/lib/gcp-rest.mjs CHANGED
@@ -16,6 +16,18 @@ function client() {
16
16
  return authClientPromise;
17
17
  }
18
18
 
19
+ /**
20
+ * Свіжий access token з того самого ADC-клієнта, що й GCP REST-виклики —
21
+ * спільний з k8s-rest.mjs: GKE приймає токен GCP IAM напряму як bearer
22
+ * (те саме, що робить `gke-gcloud-auth-plugin` під капотом kubectl), тож
23
+ * окремого клієнта чи окремого запиту токена для k8s-боку не треба.
24
+ */
25
+ export async function getAccessToken() {
26
+ const c = await client();
27
+ const { token } = await c.getAccessToken();
28
+ return token;
29
+ }
30
+
19
31
  /**
20
32
  * GET-запит, повертає розпарсений JSON. Помилку не ковтає мовчки — на
21
33
  * відміну від gcloud-версії (де collectNamespace просто бачив би порожній
@@ -1,18 +1,18 @@
1
1
  // Сирий збір фактів для одного KCC namespace: що є "живого" в GCP-проєкті і
2
2
  // що заявлено як Config Connector CR у цьому namespace — для кожного з 10
3
3
  // видів ресурсів. Жодного diff-у, жодного форматування, жодного I/O в
4
- // консоль: тільки звернення до GCP REST API (через gcp-rest.mjs) і
5
- // kubectl, і нормалізація результату в плаский список. Diff і звіт —
6
- // робота kcc-inventory.mjs, який споживає цей список.
4
+ // консоль: тільки звернення до GCP REST API (gcp-rest.mjs) і Kubernetes
5
+ // REST API (k8s-rest.mjs), і нормалізація результату в плаский список.
6
+ // Diff і звіт — робота kcc-inventory.mjs, який споживає цей список.
7
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';
8
+ // Обидва боки йдуть напряму через REST (Application Default Credentials),
9
+ // без gcloud/kubectl на PATH: без повільного старту процесу на кожен
10
+ // виклик (сам по собі перевірений внесок у затримку — один
11
+ // search-all-resources на проєкті з сотнями активів під gcloud займав
12
+ // ~100с), і з реальною помилкою замість мовчазного порожнього результату
13
+ // при збої автентифікації.
15
14
  import { getJson, paginate } from './gcp-rest.mjs';
15
+ import { getNamespace, listNamespaces, listCustomObjects } from './k8s-rest.mjs';
16
16
 
17
17
  export const KIND_ORDER = [
18
18
  'IAMServiceAccount',
@@ -27,35 +27,9 @@ export const KIND_ORDER = [
27
27
  'IAMPolicyMember',
28
28
  ];
29
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);
30
+ async function kccField(kind, namespace, fieldFn) {
31
+ const items = await listCustomObjects(kind, namespace);
32
+ return items.map(fieldFn).filter(Boolean);
59
33
  }
60
34
 
61
35
  const ASSET_API = 'https://cloudasset.googleapis.com/v1';
@@ -186,8 +160,8 @@ function kccPolicyMemberId(item, project) {
186
160
  /**
187
161
  * Проєкт GCP, прив'язаний до namespace, або null, якщо анотація відсутня.
188
162
  */
189
- export function projectForNamespace(namespace) {
190
- const ns = kubectlJson('get', 'namespace', namespace);
163
+ export async function projectForNamespace(namespace) {
164
+ const ns = await getNamespace(namespace);
191
165
  return (ns && ns.metadata && ns.metadata.annotations
192
166
  && ns.metadata.annotations['cnrm.cloud.google.com/project-id']) || null;
193
167
  }
@@ -195,9 +169,9 @@ export function projectForNamespace(namespace) {
195
169
  /**
196
170
  * Усі namespace з анотацією cnrm.cloud.google.com/project-id.
197
171
  */
198
- export function listKccNamespaces() {
199
- const data = kubectlJson('get', 'namespace');
200
- return ((data && data.items) || [])
172
+ export async function listKccNamespaces() {
173
+ const items = await listNamespaces();
174
+ return items
201
175
  .filter((i) => i.metadata && i.metadata.annotations && i.metadata.annotations['cnrm.cloud.google.com/project-id'])
202
176
  .map((i) => i.metadata.name);
203
177
  }
@@ -216,7 +190,7 @@ export function listKccNamespaces() {
216
190
  * (resources і diagnostics тоді порожні).
217
191
  */
218
192
  export async function collectNamespace(namespace, { includeSystem = false } = {}) {
219
- const project = projectForNamespace(namespace);
193
+ const project = await projectForNamespace(namespace);
220
194
  if (!project) return { namespace, project: null, resources: [], diagnostics: [] };
221
195
 
222
196
  const resources = [];
@@ -235,8 +209,8 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
235
209
  let liveSa = allLiveSa;
236
210
  if (!includeSystem) liveSa = liveSa.filter((e) => !isSystem(SA_SYSTEM, e));
237
211
  push('IAMServiceAccount', liveSa, 'gcp');
238
- push('IAMServiceAccount', kubectlField(
239
- 'iamserviceaccounts.iam.cnrm.cloud.google.com', namespace,
212
+ push('IAMServiceAccount', await kccField(
213
+ 'IAMServiceAccount', namespace,
240
214
  (i) => i.status && i.status.email,
241
215
  ), 'kcc');
242
216
 
@@ -247,10 +221,10 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
247
221
  // процесів gcloud, найповільніша частина всього скану.
248
222
  const liveKey = (await Promise.all(allLiveSa.map(listUserManagedKeyIds))).flat();
249
223
  push('IAMServiceAccountKey', liveKey, 'gcp');
250
- push('IAMServiceAccountKey', kubectlField(
251
- 'iamserviceaccountkeys.iam.cnrm.cloud.google.com', namespace,
224
+ push('IAMServiceAccountKey', (await kccField(
225
+ 'IAMServiceAccountKey', namespace,
252
226
  (i) => i.status && i.status.name,
253
- ).map((n) => n.split('/').pop()), 'kcc');
227
+ )).map((n) => n.split('/').pop()), 'kcc');
254
228
 
255
229
  // --- ArtifactRegistryRepository -------------------------------------------
256
230
  let liveAr = byType('artifactregistry.googleapis.com/Repository')
@@ -261,15 +235,15 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
261
235
  .filter(Boolean);
262
236
  if (!includeSystem) liveAr = liveAr.filter((r) => !isSystem(AR_SYSTEM, r));
263
237
  push('ArtifactRegistryRepository', liveAr, 'gcp');
264
- push('ArtifactRegistryRepository', kubectlField(
265
- 'artifactregistryrepositories.artifactregistry.cnrm.cloud.google.com', namespace,
238
+ push('ArtifactRegistryRepository', await kccField(
239
+ 'ArtifactRegistryRepository', namespace,
266
240
  (i) => i.spec && i.spec.resourceID,
267
241
  ), 'kcc');
268
242
 
269
243
  // --- ContainerCluster + ContainerNodePool ---------------------------------
270
244
  push('ContainerCluster', byType('container.googleapis.com/Cluster').map((a) => a.displayName), 'gcp');
271
- push('ContainerCluster', kubectlField(
272
- 'containerclusters.container.cnrm.cloud.google.com', namespace,
245
+ push('ContainerCluster', await kccField(
246
+ 'ContainerCluster', namespace,
273
247
  (i) => i.spec && i.spec.resourceID,
274
248
  ), 'kcc');
275
249
 
@@ -281,8 +255,8 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
281
255
  if (includeSystem || !NODEPOOL_SYSTEM.test(pool)) livePool.push(`${cluster}/${pool}`);
282
256
  }
283
257
  push('ContainerNodePool', livePool, 'gcp');
284
- push('ContainerNodePool', kubectlField(
285
- 'containernodepools.container.cnrm.cloud.google.com', namespace,
258
+ push('ContainerNodePool', await kccField(
259
+ 'ContainerNodePool', namespace,
286
260
  (i) => {
287
261
  const ref = (i.spec && i.spec.clusterRef) || {};
288
262
  return `${ref.name || ref.external}/${i.spec && i.spec.resourceID}`;
@@ -293,8 +267,8 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
293
267
  let liveBucket = byType('storage.googleapis.com/Bucket').map((a) => a.displayName);
294
268
  if (!includeSystem) liveBucket = liveBucket.filter((b) => !isSystemBucket(b, project));
295
269
  push('StorageBucket', liveBucket, 'gcp');
296
- push('StorageBucket', kubectlField(
297
- 'storagebuckets.storage.cnrm.cloud.google.com', namespace,
270
+ push('StorageBucket', await kccField(
271
+ 'StorageBucket', namespace,
298
272
  (i) => i.spec && i.spec.resourceID,
299
273
  ), 'kcc');
300
274
 
@@ -317,8 +291,8 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
317
291
  const staleAddrCount = staleAddrCount0 - liveAddr.length;
318
292
  if (staleAddrCount) diagnostics.push({ kind: 'ComputeAddress', type: 'stale-cache', count: staleAddrCount });
319
293
  push('ComputeAddress', liveAddr, 'gcp');
320
- push('ComputeAddress', kubectlField(
321
- 'computeaddresses.compute.cnrm.cloud.google.com', namespace,
294
+ push('ComputeAddress', await kccField(
295
+ 'ComputeAddress', namespace,
322
296
  (i) => `${i.spec && i.spec.resourceID}/${(i.spec && i.spec.location) || 'global'}`,
323
297
  ), 'kcc');
324
298
 
@@ -334,8 +308,8 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
334
308
  diagnostics.push({ kind: 'DNSManagedZone', type: 'gke-managed-skip', zones: [...gkeZoneNames].sort() });
335
309
  }
336
310
  push('DNSManagedZone', liveZone, 'gcp');
337
- push('DNSManagedZone', kubectlField(
338
- 'dnsmanagedzones.dns.cnrm.cloud.google.com', namespace,
311
+ push('DNSManagedZone', await kccField(
312
+ 'DNSManagedZone', namespace,
339
313
  (i) => i.spec && i.spec.resourceID,
340
314
  ), 'kcc');
341
315
 
@@ -370,8 +344,8 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
370
344
  }
371
345
  if (staleRrsetCount) diagnostics.push({ kind: 'DNSRecordSet', type: 'stale-cache', count: staleRrsetCount });
372
346
  push('DNSRecordSet', liveRrset, 'gcp');
373
- push('DNSRecordSet', kubectlField(
374
- 'dnsrecordsets.dns.cnrm.cloud.google.com', namespace,
347
+ push('DNSRecordSet', await kccField(
348
+ 'DNSRecordSet', namespace,
375
349
  (i) => {
376
350
  const zref = (i.spec && i.spec.managedZoneRef) || {};
377
351
  return `${zref.name}/${i.spec && i.spec.name}/${i.spec && i.spec.type}`;
@@ -395,12 +369,14 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
395
369
  }
396
370
  push('IAMPolicyMember', liveIam, 'gcp');
397
371
 
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');
372
+ push('IAMPolicyMember', await kccField(
373
+ 'IAMPolicyMember', namespace,
374
+ (item) => {
375
+ const rid = kccPolicyMemberId(item, project);
376
+ const spec = item.spec || {};
377
+ return `${rid}/${spec.role}/${spec.member}`;
378
+ },
379
+ ), 'kcc');
404
380
 
405
381
  return { namespace, project, resources, diagnostics };
406
382
  }
@@ -430,11 +406,12 @@ By default GCP-managed system noise is filtered out (Google-owned service
430
406
  accounts, gcr.io shims, GKE-managed node pools/DNS zones, legacy bucket
431
407
  ACL entries, ...) — pass --include-system to see it anyway.
432
408
 
433
- Requires \`kubectl\` on PATH, pointed at the target cluster (set KUBE_CONTEXT
434
- to target a specific kubeconfig context explicitly instead of relying on
435
- the current one). The GCP side talks to Cloud Asset Inventory, IAM, and
436
- Compute Engine directly over REST via Application Default Credentials —
437
- no \`gcloud\` CLI needed.
409
+ No \`gcloud\` or \`kubectl\` CLI needed both the GCP side and the cluster
410
+ side talk REST directly, authenticated with Application Default
411
+ Credentials. Cluster connection details (server, CA) still come from your
412
+ kubeconfig (KUBECONFIG, default ~/.kube/config); set KUBE_CONTEXT to
413
+ target a specific context explicitly instead of relying on
414
+ current-context. Only tested against GKE.
438
415
 
439
416
  Options:
440
417
  --json Emit {resources, diagnostics} as JSON instead of the
@@ -483,7 +460,7 @@ export async function run(argv) {
483
460
 
484
461
  let namespaces;
485
462
  if (target === '--all') {
486
- namespaces = listKccNamespaces();
463
+ namespaces = await listKccNamespaces();
487
464
  if (!namespaces.length) {
488
465
  console.error('жоден namespace не має cnrm.cloud.google.com/project-id');
489
466
  return 2;
@@ -0,0 +1,146 @@
1
+ // Тонкий REST-транспорт до Kubernetes API server — заміна шелл-аутів у
2
+ // kubectl CLI, для тих самих причин, що gcp-rest.mjs замінив gcloud: без
3
+ // залежності від встановленого на PATH kubectl.
4
+ //
5
+ // Автентифікація — той самий ADC-токен, що йде на GCP REST-виклики
6
+ // (gcp-rest.mjs:getAccessToken), не токен із самого kubeconfig. GKE
7
+ // приймає OAuth2-токен GCP IAM напряму як bearer — це буквально те, що
8
+ // робить exec-плагін `gke-gcloud-auth-plugin` під капотом kubectl, тільки
9
+ // без окремого бінарника. Тому цей шар навмисно вузький: працює для GKE
10
+ // (єдиний тип кластера, на якому реально стоїть KCC), не претендує бути
11
+ // клієнтом для будь-якого kubeconfig — client-cert- чи Azure/AWS-подібна
12
+ // exec-автентифікація тут не підтримується.
13
+ //
14
+ // server/CA беруться з kubeconfig як завжди (KUBECONFIG, чи
15
+ // ~/.kube/config; контекст — KUBE_CONTEXT, чи current-context файлу).
16
+ import { readFileSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import https from 'node:https';
20
+ import { parse as parseYaml } from 'yaml';
21
+ import { getAccessToken } from './gcp-rest.mjs';
22
+
23
+ function kubeconfigPath() {
24
+ return process.env.KUBECONFIG || join(homedir(), '.kube', 'config');
25
+ }
26
+
27
+ let configCache;
28
+ function loadKubeconfig() {
29
+ if (!configCache) configCache = parseYaml(readFileSync(kubeconfigPath(), 'utf8'));
30
+ return configCache;
31
+ }
32
+
33
+ let clusterInfoCache;
34
+ function clusterInfo() {
35
+ if (clusterInfoCache) return clusterInfoCache;
36
+
37
+ const config = loadKubeconfig();
38
+ const contextName = process.env.KUBE_CONTEXT || config['current-context'];
39
+ if (!contextName) throw new Error('kubeconfig has no current-context (set KUBE_CONTEXT)');
40
+
41
+ const ctxEntry = (config.contexts || []).find((c) => c.name === contextName);
42
+ if (!ctxEntry) throw new Error(`context "${contextName}" not found in kubeconfig`);
43
+
44
+ const clusterName = ctxEntry.context.cluster;
45
+ const clusterEntry = (config.clusters || []).find((c) => c.name === clusterName);
46
+ if (!clusterEntry) throw new Error(`cluster "${clusterName}" not found in kubeconfig`);
47
+
48
+ const cluster = clusterEntry.cluster;
49
+ const ca = cluster['certificate-authority-data']
50
+ ? Buffer.from(cluster['certificate-authority-data'], 'base64')
51
+ : cluster['certificate-authority']
52
+ ? readFileSync(cluster['certificate-authority'])
53
+ : undefined;
54
+
55
+ clusterInfoCache = { server: cluster.server, ca, insecureSkipTlsVerify: !!cluster['insecure-skip-tls-verify'] };
56
+ return clusterInfoCache;
57
+ }
58
+
59
+ async function requestJson(pathAndQuery) {
60
+ const { server, ca, insecureSkipTlsVerify } = clusterInfo();
61
+ const token = await getAccessToken();
62
+ const url = new URL(pathAndQuery, server);
63
+
64
+ return new Promise((resolve, reject) => {
65
+ const req = https.request(url, {
66
+ method: 'GET',
67
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
68
+ ca,
69
+ rejectUnauthorized: !insecureSkipTlsVerify,
70
+ }, (res) => {
71
+ let body = '';
72
+ res.setEncoding('utf8');
73
+ res.on('data', (chunk) => { body += chunk; });
74
+ res.on('end', () => {
75
+ if (res.statusCode < 200 || res.statusCode >= 300) {
76
+ const err = new Error(`kubernetes API ${res.statusCode} ${pathAndQuery}: ${body.slice(0, 300)}`);
77
+ err.status = res.statusCode;
78
+ reject(err);
79
+ return;
80
+ }
81
+ try {
82
+ resolve(body ? JSON.parse(body) : null);
83
+ } catch (err) {
84
+ reject(err);
85
+ }
86
+ });
87
+ });
88
+ req.on('error', reject);
89
+ req.end();
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Один namespace, або null якщо його немає (404) — той самий сигнал, що
95
+ * раніше давав ненульовий exit-код kubectl.
96
+ */
97
+ export async function getNamespace(name) {
98
+ try {
99
+ return await requestJson(`/api/v1/namespaces/${encodeURIComponent(name)}`);
100
+ } catch (err) {
101
+ if (err.status === 404) return null;
102
+ throw err;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Усі namespace кластера.
108
+ */
109
+ export async function listNamespaces() {
110
+ const data = await requestJson('/api/v1/namespaces');
111
+ return (data && data.items) || [];
112
+ }
113
+
114
+ // group + множина для кожного виду KCC-ресурсу, який сканує
115
+ // get-resources.mjs. Версія скрізь v1beta1 — перевірено по всіх десяти
116
+ // CRD на живому кластері (kubectl get crd ... -o jsonpath спрацьовує), і
117
+ // це стандарт самого KCC, не наш вибір.
118
+ const CRD_GVR = {
119
+ IAMServiceAccount: { group: 'iam.cnrm.cloud.google.com', plural: 'iamserviceaccounts' },
120
+ IAMServiceAccountKey: { group: 'iam.cnrm.cloud.google.com', plural: 'iamserviceaccountkeys' },
121
+ ArtifactRegistryRepository: { group: 'artifactregistry.cnrm.cloud.google.com', plural: 'artifactregistryrepositories' },
122
+ ContainerCluster: { group: 'container.cnrm.cloud.google.com', plural: 'containerclusters' },
123
+ ContainerNodePool: { group: 'container.cnrm.cloud.google.com', plural: 'containernodepools' },
124
+ StorageBucket: { group: 'storage.cnrm.cloud.google.com', plural: 'storagebuckets' },
125
+ ComputeAddress: { group: 'compute.cnrm.cloud.google.com', plural: 'computeaddresses' },
126
+ DNSManagedZone: { group: 'dns.cnrm.cloud.google.com', plural: 'dnsmanagedzones' },
127
+ DNSRecordSet: { group: 'dns.cnrm.cloud.google.com', plural: 'dnsrecordsets' },
128
+ IAMPolicyMember: { group: 'iam.cnrm.cloud.google.com', plural: 'iampolicymembers' },
129
+ };
130
+
131
+ /**
132
+ * Усі CR даного KCC-виду в namespace. Порожній масив, якщо CRD не
133
+ * встановлений (404) — той самий сигнал, що раніше давав ненульовий
134
+ * exit-код kubectl, коли CRD немає в кластері. Інші помилки (401/403/
135
+ * мережа) кидаються далі.
136
+ */
137
+ export async function listCustomObjects(kind, namespace) {
138
+ const { group, plural } = CRD_GVR[kind];
139
+ try {
140
+ const data = await requestJson(`/apis/${group}/v1beta1/namespaces/${encodeURIComponent(namespace)}/${plural}`);
141
+ return (data && data.items) || [];
142
+ } catch (err) {
143
+ if (err.status === 404) return [];
144
+ throw err;
145
+ }
146
+ }
@@ -27,13 +27,17 @@ By default GCP-managed system noise is filtered out (Google-owned service
27
27
  accounts, gcr.io shims, GKE-managed node pools/DNS zones, legacy bucket
28
28
  ACL entries, ...) — pass --include-system to see it anyway.
29
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).
30
+ No \`gcloud\` or \`kubectl\` CLI needed both the GCP side (Cloud Asset
31
+ Inventory, IAM, Compute Engine) and the cluster side talk REST directly,
32
+ authenticated with Application Default Credentials (\`gcloud auth
33
+ application-default login\` locally, a service account key via
34
+ GOOGLE_APPLICATION_CREDENTIALS, or the ambient credentials on
35
+ GCE/GKE/Cloud Build). Cluster connection details (server, CA) still come
36
+ from your kubeconfig (KUBECONFIG, default ~/.kube/config); set
37
+ KUBE_CONTEXT to target a specific context explicitly instead of relying
38
+ on current-context. Only tested against GKE — the cluster-side REST calls
39
+ assume a GCP OAuth2 token is a valid bearer token for the API server,
40
+ which isn't true for every cluster type.
37
41
 
38
42
  Options:
39
43
  --json Emit a JSON array of {kind, id, project, status} instead
@@ -143,7 +147,7 @@ export async function run(argv) {
143
147
 
144
148
  let namespaces;
145
149
  if (target === '--all') {
146
- namespaces = listKccNamespaces();
150
+ namespaces = await listKccNamespaces();
147
151
  if (!namespaces.length) {
148
152
  console.error('жоден namespace не має cnrm.cloud.google.com/project-id');
149
153
  return 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cfr",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
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; `get-resources` is that same scan without the diff.",
5
5
  "keywords": [
6
6
  "kustomize",
@@ -39,6 +39,7 @@
39
39
  "test": "node --test test/cli.test.mjs"
40
40
  },
41
41
  "dependencies": {
42
- "google-auth-library": "^10.9.1"
42
+ "google-auth-library": "^10.9.1",
43
+ "yaml": "^2.9.0"
43
44
  }
44
45
  }