@nitra/cfr 0.4.0 → 0.6.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
@@ -93,10 +93,16 @@ heard of, and never will until someone points it out.
93
93
 
94
94
  `kcc-inventory` is that someone. Per namespace (any namespace carrying the
95
95
  annotation `cnrm.cloud.google.com/project-id`), it compares what's live in
96
- the GCP project against what's declared under KCC, for
97
- `IAMServiceAccount`, `IAMServiceAccountKey`, `ArtifactRegistryRepository`,
98
- `ContainerCluster`, `ContainerNodePool`, `StorageBucket`, `ComputeAddress`,
99
- `DNSManagedZone`, `DNSRecordSet`, and `IAMPolicyMember`.
96
+ the GCP project against what's declared under KCC. Besides IAM, GKE,
97
+ Artifact Registry, buckets, addresses and Cloud DNS, it includes Cloud Run
98
+ (`RunService`, `RunJob`), `CloudSchedulerJob`, `EventarcTrigger`, Pub/Sub,
99
+ Secret Manager, VPC Access, KMS, and the Cloud Run HTTP(S) load-balancer
100
+ chain (network, subnetwork, backend service, serverless NEG, URL map,
101
+ target HTTPS proxy, global forwarding rule, and SSL certificates).
102
+
103
+ Location-scoped resources use the canonical `location/name` ID, preventing
104
+ resources with the same name in different regions from being merged. IAM
105
+ bindings on a `RunService` are normalized to the same identity.
100
106
 
101
107
  Read-only — it reports, it doesn't touch anything.
102
108
 
@@ -117,16 +123,24 @@ npx @nitra/cfr kcc-inventory <namespace|--all> --include-system # don't filter
117
123
  чисто (11 live, 11 kcc)
118
124
  ```
119
125
 
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
126
+ No `gcloud` or `kubectl` CLI needed on `PATH` the GCP side (Cloud Asset
127
+ Inventory, IAM, Compute Engine) and the cluster side both talk REST
128
+ directly, authenticated with [Application Default
125
129
  Credentials](https://cloud.google.com/docs/authentication/application-default-credentials)
126
130
  (`gcloud auth application-default login` locally, a service account key
127
131
  via `GOOGLE_APPLICATION_CREDENTIALS`, or the ambient credentials on
128
132
  GCE/GKE/Cloud Build).
129
133
 
134
+ Cluster connection details still come from your kubeconfig (`KUBECONFIG`,
135
+ default `~/.kube/config`) — that part isn't going anywhere, it's the only
136
+ place the cluster's API server address and CA certificate live. Set
137
+ `KUBE_CONTEXT` to target a specific context explicitly instead of relying
138
+ on `current-context`. The GCP access token is used directly as the
139
+ cluster bearer token (the same trick `gke-gcloud-auth-plugin` performs
140
+ under `kubectl`), so this only works against **GKE** clusters — a
141
+ kubeconfig using client certs, a static token, or a non-GCP exec plugin
142
+ (EKS, AKS, ...) won't authenticate.
143
+
130
144
  By default, GCP-managed system noise is filtered out — Google-owned
131
145
  service accounts, `gcr.io` shims, GKE-managed node pools and DNS zones,
132
146
  legacy bucket ACL entries. Pass `--include-system` to see it anyway.
@@ -178,9 +192,12 @@ npx @nitra/cfr get-resources <namespace|--all> --include-system
178
192
  diagnostics: [...]}` — `source` is `"gcp"` or `"kcc"`, `diagnostics`
179
193
  carries the same stale-cache/GKE-managed notes described above.
180
194
 
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.
195
+ Same requirements as `kcc-inventory` — no `gcloud`/`kubectl` needed, GKE
196
+ only, `--include-system` to see GCP-managed noise.
197
+
198
+ ## Changelog
199
+
200
+ See [CHANGELOG.md](https://github.com/nitra/cfr/blob/main/CHANGELOG.md).
184
201
 
185
202
  ## License
186
203
 
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
- // що заявлено як Config Connector CR у цьому namespace — для кожного з 10
2
+ // що заявлено як Config Connector CR у цьому namespace — для кожного з
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',
@@ -24,38 +24,29 @@ export const KIND_ORDER = [
24
24
  'ComputeAddress',
25
25
  'DNSManagedZone',
26
26
  'DNSRecordSet',
27
+ 'RunService',
28
+ 'RunJob',
29
+ 'CloudSchedulerJob',
30
+ 'EventarcTrigger',
31
+ 'PubSubTopic',
32
+ 'PubSubSubscription',
33
+ 'SecretManagerSecret',
34
+ 'VPCAccessConnector',
35
+ 'ComputeNetwork',
36
+ 'ComputeSubnetwork',
37
+ 'KMSCryptoKey',
38
+ 'ComputeBackendService',
39
+ 'ComputeNetworkEndpointGroup',
40
+ 'ComputeURLMap',
41
+ 'ComputeTargetHTTPSProxy',
42
+ 'ComputeGlobalForwardingRule',
43
+ 'ComputeSSLCertificate',
27
44
  'IAMPolicyMember',
28
45
  ];
29
46
 
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);
47
+ async function kccField(kind, namespace, fieldFn) {
48
+ const items = await listCustomObjects(kind, namespace);
49
+ return items.map(fieldFn).filter(Boolean);
59
50
  }
