@nitra/cfr 0.3.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 +49 -6
- package/bin/cli.mjs +6 -3
- package/lib/gcp-rest.mjs +12 -0
- package/lib/{kcc-resources.mjs → get-resources.mjs} +154 -70
- package/lib/k8s-rest.mjs +146 -0
- package/lib/kcc-inventory.mjs +15 -11
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# @nitra/cfr
|
|
2
2
|
|
|
3
3
|
A handful of small k8s/GitOps CLI utilities, one `npx`/`bunx` away — no
|
|
4
|
-
install
|
|
4
|
+
install. Three commands so far:
|
|
5
5
|
|
|
6
6
|
- **`check`** (default) — verify a Kustomize directory's `resources:` list
|
|
7
7
|
matches what's actually on disk
|
|
8
8
|
- **`kcc-inventory`** — diff a GCP Config Connector namespace against the
|
|
9
9
|
live project to find drift
|
|
10
|
+
- **`get-resources`** — the raw resource list `kcc-inventory` diffs,
|
|
11
|
+
without the diff
|
|
10
12
|
|
|
11
13
|
## `check`
|
|
12
14
|
|
|
@@ -115,16 +117,24 @@ npx @nitra/cfr kcc-inventory <namespace|--all> --include-system # don't filter
|
|
|
115
117
|
чисто (11 live, 11 kcc)
|
|
116
118
|
```
|
|
117
119
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
Inventory, IAM, and Compute Engine directly over REST — no `gcloud` CLI
|
|
122
|
-
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
|
|
123
123
|
Credentials](https://cloud.google.com/docs/authentication/application-default-credentials)
|
|
124
124
|
(`gcloud auth application-default login` locally, a service account key
|
|
125
125
|
via `GOOGLE_APPLICATION_CREDENTIALS`, or the ambient credentials on
|
|
126
126
|
GCE/GKE/Cloud Build).
|
|
127
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
|
+
|
|
128
138
|
By default, GCP-managed system noise is filtered out — Google-owned
|
|
129
139
|
service accounts, `gcr.io` shims, GKE-managed node pools and DNS zones,
|
|
130
140
|
legacy bucket ACL entries. Pass `--include-system` to see it anyway.
|
|
@@ -146,6 +156,39 @@ already deleted in GCP. Both are cross-checked against a direct Compute
|
|
|
146
156
|
Engine call before being reported, and any stale entry found this way is
|
|
147
157
|
counted and noted separately — never silently folded into DRIFT.
|
|
148
158
|
|
|
159
|
+
## `get-resources`
|
|
160
|
+
|
|
161
|
+
`kcc-inventory` is a diff on top of a fact-finding step: for each KCC
|
|
162
|
+
namespace, list what's live in GCP and what's declared under KCC. That
|
|
163
|
+
step is `get-resources` — same scan, same filtering, no drift/orphan
|
|
164
|
+
comparison. Useful on its own for piping into `jq`, feeding a different
|
|
165
|
+
tool, or just seeing everything a namespace touches without wading
|
|
166
|
+
through a diff.
|
|
167
|
+
|
|
168
|
+
### Usage
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
npx @nitra/cfr get-resources <namespace>
|
|
172
|
+
npx @nitra/cfr get-resources --all
|
|
173
|
+
npx @nitra/cfr get-resources <namespace|--all> --json
|
|
174
|
+
npx @nitra/cfr get-resources <namespace|--all> --include-system
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
### namespace nitraai -> проєкт nitraai ###
|
|
179
|
+
== StorageBucket ==
|
|
180
|
+
gcp: 7n-forgejo-lfs
|
|
181
|
+
gcp: old-backups-bucket
|
|
182
|
+
kcc: 7n-forgejo-lfs
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`--json` emits `{resources: [{namespace, project, kind, id, source}, ...],
|
|
186
|
+
diagnostics: [...]}` — `source` is `"gcp"` or `"kcc"`, `diagnostics`
|
|
187
|
+
carries the same stale-cache/GKE-managed notes described above.
|
|
188
|
+
|
|
189
|
+
Same requirements as `kcc-inventory` — no `gcloud`/`kubectl` needed, GKE
|
|
190
|
+
only, `--include-system` to see GCP-managed noise.
|
|
191
|
+
|
|
149
192
|
## License
|
|
150
193
|
|
|
151
194
|
MIT
|
package/bin/cli.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { run as runCheck } from '../lib/check.mjs';
|
|
3
3
|
import { run as runKccInventory } from '../lib/kcc-inventory.mjs';
|
|
4
|
+
import { run as runGetResources } from '../lib/get-resources.mjs';
|
|
4
5
|
|
|
5
6
|
const TOP_HELP = `cfr (@nitra/cfr) — a handful of small k8s/GitOps CLI utilities
|
|
6
7
|
|
|
@@ -12,6 +13,7 @@ Commands:
|
|
|
12
13
|
check Verify kustomization.yaml resources: match the directory
|
|
13
14
|
(default when the first argument isn't a known command)
|
|
14
15
|
kcc-inventory GCP Config Connector (KCC) drift inventory
|
|
16
|
+
get-resources Raw KCC/GCP resource list behind kcc-inventory, no diff
|
|
15
17
|
|
|
16
18
|
Run "npx @nitra/cfr <command> --help" for command-specific help.
|
|
17
19
|
`;
|
|
@@ -19,11 +21,12 @@ Run "npx @nitra/cfr <command> --help" for command-specific help.
|
|
|
19
21
|
const COMMANDS = {
|
|
20
22
|
check: runCheck,
|
|
21
23
|
'kcc-inventory': runKccInventory,
|
|
24
|
+
'get-resources': runGetResources,
|
|
22
25
|
};
|
|
23
26
|
|
|
24
|
-
// check is synchronous (local filesystem only);
|
|
25
|
-
// (
|
|
26
|
-
// 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.
|
|
27
30
|
async function main(argv) {
|
|
28
31
|
if (argv[0] === '-h' || argv[0] === '--help') {
|
|
29
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 (
|
|
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,122 @@ 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
|
}
|
|
383
|
+
|
|
384
|
+
// --- CLI: `cfr get-resources` — collectNamespace() as its own subcommand,
|
|
385
|
+
// no diffing. What kcc-inventory consumes, exposed directly for anyone
|
|
386
|
+
// who wants the raw facts (piping into jq, feeding a different tool).
|
|
387
|
+
|
|
388
|
+
export const HELP = `cfr get-resources — raw KCC/GCP resource list, no diff
|
|
389
|
+
|
|
390
|
+
Usage:
|
|
391
|
+
npx @nitra/cfr get-resources <namespace>
|
|
392
|
+
npx @nitra/cfr get-resources --all
|
|
393
|
+
npx @nitra/cfr get-resources <namespace|--all> [--json] [--include-system]
|
|
394
|
+
|
|
395
|
+
The mechanism kcc-inventory is built on, callable on its own: per KCC
|
|
396
|
+
namespace (namespace carrying the annotation
|
|
397
|
+
cnrm.cloud.google.com/project-id), lists every resource found live in the
|
|
398
|
+
GCP project (source: gcp) and every one declared as a Config Connector
|
|
399
|
+
custom resource in that namespace (source: kcc), for IAMServiceAccount,
|
|
400
|
+
IAMServiceAccountKey, ArtifactRegistryRepository, ContainerCluster,
|
|
401
|
+
ContainerNodePool, StorageBucket, ComputeAddress, DNSManagedZone,
|
|
402
|
+
DNSRecordSet, and IAMPolicyMember. No drift/orphan diff — that's
|
|
403
|
+
\`cfr kcc-inventory\`.
|
|
404
|
+
|
|
405
|
+
By default GCP-managed system noise is filtered out (Google-owned service
|
|
406
|
+
accounts, gcr.io shims, GKE-managed node pools/DNS zones, legacy bucket
|
|
407
|
+
ACL entries, ...) — pass --include-system to see it anyway.
|
|
408
|
+
|
|
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.
|
|
415
|
+
|
|
416
|
+
Options:
|
|
417
|
+
--json Emit {resources, diagnostics} as JSON instead of the
|
|
418
|
+
human-readable listing.
|
|
419
|
+
--include-system Don't filter out GCP-managed system resources.
|
|
420
|
+
-h, --help Show this help and exit.
|
|
421
|
+
|
|
422
|
+
Exits 0 on a completed scan, 2 on a usage error.
|
|
423
|
+
`;
|
|
424
|
+
|
|
425
|
+
function printHuman(namespace, collected) {
|
|
426
|
+
console.log(`### namespace ${namespace} -> проєкт ${collected.project} ###`);
|
|
427
|
+
const byKind = new Map(KIND_ORDER.map((k) => [k, []]));
|
|
428
|
+
for (const r of collected.resources) byKind.get(r.kind)?.push(r);
|
|
429
|
+
for (const kind of KIND_ORDER) {
|
|
430
|
+
const entries = byKind.get(kind);
|
|
431
|
+
if (!entries.length) continue;
|
|
432
|
+
console.log(`== ${kind} ==`);
|
|
433
|
+
for (const { source, id } of [...entries].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
434
|
+
console.log(` ${source}: ${id}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (collected.diagnostics.length) {
|
|
438
|
+
console.log('== diagnostics ==');
|
|
439
|
+
for (const d of collected.diagnostics) {
|
|
440
|
+
if (d.type === 'stale-cache') console.log(` ${d.kind}: застарілий кеш Asset Inventory, ${d.count} запис(ів)`);
|
|
441
|
+
else if (d.type === 'gke-managed-skip') console.log(` ${d.kind}: керує сам GKE — ${d.zones.join(', ')}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
console.log('');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export async function run(argv) {
|
|
448
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
449
|
+
process.stdout.write(HELP);
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const target = argv[0];
|
|
454
|
+
if (!target) {
|
|
455
|
+
console.error('usage: cfr get-resources <namespace|--all> [--json] [--include-system] (KUBE_CONTEXT=... для явного контексту)');
|
|
456
|
+
return 2;
|
|
457
|
+
}
|
|
458
|
+
const jsonMode = argv.includes('--json');
|
|
459
|
+
const includeSystem = argv.includes('--include-system');
|
|
460
|
+
|
|
461
|
+
let namespaces;
|
|
462
|
+
if (target === '--all') {
|
|
463
|
+
namespaces = await listKccNamespaces();
|
|
464
|
+
if (!namespaces.length) {
|
|
465
|
+
console.error('жоден namespace не має cnrm.cloud.google.com/project-id');
|
|
466
|
+
return 2;
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
namespaces = [target];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const resources = [];
|
|
473
|
+
const diagnostics = [];
|
|
474
|
+
for (const namespace of namespaces) {
|
|
475
|
+
const collected = await collectNamespace(namespace, { includeSystem });
|
|
476
|
+
if (!collected.project) {
|
|
477
|
+
console.error(`namespace ${namespace} не має cnrm.cloud.google.com/project-id`);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (jsonMode) {
|
|
481
|
+
for (const r of collected.resources) resources.push({ namespace, project: collected.project, ...r });
|
|
482
|
+
for (const d of collected.diagnostics) diagnostics.push({ namespace, project: collected.project, ...d });
|
|
483
|
+
} else {
|
|
484
|
+
printHuman(namespace, collected);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (jsonMode) console.log(JSON.stringify({ resources, diagnostics }, null, 2));
|
|
489
|
+
return 0;
|
|
490
|
+
}
|
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
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Diff і звіт над сирим списком з
|
|
1
|
+
// Diff і звіт над сирим списком з get-resources.mjs: що з GCP-ресурсів
|
|
2
2
|
// проєкту реально живе під KCC (описано в git), а що — "чуже" (створено
|
|
3
3
|
// повз KCC). Суто читання, нічого не видаляє — вхідний контракт для
|
|
4
4
|
// окремого cleanup-рішення, яке вирішує, що з DRIFT можна прибрати.
|
|
5
|
-
import { collectNamespace, listKccNamespaces, KIND_ORDER } from './
|
|
5
|
+
import { collectNamespace, listKccNamespaces, KIND_ORDER } from './get-resources.mjs';
|
|
6
6
|
|
|
7
7
|
export const HELP = `cfr kcc-inventory — GCP Config Connector (KCC) drift inventory
|
|
8
8
|
|
|
@@ -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
|
|
@@ -93,7 +97,7 @@ function printDiagnostic(diag, jsonMode, results, project) {
|
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
// Groups the flat resources list from
|
|
100
|
+
// Groups the flat resources list from get-resources.mjs back into
|
|
97
101
|
// per-kind {live, kcc} arrays, diffs and prints each kind in KIND_ORDER,
|
|
98
102
|
// interleaving each kind's diagnostics (if any) right before its report —
|
|
99
103
|
// same order a human reading top to bottom expects.
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitra/cfr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A handful of small k8s/GitOps CLI utilities: `check` verifies every YAML file in a Kustomize directory is listed in kustomization.yaml's explicit resources: (and vice versa); `kcc-inventory` diffs a GCP Config Connector namespace against the live project to find drift.",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "A handful of small k8s/GitOps CLI utilities: `check` verifies every YAML file in a Kustomize directory is listed in kustomization.yaml's explicit resources: (and vice versa); `kcc-inventory` diffs a GCP Config Connector namespace against the live project to find drift; `get-resources` is that same scan without the diff.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kustomize",
|
|
7
7
|
"flux",
|
|
@@ -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
|
}
|