@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 +15 -8
- package/bin/cli.mjs +3 -3
- package/lib/gcp-rest.mjs +12 -0
- package/lib/get-resources.mjs +53 -76
- package/lib/k8s-rest.mjs +146 -0
- package/lib/kcc-inventory.mjs +12 -8
- package/package.json +3 -2
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
182
|
-
|
|
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);
|
|
28
|
-
// (
|
|
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 просто бачив би порожній
|
package/lib/get-resources.mjs
CHANGED
|
@@ -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 (
|
|
5
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
31
|
-
const
|
|
32
|
-
return
|
|
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 =
|
|
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
|
|
200
|
-
return
|
|
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',
|
|
239
|
-
'
|
|
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',
|
|
251
|
-
'
|
|
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',
|
|
265
|
-
'
|
|
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',
|
|
272
|
-
'
|
|
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',
|
|
285
|
-
'
|
|
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',
|
|
297
|
-
'
|
|
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',
|
|
321
|
-
'
|
|
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',
|
|
338
|
-
'
|
|
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',
|
|
374
|
-
'
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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;
|
package/lib/k8s-rest.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/kcc-inventory.mjs
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
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
|
}
|