60
51
 
61
52
  const ASSET_API = 'https://cloudasset.googleapis.com/v1';
@@ -88,7 +79,7 @@ async function listRegionalAddressIds(project) {
88
79
  for (const [scopeKey, val] of Object.entries(data.items || {})) {
89
80
  if (!val.addresses) continue;
90
81
  const region = scopeKey.replace(/^regions\//, '');
91
- for (const a of val.addresses) out.push(`${a.name}/${region}`);
82
+ for (const a of val.addresses) out.push(`${region}/${a.name}`);
92
83
  }
93
84
  pageToken = data.nextPageToken;
94
85
  } while (pageToken);
@@ -97,13 +88,46 @@ async function listRegionalAddressIds(project) {
97
88
 
98
89
  async function listGlobalAddressIds(project) {
99
90
  const items = await paginate(`${COMPUTE_API}/projects/${project}/global/addresses`, { pageSize: 500 }, 'items');
100
- return items.map((a) => `${a.name}/global`);
91
+ return items.map((a) => `global/${a.name}`);
101
92
  }
102
93
 
103
94
  function stripPrefix(s, prefix) {
104
95
  return s.startsWith(prefix) ? s.slice(prefix.length) : s;
105
96
  }
106
97
 
98
+ // Один ID для всіх location/region/zone ресурсів: location/name. Це не дає
99
+ // однаковим іменам у різних регіонах зливатися в один inventory запис.
100
+ function assetScopedId(asset, collection) {
101
+ const m = (asset.name || '').match(new RegExp(`/(?:locations|regions|zones)/([^/]+)/${collection}/([^/]+)$`));
102
+ return m && `${m[1]}/${m[2]}`;
103
+ }
104
+
105
+ function kccScopedId(item, { defaultLocation, name = (i) => i.spec && i.spec.resourceID } = {}) {
106
+ const spec = item.spec || {};
107
+ const resourceName = name(item) || (item.metadata && item.metadata.name);
108
+ const location = spec.location || spec.region || spec.zone || defaultLocation;
109
+ return location && resourceName ? `${location}/${resourceName}` : null;
110
+ }
111
+
112
+ function resourceId(item) {
113
+ return (item.spec && item.spec.resourceID) || (item.metadata && item.metadata.name);
114
+ }
115
+
116
+ function liveScopedIds(assets, type, collection) {
117
+ return assets.filter((a) => a.assetType === type).map((a) => assetScopedId(a, collection)).filter(Boolean);
118
+ }
119
+
120
+ function assetGlobalOrScopedId(asset, collection) {
121
+ const scoped = assetScopedId(asset, collection);
122
+ if (scoped) return scoped;
123
+ const global = (asset.name || '').match(new RegExp(`/global/${collection}/([^/]+)$`));
124
+ return global && `global/${global[1]}`;
125
+ }
126
+
127
+ function liveGlobalOrScopedIds(assets, type, collection) {
128
+ return assets.filter((a) => a.assetType === type).map((a) => assetGlobalOrScopedId(a, collection)).filter(Boolean);
129
+ }
130
+
107
131
  // --- фільтри GCP-системного шуму (див. includeSystem) ----------------------
108
132
 
109
133
  const SA_SYSTEM = [
@@ -147,6 +171,8 @@ function assetResId(entry, project) {
147
171
  const mAr = res.match(/\/repositories\/([^/]+)$/);
148
172
  if (mAr && assetType === 'artifactregistry.googleapis.com/Repository') return `ar/${mAr[1]}`;
149
173
  if (assetType === 'storage.googleapis.com/Bucket') return 'bucket/' + stripPrefix(res, '//storage.googleapis.com/');
174
+ const mRunService = res.match(/\/projects\/[^/]+\/locations\/([^/]+)\/services\/([^/]+)$/);
175
+ if (mRunService && assetType === 'run.googleapis.com/Service') return `run-service/${mRunService[1]}/${mRunService[2]}`;
150
176
  return 'other/' + stripPrefix(res, '//');
151
177
  }
152
178
 
@@ -167,7 +193,7 @@ function kccSaId(ref, project) {
167
193
  return '?';
168
194
  }
169
195
 
170
- function kccPolicyMemberId(item, project) {
196
+ function kccPolicyMemberId(item, project, runServiceRefs) {
171
197
  const ref = (item.spec && item.spec.resourceRef) || {};
172
198
  switch (ref.kind) {
173
199
  case 'Project':
@@ -178,6 +204,12 @@ function kccPolicyMemberId(item, project) {
178
204
  return 'ar/' + kccArId(ref);
179
205
  case 'StorageBucket':
180
206
  return 'bucket/' + (ref.external || ref.name || '?');
207
+ case 'RunService': {
208
+ const external = ref.external || '';
209
+ const m = external.match(/projects\/[^/]+\/locations\/([^/]+)\/services\/([^/]+)$/);
210
+ if (m) return `run-service/${m[1]}/${m[2]}`;
211
+ return runServiceRefs.get(ref.name) || `run-service/?/${ref.name || '?'}`;
212
+ }
181
213
  default:
182
214
  return 'other/' + (ref.external || ref.name || '?');
183
215
  }
@@ -186,8 +218,8 @@ function kccPolicyMemberId(item, project) {
186
218
  /**
187
219
  * Проєкт GCP, прив'язаний до namespace, або null, якщо анотація відсутня.
188
220
  */
189
- export function projectForNamespace(namespace) {
190
- const ns = kubectlJson('get', 'namespace', namespace);
221
+ export async function projectForNamespace(namespace) {
222
+ const ns = await getNamespace(namespace);
191
223
  return (ns && ns.metadata && ns.metadata.annotations
192
224
  && ns.metadata.annotations['cnrm.cloud.google.com/project-id']) || null;
193
225
  }
@@ -195,9 +227,9 @@ export function projectForNamespace(namespace) {
195
227
  /**
196
228
  * Усі namespace з анотацією cnrm.cloud.google.com/project-id.
197
229
  */
198
- export function listKccNamespaces() {
199
- const data = kubectlJson('get', 'namespace');
200
- return ((data && data.items) || [])
230
+ export async function listKccNamespaces() {
231
+ const items = await listNamespaces();
232
+ return items
201
233
  .filter((i) => i.metadata && i.metadata.annotations && i.metadata.annotations['cnrm.cloud.google.com/project-id'])
202
234
  .map((i) => i.metadata.name);
203
235
  }
@@ -216,7 +248,7 @@ export function listKccNamespaces() {
216
248
  * (resources і diagnostics тоді порожні).
217
249
  */
218
250
  export async function collectNamespace(namespace, { includeSystem = false } = {}) {
219
- const project = projectForNamespace(namespace);
251
+ const project = await projectForNamespace(namespace);
220
252
  if (!project) return { namespace, project: null, resources: [], diagnostics: [] };
221
253
 
222
254
  const resources = [];
@@ -235,8 +267,8 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
235
267
  let liveSa = allLiveSa;
236
268
  if (!includeSystem) liveSa = liveSa.filter((e) => !isSystem(SA_SYSTEM, e));
237
269
  push('IAMServiceAccount', liveSa, 'gcp');
238
- push('IAMServiceAccount', kubectlField(
239
- 'iamserviceaccounts.iam.cnrm.cloud.google.com', namespace,
270
+ push('IAMServiceAccount', await kccField(
271
+ 'IAMServiceAccount', namespace,
240
272
  (i) => i.status && i.status.email,
241
273
  ), 'kcc');
242
274
 
@@ -247,45 +279,42 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
247
279
  // процесів gcloud, найповільніша частина всього скану.
248
280
  const liveKey = (await Promise.all(allLiveSa.map(listUserManagedKeyIds))).flat();
249
281
  push('IAMServiceAccountKey', liveKey, 'gcp');
250
- push('IAMServiceAccountKey', kubectlField(
251
- 'iamserviceaccountkeys.iam.cnrm.cloud.google.com', namespace,
282
+ push('IAMServiceAccountKey', (await kccField(
283
+ 'IAMServiceAccountKey', namespace,
252
284
  (i) => i.status && i.status.name,
253
- ).map((n) => n.split('/').pop()), 'kcc');
285
+ )).map((n) => n.split('/').pop()), 'kcc');
254
286
 
255
287
  // --- 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);
288
+ let liveAr = liveScopedIds(assets, 'artifactregistry.googleapis.com/Repository', 'repositories');
262
289
  if (!includeSystem) liveAr = liveAr.filter((r) => !isSystem(AR_SYSTEM, r));
263
290
  push('ArtifactRegistryRepository', liveAr, 'gcp');
264
- push('ArtifactRegistryRepository', kubectlField(
265
- 'artifactregistryrepositories.artifactregistry.cnrm.cloud.google.com', namespace,
266
- (i) => i.spec && i.spec.resourceID,
291
+ push('ArtifactRegistryRepository', await kccField(
292
+ 'ArtifactRegistryRepository', namespace,
293
+ (i) => kccScopedId(i),
267
294
  ), 'kcc');
268
295
 
269
296
  // --- 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');
297
+ push('ContainerCluster', liveScopedIds(assets, 'container.googleapis.com/Cluster', 'clusters'), 'gcp');
298
+ const containerClusterItems = await listCustomObjects('ContainerCluster', namespace);
299
+ push('ContainerCluster', containerClusterItems.map((i) => kccScopedId(i)).filter(Boolean), 'kcc');
300
+ const clusterRefs = new Map(containerClusterItems.map((i) => [i.metadata && i.metadata.name, kccScopedId(i)]));
275
301
 
276
302
  const livePool = [];
277
303
  for (const a of byType('container.googleapis.com/NodePool')) {
278
304
  const m = (a.name || '').match(/\/clusters\/([^/]+)\/nodePools\/([^/]+)$/);
279
305
  if (!m) continue;
280
306
  const [, cluster, pool] = m;
281
- if (includeSystem || !NODEPOOL_SYSTEM.test(pool)) livePool.push(`${cluster}/${pool}`);
307
+ const location = (a.name || '').match(/\/locations\/([^/]+)\/clusters\/[^/]+\/nodePools\/[^/]+$/)?.[1];
308
+ if (includeSystem || !NODEPOOL_SYSTEM.test(pool)) livePool.push(location ? `${location}/${cluster}/${pool}` : `${cluster}/${pool}`);
282
309
  }
283
310
  push('ContainerNodePool', livePool, 'gcp');
284
- push('ContainerNodePool', kubectlField(
285
- 'containernodepools.container.cnrm.cloud.google.com', namespace,
311
+ push('ContainerNodePool', await kccField(
312
+ 'ContainerNodePool', namespace,
286
313
  (i) => {
287
314
  const ref = (i.spec && i.spec.clusterRef) || {};
288
- return `${ref.name || ref.external}/${i.spec && i.spec.resourceID}`;
315
+ const external = ref.external || '';
316
+ const m = external.match(/\/locations\/([^/]+)\/clusters\/([^/]+)$/);
317
+ return m ? `${m[1]}/${m[2]}/${resourceId(i)}` : `${clusterRefs.get(ref.name) || ref.name || external}/${resourceId(i)}`;
289
318
  },
290
319
  ), 'kcc');
291
320
 
@@ -293,8 +322,8 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
293
322
  let liveBucket = byType('storage.googleapis.com/Bucket').map((a) => a.displayName);
294
323
  if (!includeSystem) liveBucket = liveBucket.filter((b) => !isSystemBucket(b, project));
295
324
  push('StorageBucket', liveBucket, 'gcp');
296
- push('StorageBucket', kubectlField(
297
- 'storagebuckets.storage.cnrm.cloud.google.com', namespace,
325
+ push('StorageBucket', await kccField(
326
+ 'StorageBucket', namespace,
298
327
  (i) => i.spec && i.spec.resourceID,
299
328
  ), 'kcc');
300
329
 
@@ -312,14 +341,14 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
312
341
  const liveAddrIds = new Set([...regionalAddrIds, ...globalAddrIds]);
313
342
  const staleAddrCount0 = byType('compute.googleapis.com/Address', 'compute.googleapis.com/GlobalAddress').length;
314
343
  const liveAddr = byType('compute.googleapis.com/Address', 'compute.googleapis.com/GlobalAddress')
315
- .map((a) => `${a.displayName}/${a.location === 'global' ? 'global' : a.location}`)
344
+ .map((a) => `${a.location === 'global' ? 'global' : a.location}/${a.displayName}`)
316
345
  .filter((id) => liveAddrIds.has(id));
317
346
  const staleAddrCount = staleAddrCount0 - liveAddr.length;
318
347
  if (staleAddrCount) diagnostics.push({ kind: 'ComputeAddress', type: 'stale-cache', count: staleAddrCount });
319
348
  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'}`,
349
+ push('ComputeAddress', await kccField(
350
+ 'ComputeAddress', namespace,
351
+ (i) => kccScopedId(i, { defaultLocation: 'global' }),
323
352
  ), 'kcc');
324
353
 
325
354
  // --- DNSManagedZone + DNSRecordSet -------------------------------------------
@@ -334,8 +363,8 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
334
363
  diagnostics.push({ kind: 'DNSManagedZone', type: 'gke-managed-skip', zones: [...gkeZoneNames].sort() });
335
364
  }
336
365
  push('DNSManagedZone', liveZone, 'gcp');
337
- push('DNSManagedZone', kubectlField(
338
- 'dnsmanagedzones.dns.cnrm.cloud.google.com', namespace,
366
+ push('DNSManagedZone', await kccField(
367
+ 'DNSManagedZone', namespace,
339
368
  (i) => i.spec && i.spec.resourceID,
340
369
  ), 'kcc');
341
370
 
@@ -370,14 +399,80 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
370
399
  }
371
400
  if (staleRrsetCount) diagnostics.push({ kind: 'DNSRecordSet', type: 'stale-cache', count: staleRrsetCount });
372
401
  push('DNSRecordSet', liveRrset, 'gcp');
373
- push('DNSRecordSet', kubectlField(
374
- 'dnsrecordsets.dns.cnrm.cloud.google.com', namespace,
402
+ push('DNSRecordSet', await kccField(
403
+ 'DNSRecordSet', namespace,
375
404
  (i) => {
376
405
  const zref = (i.spec && i.spec.managedZoneRef) || {};
377
406
  return `${zref.name}/${i.spec && i.spec.name}/${i.spec && i.spec.type}`;
378
407
  },
379
408
  ), 'kcc');
380
409
 
410
+ // --- Cloud Run та його тригери/залежності ---------------------------------
411
+ push('RunService', liveScopedIds(assets, 'run.googleapis.com/Service', 'services'), 'gcp');
412
+ const runServiceItems = await listCustomObjects('RunService', namespace);
413
+ push('RunService', runServiceItems.map((i) => kccScopedId(i)).filter(Boolean), 'kcc');
414
+
415
+ push('RunJob', liveScopedIds(assets, 'run.googleapis.com/Job', 'jobs'), 'gcp');
416
+ push('RunJob', await kccField('RunJob', namespace, (i) => kccScopedId(i)), 'kcc');
417
+
418
+ push('CloudSchedulerJob', liveScopedIds(assets, 'cloudscheduler.googleapis.com/Job', 'jobs'), 'gcp');
419
+ push('CloudSchedulerJob', await kccField('CloudSchedulerJob', namespace, (i) => kccScopedId(i)), 'kcc');
420
+
421
+ push('EventarcTrigger', liveScopedIds(assets, 'eventarc.googleapis.com/Trigger', 'triggers'), 'gcp');
422
+ push('EventarcTrigger', await kccField('EventarcTrigger', namespace, (i) => kccScopedId(i)), 'kcc');
423
+
424
+ push('PubSubTopic', byType('pubsub.googleapis.com/Topic').map((a) => a.displayName), 'gcp');
425
+ push('PubSubTopic', await kccField('PubSubTopic', namespace, resourceId), 'kcc');
426
+ push('PubSubSubscription', byType('pubsub.googleapis.com/Subscription').map((a) => a.displayName), 'gcp');
427
+ push('PubSubSubscription', await kccField('PubSubSubscription', namespace, resourceId), 'kcc');
428
+
429
+ push('SecretManagerSecret', byType('secretmanager.googleapis.com/Secret').map((a) => a.displayName), 'gcp');
430
+ push('SecretManagerSecret', await kccField('SecretManagerSecret', namespace, resourceId), 'kcc');
431
+
432
+ push('VPCAccessConnector', liveScopedIds(assets, 'vpcaccess.googleapis.com/Connector', 'connectors'), 'gcp');
433
+ push('VPCAccessConnector', await kccField('VPCAccessConnector', namespace, (i) => kccScopedId(i)), 'kcc');
434
+ push('ComputeNetwork', liveGlobalOrScopedIds(assets, 'compute.googleapis.com/Network', 'networks'), 'gcp');
435
+ push('ComputeNetwork', await kccField('ComputeNetwork', namespace, (i) => kccScopedId(i, { defaultLocation: 'global' })), 'kcc');
436
+ push('ComputeSubnetwork', liveScopedIds(assets, 'compute.googleapis.com/Subnetwork', 'subnetworks'), 'gcp');
437
+ push('ComputeSubnetwork', await kccField('ComputeSubnetwork', namespace, (i) => kccScopedId(i)), 'kcc');
438
+
439
+ // KMS key ідентифікується не лише ім'ям: keyRing теж є частиною URI.
440
+ const cryptoKeyId = (a) => {
441
+ const m = (a.name || '').match(/\/locations\/([^/]+)\/keyRings\/([^/]+)\/cryptoKeys\/([^/]+)$/);
442
+ return m && `${m[1]}/${m[2]}/${m[3]}`;
443
+ };
444
+ push('KMSCryptoKey', byType('cloudkms.googleapis.com/CryptoKey').map(cryptoKeyId).filter(Boolean), 'gcp');
445
+ push('KMSCryptoKey', await kccField('KMSCryptoKey', namespace, (i) => {
446
+ const ref = (i.spec && i.spec.keyRingRef) || {};
447
+ const m = (ref.external || '').match(/\/locations\/([^/]+)\/keyRings\/([^/]+)$/);
448
+ return m ? `${m[1]}/${m[2]}/${resourceId(i)}` : null;
449
+ }), 'kcc');
450
+
451
+ // --- HTTP(S) LB, що часто стоїть перед Cloud Run ---------------------------
452
+ push('ComputeBackendService', liveGlobalOrScopedIds(assets, 'compute.googleapis.com/BackendService', 'backendServices'), 'gcp');
453
+ push('ComputeBackendService', await kccField('ComputeBackendService', namespace, (i) => kccScopedId(i, { defaultLocation: 'global' })), 'kcc');
454
+ const isServerlessNeg = (a) => (a.additionalAttributes && a.additionalAttributes.networkEndpointType) === 'SERVERLESS'
455
+ || (a.resource && a.resource.data && a.resource.data.networkEndpointType) === 'SERVERLESS';
456
+ push('ComputeNetworkEndpointGroup', byType('compute.googleapis.com/NetworkEndpointGroup')
457
+ .filter(isServerlessNeg).map((a) => assetGlobalOrScopedId(a, 'networkEndpointGroups')).filter(Boolean), 'gcp');
458
+ push('ComputeNetworkEndpointGroup', await kccField('ComputeNetworkEndpointGroup', namespace, (i) => {
459
+ const type = i.spec && i.spec.networkEndpointType;
460
+ return type === 'SERVERLESS' ? kccScopedId(i, { defaultLocation: 'global' }) : null;
461
+ }), 'kcc');
462
+ push('ComputeURLMap', liveGlobalOrScopedIds(assets, 'compute.googleapis.com/UrlMap', 'urlMaps'), 'gcp');
463
+ push('ComputeURLMap', await kccField('ComputeURLMap', namespace, (i) => kccScopedId(i, { defaultLocation: 'global' })), 'kcc');
464
+ push('ComputeTargetHTTPSProxy', liveGlobalOrScopedIds(assets, 'compute.googleapis.com/TargetHttpsProxy', 'targetHttpsProxies'), 'gcp');
465
+ push('ComputeTargetHTTPSProxy', await kccField('ComputeTargetHTTPSProxy', namespace, (i) => kccScopedId(i, { defaultLocation: 'global' })), 'kcc');
466
+ push('ComputeGlobalForwardingRule', byType('compute.googleapis.com/ForwardingRule')
467
+ .map((a) => assetGlobalOrScopedId(a, 'forwardingRules')).filter((id) => id && id.startsWith('global/')), 'gcp');
468
+ push('ComputeGlobalForwardingRule', await kccField('ComputeGlobalForwardingRule', namespace, (i) => kccScopedId(i, { defaultLocation: 'global' })), 'kcc');
469
+ push('ComputeSSLCertificate', liveGlobalOrScopedIds(assets, 'compute.googleapis.com/SslCertificate', 'sslCertificates'), 'gcp');
470
+ const sslKcc = await Promise.all([
471
+ kccField('ComputeSSLCertificate', namespace, (i) => kccScopedId(i, { defaultLocation: 'global' })),
472
+ kccField('ComputeManagedSSLCertificate', namespace, (i) => kccScopedId(i, { defaultLocation: 'global' })),
473
+ ]);
474
+ push('ComputeSSLCertificate', sslKcc.flat(), 'kcc');
475
+
381
476
  // --- IAMPolicyMember ---------------------------------------------------------
382
477
  // search-all-iam-policies — усі біндинги проєкту одразу, на будь-якому
383
478
  // типі ресурсу, не тільки на трьох раніше підтримуваних (Project/SA/AR).
@@ -395,12 +490,15 @@ export async function collectNamespace(namespace, { includeSystem = false } = {}
395
490
  }
396
491
  push('IAMPolicyMember', liveIam, 'gcp');
397
492
 
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');
493
+ const runServiceRefs = new Map(runServiceItems.map((i) => [i.metadata && i.metadata.name, kccScopedId(i)]));
494
+ push('IAMPolicyMember', await kccField(
495
+ 'IAMPolicyMember', namespace,
496
+ (item) => {
497
+ const rid = kccPolicyMemberId(item, project, runServiceRefs);
498
+ const spec = item.spec || {};
499
+ return `${rid}/${spec.role}/${spec.member}`;
500
+ },
501
+ ), 'kcc');
404
502
 
405
503
  return { namespace, project, resources, diagnostics };
406
504
  }
@@ -420,21 +518,22 @@ The mechanism kcc-inventory is built on, callable on its own: per KCC
420
518
  namespace (namespace carrying the annotation
421
519
  cnrm.cloud.google.com/project-id), lists every resource found live in the
422
520
  GCP project (source: gcp) and every one declared as a Config Connector
423
- custom resource in that namespace (source: kcc), for IAMServiceAccount,
424
- IAMServiceAccountKey, ArtifactRegistryRepository, ContainerCluster,
425
- ContainerNodePool, StorageBucket, ComputeAddress, DNSManagedZone,
426
- DNSRecordSet, and IAMPolicyMember. No drift/orphan diff — that's
521
+ custom resource in that namespace. It covers IAM, Artifact Registry, GKE,
522
+ Cloud Run (services and jobs), Scheduler, Eventarc, Pub/Sub, Secret Manager,
523
+ VPC Access, selected network/LB resources, KMS, Cloud DNS, and
524
+ IAMPolicyMember. No drift/orphan diff — that's
427
525
  \`cfr kcc-inventory\`.
428
526
 
429
527
  By default GCP-managed system noise is filtered out (Google-owned service
430
528
  accounts, gcr.io shims, GKE-managed node pools/DNS zones, legacy bucket
431
529
  ACL entries, ...) — pass --include-system to see it anyway.
432
530
 
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.
531
+ No \`gcloud\` or \`kubectl\` CLI needed both the GCP side and the cluster
532
+ side talk REST directly, authenticated with Application Default
533
+ Credentials. Cluster connection details (server, CA) still come from your
534
+ kubeconfig (KUBECONFIG, default ~/.kube/config); set KUBE_CONTEXT to
535
+ target a specific context explicitly instead of relying on
536
+ current-context. Only tested against GKE.
438
537
 
439
538
  Options:
440
539
  --json Emit {resources, diagnostics} as JSON instead of the
@@ -483,7 +582,7 @@ export async function run(argv) {
483
582
 
484
583
  let namespaces;
485
584
  if (target === '--all') {
486
- namespaces = listKccNamespaces();
585
+ namespaces = await listKccNamespaces();
487
586
  if (!namespaces.length) {
488
587
  console.error('жоден namespace не має cnrm.cloud.google.com/project-id');
489
588
  return 2;
@@ -0,0 +1,162 @@
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 — стандарт KCC, не наш вибір.
116
+ export const CRD_GVR = {
117
+ IAMServiceAccount: { group: 'iam.cnrm.cloud.google.com', plural: 'iamserviceaccounts' },
118
+ IAMServiceAccountKey: { group: 'iam.cnrm.cloud.google.com', plural: 'iamserviceaccountkeys' },
119
+ ArtifactRegistryRepository: { group: 'artifactregistry.cnrm.cloud.google.com', plural: 'artifactregistryrepositories' },
120
+ ContainerCluster: { group: 'container.cnrm.cloud.google.com', plural: 'containerclusters' },
121
+ ContainerNodePool: { group: 'container.cnrm.cloud.google.com', plural: 'containernodepools' },
122
+ StorageBucket: { group: 'storage.cnrm.cloud.google.com', plural: 'storagebuckets' },
123
+ ComputeAddress: { group: 'compute.cnrm.cloud.google.com', plural: 'computeaddresses' },
124
+ DNSManagedZone: { group: 'dns.cnrm.cloud.google.com', plural: 'dnsmanagedzones' },
125
+ DNSRecordSet: { group: 'dns.cnrm.cloud.google.com', plural: 'dnsrecordsets' },
126
+ RunService: { group: 'run.cnrm.cloud.google.com', plural: 'runservices' },
127
+ RunJob: { group: 'run.cnrm.cloud.google.com', plural: 'runjobs' },
128
+ CloudSchedulerJob: { group: 'cloudscheduler.cnrm.cloud.google.com', plural: 'cloudschedulerjobs' },
129
+ EventarcTrigger: { group: 'eventarc.cnrm.cloud.google.com', plural: 'eventarctriggers' },
130
+ PubSubTopic: { group: 'pubsub.cnrm.cloud.google.com', plural: 'pubsubtopics' },
131
+ PubSubSubscription: { group: 'pubsub.cnrm.cloud.google.com', plural: 'pubsubsubscriptions' },
132
+ SecretManagerSecret: { group: 'secretmanager.cnrm.cloud.google.com', plural: 'secretmanagersecrets' },
133
+ VPCAccessConnector: { group: 'vpcaccess.cnrm.cloud.google.com', plural: 'vpcaccessconnectors' },
134
+ ComputeNetwork: { group: 'compute.cnrm.cloud.google.com', plural: 'computenetworks' },
135
+ ComputeSubnetwork: { group: 'compute.cnrm.cloud.google.com', plural: 'computesubnetworks' },
136
+ KMSCryptoKey: { group: 'kms.cnrm.cloud.google.com', plural: 'kmscryptokeys' },
137
+ ComputeBackendService: { group: 'compute.cnrm.cloud.google.com', plural: 'computebackendservices' },
138
+ ComputeNetworkEndpointGroup: { group: 'compute.cnrm.cloud.google.com', plural: 'computenetworkendpointgroups' },
139
+ ComputeURLMap: { group: 'compute.cnrm.cloud.google.com', plural: 'computeurlmaps' },
140
+ ComputeTargetHTTPSProxy: { group: 'compute.cnrm.cloud.google.com', plural: 'computetargethttpsproxies' },
141
+ ComputeGlobalForwardingRule: { group: 'compute.cnrm.cloud.google.com', plural: 'computeglobalforwardingrules' },
142
+ ComputeSSLCertificate: { group: 'compute.cnrm.cloud.google.com', plural: 'computesslcertificates' },
143
+ ComputeManagedSSLCertificate: { group: 'compute.cnrm.cloud.google.com', plural: 'computemanagedsslcertificates' },
144
+ IAMPolicyMember: { group: 'iam.cnrm.cloud.google.com', plural: 'iampolicymembers' },
145
+ };
146
+
147
+ /**
148
+ * Усі CR даного KCC-виду в namespace. Порожній масив, якщо CRD не
149
+ * встановлений (404) — той самий сигнал, що раніше давав ненульовий
150
+ * exit-код kubectl, коли CRD немає в кластері. Інші помилки (401/403/
151
+ * мережа) кидаються далі.
152
+ */
153
+ export async function listCustomObjects(kind, namespace) {
154
+ const { group, plural } = CRD_GVR[kind];
155
+ try {
156
+ const data = await requestJson(`/apis/${group}/v1beta1/namespaces/${encodeURIComponent(namespace)}/${plural}`);
157
+ return (data && data.items) || [];
158
+ } catch (err) {
159
+ if (err.status === 404) return [];
160
+ throw err;
161
+ }
162
+ }
@@ -14,10 +14,9 @@ Usage:
14
14
  Compares, per KCC namespace (namespace carrying the annotation
15
15
  cnrm.cloud.google.com/project-id), what actually exists in the GCP project
16
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.
17
+ namespace. It covers IAM, Artifact Registry, GKE, Cloud Run, Scheduler,
18
+ Eventarc, Pub/Sub, Secret Manager, VPC Access, selected network/LB
19
+ resources, KMS, Cloud DNS, and IAMPolicyMember.
21
20
 
22
21
  Reports two directions per kind:
23
22
  DRIFT — live in GCP, not declared under KCC (adopt it or delete it)
@@ -27,13 +26,17 @@ By default GCP-managed system noise is filtered out (Google-owned service
27
26
  accounts, gcr.io shims, GKE-managed node pools/DNS zones, legacy bucket
28
27
  ACL entries, ...) — pass --include-system to see it anyway.
29
28
 
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).
29
+ No \`gcloud\` or \`kubectl\` CLI needed both the GCP side (Cloud Asset
30
+ Inventory, IAM, Compute Engine) and the cluster side talk REST directly,
31
+ authenticated with Application Default Credentials (\`gcloud auth
32
+ application-default login\` locally, a service account key via
33
+ GOOGLE_APPLICATION_CREDENTIALS, or the ambient credentials on
34
+ GCE/GKE/Cloud Build). Cluster connection details (server, CA) still come
35
+ from your kubeconfig (KUBECONFIG, default ~/.kube/config); set
36
+ KUBE_CONTEXT to target a specific context explicitly instead of relying
37
+ on current-context. Only tested against GKE — the cluster-side REST calls
38
+ assume a GCP OAuth2 token is a valid bearer token for the API server,
39
+ which isn't true for every cluster type.
37
40
 
38
41
  Options:
39
42
  --json Emit a JSON array of {kind, id, project, status} instead
@@ -143,7 +146,7 @@ export async function run(argv) {
143
146
 
144
147
  let namespaces;
145
148
  if (target === '--all') {
146
- namespaces = listKccNamespaces();
149
+ namespaces = await listKccNamespaces();
147
150
  if (!namespaces.length) {
148
151
  console.error('жоден namespace не має cnrm.cloud.google.com/project-id');
149
152
  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.6.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",
@@ -36,9 +36,10 @@
36
36
  "access": "public"
37
37
  },
38
38
  "scripts": {
39
- "test": "node --test test/cli.test.mjs"
39
+ "test": "node --test test/*.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
  }