@nitra/cfr 0.2.0 → 0.4.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 +53 -11
- package/bin/cli.mjs +14 -2
- package/lib/gcp-rest.mjs +43 -0
- package/lib/get-resources.mjs +513 -0
- package/lib/kcc-inventory.mjs +54 -351
- package/package.json +5 -2
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,9 +117,15 @@ npx @nitra/cfr kcc-inventory <namespace|--all> --include-system # don't filter
|
|
|
115
117
|
чисто (11 live, 11 kcc)
|
|
116
118
|
```
|
|
117
119
|
|
|
118
|
-
Requires `
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
125
|
+
Credentials](https://cloud.google.com/docs/authentication/application-default-credentials)
|
|
126
|
+
(`gcloud auth application-default login` locally, a service account key
|
|
127
|
+
via `GOOGLE_APPLICATION_CREDENTIALS`, or the ambient credentials on
|
|
128
|
+
GCE/GKE/Cloud Build).
|
|
121
129
|
|
|
122
130
|
By default, GCP-managed system noise is filtered out — Google-owned
|
|
123
131
|
service accounts, `gcr.io` shims, GKE-managed node pools and DNS zones,
|
|
@@ -132,13 +140,47 @@ legacy bucket ACL entries. Pass `--include-system` to see it anyway.
|
|
|
132
140
|
|
|
133
141
|
### A known Cloud Asset Inventory quirk
|
|
134
142
|
|
|
135
|
-
`kcc-inventory`
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
`kcc-inventory` calls `searchAllResources`/`searchAllIamPolicies` on the
|
|
144
|
+
Cloud Asset API — one or two paginated calls per project instead of a
|
|
145
|
+
list call per resource kind. That index can lag: it has been observed
|
|
146
|
+
returning `DNSRecordSet` and `ComputeAddress` entries for resources
|
|
147
|
+
already deleted in GCP. Both are cross-checked against a direct Compute
|
|
148
|
+
Engine call before being reported, and any stale entry found this way is
|
|
149
|
+
counted and noted separately — never silently folded into DRIFT.
|
|
150
|
+
|
|
151
|
+
## `get-resources`
|
|
152
|
+
|
|
153
|
+
`kcc-inventory` is a diff on top of a fact-finding step: for each KCC
|
|
154
|
+
namespace, list what's live in GCP and what's declared under KCC. That
|
|
155
|
+
step is `get-resources` — same scan, same filtering, no drift/orphan
|
|
156
|
+
comparison. Useful on its own for piping into `jq`, feeding a different
|
|
157
|
+
tool, or just seeing everything a namespace touches without wading
|
|
158
|
+
through a diff.
|
|
159
|
+
|
|
160
|
+
### Usage
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
npx @nitra/cfr get-resources <namespace>
|
|
164
|
+
npx @nitra/cfr get-resources --all
|
|
165
|
+
npx @nitra/cfr get-resources <namespace|--all> --json
|
|
166
|
+
npx @nitra/cfr get-resources <namespace|--all> --include-system
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
### namespace nitraai -> проєкт nitraai ###
|
|
171
|
+
== StorageBucket ==
|
|
172
|
+
gcp: 7n-forgejo-lfs
|
|
173
|
+
gcp: old-backups-bucket
|
|
174
|
+
kcc: 7n-forgejo-lfs
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`--json` emits `{resources: [{namespace, project, kind, id, source}, ...],
|
|
178
|
+
diagnostics: [...]}` — `source` is `"gcp"` or `"kcc"`, `diagnostics`
|
|
179
|
+
carries the same stale-cache/GKE-managed notes described above.
|
|
180
|
+
|
|
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.
|
|
142
184
|
|
|
143
185
|
## License
|
|
144
186
|
|
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,9 +21,13 @@ 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
|
-
|
|
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.
|
|
30
|
+
async function main(argv) {
|
|
25
31
|
if (argv[0] === '-h' || argv[0] === '--help') {
|
|
26
32
|
process.stdout.write(TOP_HELP);
|
|
27
33
|
return 0;
|
|
@@ -36,4 +42,10 @@ function main(argv) {
|
|
|
36
42
|
return command(rest);
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
|
|
45
|
+
main(process.argv.slice(2)).then(
|
|
46
|
+
(code) => process.exit(code),
|
|
47
|
+
(err) => {
|
|
48
|
+
console.error(`✗ ${err.message || err}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
},
|
|
51
|
+
);
|
package/lib/gcp-rest.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Тонкий REST-транспорт до Google Cloud API — заміна шелл-аутів у gcloud
|
|
2
|
+
// CLI. Автентифікація через Application Default Credentials (той самий
|
|
3
|
+
// механізм, що й gcloud: `gcloud auth application-default login` локально,
|
|
4
|
+
// service account key через GOOGLE_APPLICATION_CREDENTIALS, метадата-сервер
|
|
5
|
+
// на GCE/GKE) — жодного окремого налаштування не додає, лише прибирає
|
|
6
|
+
// залежність від встановленого `gcloud` на PATH і повільний старт
|
|
7
|
+
// Python-процесу на кожен виклик.
|
|
8
|
+
import { GoogleAuth } from 'google-auth-library';
|
|
9
|
+
|
|
10
|
+
let authClientPromise;
|
|
11
|
+
function client() {
|
|
12
|
+
if (!authClientPromise) {
|
|
13
|
+
const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'] });
|
|
14
|
+
authClientPromise = auth.getClient();
|
|
15
|
+
}
|
|
16
|
+
return authClientPromise;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* GET-запит, повертає розпарсений JSON. Помилку не ковтає мовчки — на
|
|
21
|
+
* відміну від gcloud-версії (де collectNamespace просто бачив би порожній
|
|
22
|
+
* результат), кидає далі, щоб причина (401/403/мережа) була видна.
|
|
23
|
+
*/
|
|
24
|
+
export async function getJson(url, params) {
|
|
25
|
+
const c = await client();
|
|
26
|
+
const res = await c.request({ url, params });
|
|
27
|
+
return res.data;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Пагінація за nextPageToken/pageToken — спільна для Asset Inventory
|
|
32
|
+
* (results[]) і Compute globalAddresses (items[]).
|
|
33
|
+
*/
|
|
34
|
+
export async function paginate(url, params, itemsKey) {
|
|
35
|
+
const out = [];
|
|
36
|
+
let pageToken;
|
|
37
|
+
do {
|
|
38
|
+
const data = await getJson(url, { ...params, pageToken });
|
|
39
|
+
out.push(...(data[itemsKey] || []));
|
|
40
|
+
pageToken = data.nextPageToken;
|
|
41
|
+
} while (pageToken);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
// Сирий збір фактів для одного KCC namespace: що є "живого" в GCP-проєкті і
|
|
2
|
+
// що заявлено як Config Connector CR у цьому namespace — для кожного з 10
|
|
3
|
+
// видів ресурсів. Жодного diff-у, жодного форматування, жодного I/O в
|
|
4
|
+
// консоль: тільки звернення до GCP REST API (через gcp-rest.mjs) і
|
|
5
|
+
// kubectl, і нормалізація результату в плаский список. Diff і звіт —
|
|
6
|
+
// робота kcc-inventory.mjs, який споживає цей список.
|
|
7
|
+
//
|
|
8
|
+
// GCP-бік іде напряму через REST (Application Default Credentials), не
|
|
9
|
+
// через `gcloud` CLI: без залежності від встановленого на PATH gcloud, без
|
|
10
|
+
// повільного старту Python-процесу на кожен виклик (сам по собі
|
|
11
|
+
// перевірений внесок у затримку — один search-all-resources на проєкті з
|
|
12
|
+
// сотнями активів під gcloud займав ~100с), і з реальною помилкою замість
|
|
13
|
+
// мовчазного порожнього результату при збої автентифікації.
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { getJson, paginate } from './gcp-rest.mjs';
|
|
16
|
+
|
|
17
|
+
export const KIND_ORDER = [
|
|
18
|
+
'IAMServiceAccount',
|
|
19
|
+
'IAMServiceAccountKey',
|
|
20
|
+
'ArtifactRegistryRepository',
|
|
21
|
+
'ContainerCluster',
|
|
22
|
+
'ContainerNodePool',
|
|
23
|
+
'StorageBucket',
|
|
24
|
+
'ComputeAddress',
|
|
25
|
+
'DNSManagedZone',
|
|
26
|
+
'DNSRecordSet',
|
|
27
|
+
'IAMPolicyMember',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function kubectlBase() {
|
|
31
|
+
const ctx = process.env.KUBE_CONTEXT;
|
|
32
|
+
return ctx ? ['kubectl', '--context', ctx] : ['kubectl'];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// maxBuffer: дефолтні 1MB замалі для kubectl get на namespace з великою
|
|
36
|
+
// кількістю CR — spawnSync мовчки падає в ENOBUFS, і виклик, що його не
|
|
37
|
+
// перевіряє (як тут), бачить порожній результат замість помилки. Той самий
|
|
38
|
+
// клас бага, що раніше ловився на gcloud-виклику search-all-resources,
|
|
39
|
+
// поки той не переїхав на пагінований REST (див. gcp-rest.mjs).
|
|
40
|
+
function runCmd(cmd, args) {
|
|
41
|
+
return spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 100 * 1024 * 1024 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function kubectlJson(...args) {
|
|
45
|
+
const [bin, ...base] = kubectlBase();
|
|
46
|
+
const proc = runCmd(bin, [...base, ...args, '-o', 'json']);
|
|
47
|
+
if (proc.status !== 0 || !proc.stdout || !proc.stdout.trim()) return null;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(proc.stdout);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function kubectlField(crd, namespace, fieldFn) {
|
|
56
|
+
const data = kubectlJson('get', crd, '-n', namespace);
|
|
57
|
+
if (!data) return [];
|
|
58
|
+
return (data.items || []).map(fieldFn).filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ASSET_API = 'https://cloudasset.googleapis.com/v1';
|
|
62
|
+
const IAM_API = 'https://iam.googleapis.com/v1';
|
|
63
|
+
const COMPUTE_API = 'https://compute.googleapis.com/compute/v1';
|
|
64
|
+
|
|
65
|
+
function searchAllResources(project) {
|
|
66
|
+
return paginate(`${ASSET_API}/projects/${project}:searchAllResources`, { pageSize: 500 }, 'results');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function searchAllIamPolicies(project) {
|
|
70
|
+
return paginate(`${ASSET_API}/projects/${project}:searchAllIamPolicies`, { pageSize: 500 }, 'results');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// projects/-/serviceAccounts/{email} — валідний шлях в IAM API, project
|
|
74
|
+
// беремо з листа `-`: не треба окремо передавати project поруч з email.
|
|
75
|
+
async function listUserManagedKeyIds(email) {
|
|
76
|
+
const data = await getJson(`${IAM_API}/projects/-/serviceAccounts/${email}/keys`, { keyTypes: 'USER_MANAGED' });
|
|
77
|
+
return (data.keys || []).map((k) => k.name.split('/').pop());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// aggregatedList: один виклик на всі регіони одразу, а не по одному на
|
|
81
|
+
// регіон. items — мапа "regions/{region}" -> {addresses: [...]}, не
|
|
82
|
+
// плаский масив під nextPageToken, тому власна пагінація, не paginate().
|
|
83
|
+
async function listRegionalAddressIds(project) {
|
|
84
|
+
const out = [];
|
|
85
|
+
let pageToken;
|
|
86
|
+
do {
|
|
87
|
+
const data = await getJson(`${COMPUTE_API}/projects/${project}/aggregated/addresses`, { pageSize: 500, pageToken });
|
|
88
|
+
for (const [scopeKey, val] of Object.entries(data.items || {})) {
|
|
89
|
+
if (!val.addresses) continue;
|
|
90
|
+
const region = scopeKey.replace(/^regions\//, '');
|
|
91
|
+
for (const a of val.addresses) out.push(`${a.name}/${region}`);
|
|
92
|
+
}
|
|
93
|
+
pageToken = data.nextPageToken;
|
|
94
|
+
} while (pageToken);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function listGlobalAddressIds(project) {
|
|
99
|
+
const items = await paginate(`${COMPUTE_API}/projects/${project}/global/addresses`, { pageSize: 500 }, 'items');
|
|
100
|
+
return items.map((a) => `${a.name}/global`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stripPrefix(s, prefix) {
|
|
104
|
+
return s.startsWith(prefix) ? s.slice(prefix.length) : s;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- фільтри GCP-системного шуму (див. includeSystem) ----------------------
|
|
108
|
+
|
|
109
|
+
const SA_SYSTEM = [
|
|
110
|
+
/^[0-9]+-compute@developer\.gserviceaccount\.com$/,
|
|
111
|
+
/@appspot\.gserviceaccount\.com$/,
|
|
112
|
+
/@cloudservices\.gserviceaccount\.com$/,
|
|
113
|
+
/^firebase-adminsdk-/,
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const AR_SYSTEM = [/(^|\.)gcr\.io$/, /^gcf-artifacts$/];
|
|
117
|
+
|
|
118
|
+
const NODEPOOL_SYSTEM = /^nap-/;
|
|
119
|
+
|
|
120
|
+
function isSystemBucket(name, project) {
|
|
121
|
+
return name.endsWith('.appspot.com') || name.startsWith('gcf-sources-') || name === `${project}_cloudbuild`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// projectEditor:/projectOwner:/projectViewer: — legacy bucket ACL, GCP
|
|
125
|
+
// навішує на кожен бакет сам; решта — Google-керовані service agents.
|
|
126
|
+
const IAM_SYSTEM = [
|
|
127
|
+
/^project(Editor|Owner|Viewer):/,
|
|
128
|
+
/@system\.gserviceaccount\.com$/,
|
|
129
|
+
/serviceAccount:service-[0-9]+@/,
|
|
130
|
+
/@gcp-sa-[a-z0-9-]+\.iam\.gserviceaccount\.com$/,
|
|
131
|
+
/@(cloudbuild|cloudservices|developer|appspot)\.gserviceaccount\.com$/,
|
|
132
|
+
/@(container-engine-robot|serverless-robot-prod|gcf-admin-robot|firebase-rules|firebase-sa-management)\.iam\.gserviceaccount\.com$/,
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
function isSystem(patterns, value) {
|
|
136
|
+
return patterns.some((p) => p.test(value));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- IAMPolicyMember: розбір id ресурсу з обох боків -----------------------
|
|
140
|
+
|
|
141
|
+
function assetResId(entry, project) {
|
|
142
|
+
const assetType = entry.assetType || '';
|
|
143
|
+
const res = entry.resource || '';
|
|
144
|
+
if (res.endsWith(`/projects/${project}`)) return `project/${project}`;
|
|
145
|
+
const mSa = res.match(/\/serviceAccounts\/([^/]+)$/);
|
|
146
|
+
if (mSa && assetType === 'iam.googleapis.com/ServiceAccount') return `sa/${mSa[1]}`;
|
|
147
|
+
const mAr = res.match(/\/repositories\/([^/]+)$/);
|
|
148
|
+
if (mAr && assetType === 'artifactregistry.googleapis.com/Repository') return `ar/${mAr[1]}`;
|
|
149
|
+
if (assetType === 'storage.googleapis.com/Bucket') return 'bucket/' + stripPrefix(res, '//storage.googleapis.com/');
|
|
150
|
+
return 'other/' + stripPrefix(res, '//');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function kccArId(ref) {
|
|
154
|
+
const ext = ref.external || '';
|
|
155
|
+
const m = ext.match(/\/repositories\/([^/]+)$/);
|
|
156
|
+
if (m) return m[1];
|
|
157
|
+
if (ref.name) return ref.name;
|
|
158
|
+
return '?';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function kccSaId(ref, project) {
|
|
162
|
+
const ext = ref.external || '';
|
|
163
|
+
const m = ext.match(/\/serviceAccounts\/([^/]+)$/);
|
|
164
|
+
if (m) return m[1];
|
|
165
|
+
if (ext) return ext;
|
|
166
|
+
if (ref.name) return `${ref.name}@${project}.iam.gserviceaccount.com`;
|
|
167
|
+
return '?';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function kccPolicyMemberId(item, project) {
|
|
171
|
+
const ref = (item.spec && item.spec.resourceRef) || {};
|
|
172
|
+
switch (ref.kind) {
|
|
173
|
+
case 'Project':
|
|
174
|
+
return `project/${project}`;
|
|
175
|
+
case 'IAMServiceAccount':
|
|
176
|
+
return 'sa/' + kccSaId(ref, project);
|
|
177
|
+
case 'ArtifactRegistryRepository':
|
|
178
|
+
return 'ar/' + kccArId(ref);
|
|
179
|
+
case 'StorageBucket':
|
|
180
|
+
return 'bucket/' + (ref.external || ref.name || '?');
|
|
181
|
+
default:
|
|
182
|
+
return 'other/' + (ref.external || ref.name || '?');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Проєкт GCP, прив'язаний до namespace, або null, якщо анотація відсутня.
|
|
188
|
+
*/
|
|
189
|
+
export function projectForNamespace(namespace) {
|
|
190
|
+
const ns = kubectlJson('get', 'namespace', namespace);
|
|
191
|
+
return (ns && ns.metadata && ns.metadata.annotations
|
|
192
|
+
&& ns.metadata.annotations['cnrm.cloud.google.com/project-id']) || null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Усі namespace з анотацією cnrm.cloud.google.com/project-id.
|
|
197
|
+
*/
|
|
198
|
+
export function listKccNamespaces() {
|
|
199
|
+
const data = kubectlJson('get', 'namespace');
|
|
200
|
+
return ((data && data.items) || [])
|
|
201
|
+
.filter((i) => i.metadata && i.metadata.annotations && i.metadata.annotations['cnrm.cloud.google.com/project-id'])
|
|
202
|
+
.map((i) => i.metadata.name);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Збирає сирий список ресурсів одного namespace: усе, що живе в GCP
|
|
207
|
+
* (source: 'gcp'), і все, що заявлено під KCC (source: 'kcc'), для кожного
|
|
208
|
+
* з KIND_ORDER. Дублікати не прибираються — це відповідальність споживача.
|
|
209
|
+
*
|
|
210
|
+
* diagnostics — записи, які не є ні "живим", ні "заявленим" ресурсом, а
|
|
211
|
+
* приміткою про сам збір: застарілий кеш Cloud Asset Inventory, зони, якими
|
|
212
|
+
* керує сам GKE.
|
|
213
|
+
*
|
|
214
|
+
* Повертає { namespace, project, resources, diagnostics }. project — null,
|
|
215
|
+
* якщо namespace не має анотації cnrm.cloud.google.com/project-id
|
|
216
|
+
* (resources і diagnostics тоді порожні).
|
|
217
|
+
*/
|
|
218
|
+
export async function collectNamespace(namespace, { includeSystem = false } = {}) {
|
|
219
|
+
const project = projectForNamespace(namespace);
|
|
220
|
+
if (!project) return { namespace, project: null, resources: [], diagnostics: [] };
|
|
221
|
+
|
|
222
|
+
const resources = [];
|
|
223
|
+
const diagnostics = [];
|
|
224
|
+
const push = (kind, ids, source) => {
|
|
225
|
+
for (const id of ids) if (id) resources.push({ kind, id, source });
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const assets = await searchAllResources(project);
|
|
229
|
+
const byType = (...types) => assets.filter((a) => types.includes(a.assetType));
|
|
230
|
+
|
|
231
|
+
// --- IAMServiceAccount ---------------------------------------------------
|
|
232
|
+
const allLiveSa = byType('iam.googleapis.com/ServiceAccount')
|
|
233
|
+
.map((a) => a.additionalAttributes && a.additionalAttributes.email)
|
|
234
|
+
.filter(Boolean);
|
|
235
|
+
let liveSa = allLiveSa;
|
|
236
|
+
if (!includeSystem) liveSa = liveSa.filter((e) => !isSystem(SA_SYSTEM, e));
|
|
237
|
+
push('IAMServiceAccount', liveSa, 'gcp');
|
|
238
|
+
push('IAMServiceAccount', kubectlField(
|
|
239
|
+
'iamserviceaccounts.iam.cnrm.cloud.google.com', namespace,
|
|
240
|
+
(i) => i.status && i.status.email,
|
|
241
|
+
), 'kcc');
|
|
242
|
+
|
|
243
|
+
// --- IAMServiceAccountKey (тільки user-managed) ---------------------------
|
|
244
|
+
// Живі SA для циклу ключів беремо БЕЗ фільтра system — дешевше не
|
|
245
|
+
// ускладнювати, ключ системного SA все одно рідкість. Один REST-виклик
|
|
246
|
+
// на SA, усі паралельно — раніше це був послідовний цикл окремих
|
|
247
|
+
// процесів gcloud, найповільніша частина всього скану.
|
|
248
|
+
const liveKey = (await Promise.all(allLiveSa.map(listUserManagedKeyIds))).flat();
|
|
249
|
+
push('IAMServiceAccountKey', liveKey, 'gcp');
|
|
250
|
+
push('IAMServiceAccountKey', kubectlField(
|
|
251
|
+
'iamserviceaccountkeys.iam.cnrm.cloud.google.com', namespace,
|
|
252
|
+
(i) => i.status && i.status.name,
|
|
253
|
+
).map((n) => n.split('/').pop()), 'kcc');
|
|
254
|
+
|
|
255
|
+
// --- ArtifactRegistryRepository -------------------------------------------
|
|
256
|
+
let liveAr = byType('artifactregistry.googleapis.com/Repository')
|
|
257
|
+
.map((a) => {
|
|
258
|
+
const m = (a.name || '').match(/\/repositories\/([^/]+)$/);
|
|
259
|
+
return m && m[1];
|
|
260
|
+
})
|
|
261
|
+
.filter(Boolean);
|
|
262
|
+
if (!includeSystem) liveAr = liveAr.filter((r) => !isSystem(AR_SYSTEM, r));
|
|
263
|
+
push('ArtifactRegistryRepository', liveAr, 'gcp');
|
|
264
|
+
push('ArtifactRegistryRepository', kubectlField(
|
|
265
|
+
'artifactregistryrepositories.artifactregistry.cnrm.cloud.google.com', namespace,
|
|
266
|
+
(i) => i.spec && i.spec.resourceID,
|
|
267
|
+
), 'kcc');
|
|
268
|
+
|
|
269
|
+
// --- ContainerCluster + ContainerNodePool ---------------------------------
|
|
270
|
+
push('ContainerCluster', byType('container.googleapis.com/Cluster').map((a) => a.displayName), 'gcp');
|
|
271
|
+
push('ContainerCluster', kubectlField(
|
|
272
|
+
'containerclusters.container.cnrm.cloud.google.com', namespace,
|
|
273
|
+
(i) => i.spec && i.spec.resourceID,
|
|
274
|
+
), 'kcc');
|
|
275
|
+
|
|
276
|
+
const livePool = [];
|
|
277
|
+
for (const a of byType('container.googleapis.com/NodePool')) {
|
|
278
|
+
const m = (a.name || '').match(/\/clusters\/([^/]+)\/nodePools\/([^/]+)$/);
|
|
279
|
+
if (!m) continue;
|
|
280
|
+
const [, cluster, pool] = m;
|
|
281
|
+
if (includeSystem || !NODEPOOL_SYSTEM.test(pool)) livePool.push(`${cluster}/${pool}`);
|
|
282
|
+
}
|
|
283
|
+
push('ContainerNodePool', livePool, 'gcp');
|
|
284
|
+
push('ContainerNodePool', kubectlField(
|
|
285
|
+
'containernodepools.container.cnrm.cloud.google.com', namespace,
|
|
286
|
+
(i) => {
|
|
287
|
+
const ref = (i.spec && i.spec.clusterRef) || {};
|
|
288
|
+
return `${ref.name || ref.external}/${i.spec && i.spec.resourceID}`;
|
|
289
|
+
},
|
|
290
|
+
), 'kcc');
|
|
291
|
+
|
|
292
|
+
// --- StorageBucket ---------------------------------------------------------
|
|
293
|
+
let liveBucket = byType('storage.googleapis.com/Bucket').map((a) => a.displayName);
|
|
294
|
+
if (!includeSystem) liveBucket = liveBucket.filter((b) => !isSystemBucket(b, project));
|
|
295
|
+
push('StorageBucket', liveBucket, 'gcp');
|
|
296
|
+
push('StorageBucket', kubectlField(
|
|
297
|
+
'storagebuckets.storage.cnrm.cloud.google.com', namespace,
|
|
298
|
+
(i) => i.spec && i.spec.resourceID,
|
|
299
|
+
), 'kcc');
|
|
300
|
+
|
|
301
|
+
// --- ComputeAddress (регіональні й глобальні) -------------------------------
|
|
302
|
+
// Asset Inventory інколи повертає вже видалені адреси (перевірено
|
|
303
|
+
// емпірично на azovmemo: search-all-resources показав адресу без
|
|
304
|
+
// читабельного імені, якої `gcloud compute addresses describe` вже не
|
|
305
|
+
// знаходить — той самий клас застарілого кешу, що й ResourceRecordSet).
|
|
306
|
+
// Два дешевих прямих виклики (regional + global), не по одному на
|
|
307
|
+
// ресурс, — фільтруємо ними ці привиди CAI.
|
|
308
|
+
const [regionalAddrIds, globalAddrIds] = await Promise.all([
|
|
309
|
+
listRegionalAddressIds(project),
|
|
310
|
+
listGlobalAddressIds(project),
|
|
311
|
+
]);
|
|
312
|
+
const liveAddrIds = new Set([...regionalAddrIds, ...globalAddrIds]);
|
|
313
|
+
const staleAddrCount0 = byType('compute.googleapis.com/Address', 'compute.googleapis.com/GlobalAddress').length;
|
|
314
|
+
const liveAddr = byType('compute.googleapis.com/Address', 'compute.googleapis.com/GlobalAddress')
|
|
315
|
+
.map((a) => `${a.displayName}/${a.location === 'global' ? 'global' : a.location}`)
|
|
316
|
+
.filter((id) => liveAddrIds.has(id));
|
|
317
|
+
const staleAddrCount = staleAddrCount0 - liveAddr.length;
|
|
318
|
+
if (staleAddrCount) diagnostics.push({ kind: 'ComputeAddress', type: 'stale-cache', count: staleAddrCount });
|
|
319
|
+
push('ComputeAddress', liveAddr, 'gcp');
|
|
320
|
+
push('ComputeAddress', kubectlField(
|
|
321
|
+
'computeaddresses.compute.cnrm.cloud.google.com', namespace,
|
|
322
|
+
(i) => `${i.spec && i.spec.resourceID}/${(i.spec && i.spec.location) || 'global'}`,
|
|
323
|
+
), 'kcc');
|
|
324
|
+
|
|
325
|
+
// --- DNSManagedZone + DNSRecordSet -------------------------------------------
|
|
326
|
+
// Зони, які GKE заводить і веде сам (label goog-gke-node) — не в diff
|
|
327
|
+
// узагалі: не KCC-кандидат, і рекордсетів там сотні на Service.
|
|
328
|
+
const zones = byType('dns.googleapis.com/ManagedZone');
|
|
329
|
+
const gkeZoneNames = new Set(
|
|
330
|
+
zones.filter((z) => z.labels && z.labels['goog-gke-node'] !== undefined).map((z) => z.displayName),
|
|
331
|
+
);
|
|
332
|
+
const liveZone = zones.map((z) => z.displayName).filter((n) => !gkeZoneNames.has(n));
|
|
333
|
+
if (gkeZoneNames.size) {
|
|
334
|
+
diagnostics.push({ kind: 'DNSManagedZone', type: 'gke-managed-skip', zones: [...gkeZoneNames].sort() });
|
|
335
|
+
}
|
|
336
|
+
push('DNSManagedZone', liveZone, 'gcp');
|
|
337
|
+
push('DNSManagedZone', kubectlField(
|
|
338
|
+
'dnsmanagedzones.dns.cnrm.cloud.google.com', namespace,
|
|
339
|
+
(i) => i.spec && i.spec.resourceID,
|
|
340
|
+
), 'kcc');
|
|
341
|
+
|
|
342
|
+
// rrset .name містить числовий id зони, не читабельне ім'я — мапа
|
|
343
|
+
// id->displayName будується з тих самих ManagedZone-записів.
|
|
344
|
+
const zoneNameById = new Map();
|
|
345
|
+
for (const z of zones) {
|
|
346
|
+
const m = (z.name || '').match(/\/managedZones\/([^/]+)$/);
|
|
347
|
+
if (m) zoneNameById.set(m[1], z.displayName);
|
|
348
|
+
}
|
|
349
|
+
// Cloud Asset Inventory інколи повертає ResourceRecordSet від зон, яких
|
|
350
|
+
// уже немає (перевірено емпірично на azovmemo: rrsets посилались на три
|
|
351
|
+
// різні managedZone-id, жоден з яких не збігався з id живої зони —
|
|
352
|
+
// застарілий кеш після пересоздання GKE-зони). Запис без резолву в
|
|
353
|
+
// zoneNameById — не дрейф, а сміття CAI, тому пропускаємо мовчки, а не
|
|
354
|
+
// фолбечимось на сирий id (інакше він ніколи не збіжиться з
|
|
355
|
+
// gkeZoneNames і завжди рахувався б за DRIFT).
|
|
356
|
+
let staleRrsetCount = 0;
|
|
357
|
+
const liveRrset = [];
|
|
358
|
+
for (const a of byType('dns.googleapis.com/ResourceRecordSet')) {
|
|
359
|
+
const mZid = (a.parentFullResourceName || '').match(/\/managedZones\/([^/]+)$/);
|
|
360
|
+
if (!mZid) continue;
|
|
361
|
+
const zname = zoneNameById.get(mZid[1]);
|
|
362
|
+
if (zname === undefined) {
|
|
363
|
+
staleRrsetCount += 1;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (gkeZoneNames.has(zname)) continue;
|
|
367
|
+
const mRr = (a.name || '').match(/\/rrsets\/(.+)\/([A-Za-z]+)$/);
|
|
368
|
+
if (!mRr) continue;
|
|
369
|
+
liveRrset.push(`${zname}/${mRr[1]}/${mRr[2]}`);
|
|
370
|
+
}
|
|
371
|
+
if (staleRrsetCount) diagnostics.push({ kind: 'DNSRecordSet', type: 'stale-cache', count: staleRrsetCount });
|
|
372
|
+
push('DNSRecordSet', liveRrset, 'gcp');
|
|
373
|
+
push('DNSRecordSet', kubectlField(
|
|
374
|
+
'dnsrecordsets.dns.cnrm.cloud.google.com', namespace,
|
|
375
|
+
(i) => {
|
|
376
|
+
const zref = (i.spec && i.spec.managedZoneRef) || {};
|
|
377
|
+
return `${zref.name}/${i.spec && i.spec.name}/${i.spec && i.spec.type}`;
|
|
378
|
+
},
|
|
379
|
+
), 'kcc');
|
|
380
|
+
|
|
381
|
+
// --- IAMPolicyMember ---------------------------------------------------------
|
|
382
|
+
// search-all-iam-policies — усі біндинги проєкту одразу, на будь-якому
|
|
383
|
+
// типі ресурсу, не тільки на трьох раніше підтримуваних (Project/SA/AR).
|
|
384
|
+
const iamPolicies = await searchAllIamPolicies(project);
|
|
385
|
+
const liveIam = [];
|
|
386
|
+
for (const entry of iamPolicies) {
|
|
387
|
+
const rid = assetResId(entry, project);
|
|
388
|
+
for (const binding of (entry.policy && entry.policy.bindings) || []) {
|
|
389
|
+
for (const member of binding.members || []) {
|
|
390
|
+
if (includeSystem || !isSystem(IAM_SYSTEM, member)) {
|
|
391
|
+
liveIam.push(`${rid}/${binding.role}/${member}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
push('IAMPolicyMember', liveIam, 'gcp');
|
|
397
|
+
|
|
398
|
+
const kccPolicyItems = kubectlJson('get', 'iampolicymembers.iam.cnrm.cloud.google.com', '-n', namespace);
|
|
399
|
+
push('IAMPolicyMember', ((kccPolicyItems && kccPolicyItems.items) || []).map((item) => {
|
|
400
|
+
const rid = kccPolicyMemberId(item, project);
|
|
401
|
+
const spec = item.spec || {};
|
|
402
|
+
return `${rid}/${spec.role}/${spec.member}`;
|
|
403
|
+
}), 'kcc');
|
|
404
|
+
|
|
405
|
+
return { namespace, project, resources, diagnostics };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// --- CLI: `cfr get-resources` — collectNamespace() as its own subcommand,
|
|
409
|
+
// no diffing. What kcc-inventory consumes, exposed directly for anyone
|
|
410
|
+
// who wants the raw facts (piping into jq, feeding a different tool).
|
|
411
|
+
|
|
412
|
+
export const HELP = `cfr get-resources — raw KCC/GCP resource list, no diff
|
|
413
|
+
|
|
414
|
+
Usage:
|
|
415
|
+
npx @nitra/cfr get-resources <namespace>
|
|
416
|
+
npx @nitra/cfr get-resources --all
|
|
417
|
+
npx @nitra/cfr get-resources <namespace|--all> [--json] [--include-system]
|
|
418
|
+
|
|
419
|
+
The mechanism kcc-inventory is built on, callable on its own: per KCC
|
|
420
|
+
namespace (namespace carrying the annotation
|
|
421
|
+
cnrm.cloud.google.com/project-id), lists every resource found live in the
|
|
422
|
+
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
|
|
427
|
+
\`cfr kcc-inventory\`.
|
|
428
|
+
|
|
429
|
+
By default GCP-managed system noise is filtered out (Google-owned service
|
|
430
|
+
accounts, gcr.io shims, GKE-managed node pools/DNS zones, legacy bucket
|
|
431
|
+
ACL entries, ...) — pass --include-system to see it anyway.
|
|
432
|
+
|
|
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.
|
|
438
|
+
|
|
439
|
+
Options:
|
|
440
|
+
--json Emit {resources, diagnostics} as JSON instead of the
|
|
441
|
+
human-readable listing.
|
|
442
|
+
--include-system Don't filter out GCP-managed system resources.
|
|
443
|
+
-h, --help Show this help and exit.
|
|
444
|
+
|
|
445
|
+
Exits 0 on a completed scan, 2 on a usage error.
|
|
446
|
+
`;
|
|
447
|
+
|
|
448
|
+
function printHuman(namespace, collected) {
|
|
449
|
+
console.log(`### namespace ${namespace} -> проєкт ${collected.project} ###`);
|
|
450
|
+
const byKind = new Map(KIND_ORDER.map((k) => [k, []]));
|
|
451
|
+
for (const r of collected.resources) byKind.get(r.kind)?.push(r);
|
|
452
|
+
for (const kind of KIND_ORDER) {
|
|
453
|
+
const entries = byKind.get(kind);
|
|
454
|
+
if (!entries.length) continue;
|
|
455
|
+
console.log(`== ${kind} ==`);
|
|
456
|
+
for (const { source, id } of [...entries].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
457
|
+
console.log(` ${source}: ${id}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (collected.diagnostics.length) {
|
|
461
|
+
console.log('== diagnostics ==');
|
|
462
|
+
for (const d of collected.diagnostics) {
|
|
463
|
+
if (d.type === 'stale-cache') console.log(` ${d.kind}: застарілий кеш Asset Inventory, ${d.count} запис(ів)`);
|
|
464
|
+
else if (d.type === 'gke-managed-skip') console.log(` ${d.kind}: керує сам GKE — ${d.zones.join(', ')}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
console.log('');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export async function run(argv) {
|
|
471
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
472
|
+
process.stdout.write(HELP);
|
|
473
|
+
return 0;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const target = argv[0];
|
|
477
|
+
if (!target) {
|
|
478
|
+
console.error('usage: cfr get-resources <namespace|--all> [--json] [--include-system] (KUBE_CONTEXT=... для явного контексту)');
|
|
479
|
+
return 2;
|
|
480
|
+
}
|
|
481
|
+
const jsonMode = argv.includes('--json');
|
|
482
|
+
const includeSystem = argv.includes('--include-system');
|
|
483
|
+
|
|
484
|
+
let namespaces;
|
|
485
|
+
if (target === '--all') {
|
|
486
|
+
namespaces = listKccNamespaces();
|
|
487
|
+
if (!namespaces.length) {
|
|
488
|
+
console.error('жоден namespace не має cnrm.cloud.google.com/project-id');
|
|
489
|
+
return 2;
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
namespaces = [target];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const resources = [];
|
|
496
|
+
const diagnostics = [];
|
|
497
|
+
for (const namespace of namespaces) {
|
|
498
|
+
const collected = await collectNamespace(namespace, { includeSystem });
|
|
499
|
+
if (!collected.project) {
|
|
500
|
+
console.error(`namespace ${namespace} не має cnrm.cloud.google.com/project-id`);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (jsonMode) {
|
|
504
|
+
for (const r of collected.resources) resources.push({ namespace, project: collected.project, ...r });
|
|
505
|
+
for (const d of collected.diagnostics) diagnostics.push({ namespace, project: collected.project, ...d });
|
|
506
|
+
} else {
|
|
507
|
+
printHuman(namespace, collected);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (jsonMode) console.log(JSON.stringify({ resources, diagnostics }, null, 2));
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
package/lib/kcc-inventory.mjs
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// читання, нічого не видаляє — вхідний контракт для
|
|
4
|
-
// cleanup-рішення, яке вирішує, що з DRIFT можна прибрати.
|
|
5
|
-
|
|
6
|
-
// Проєкт береться з анотації namespace (cnrm.cloud.google.com/project-id),
|
|
7
|
-
// не з константи — той самий скрипт працює для будь-якого namespace, що
|
|
8
|
-
// веде GCP-проєкт через KCC у namespaced mode.
|
|
9
|
-
import { spawnSync } from 'node:child_process';
|
|
1
|
+
// Diff і звіт над сирим списком з get-resources.mjs: що з GCP-ресурсів
|
|
2
|
+
// проєкту реально живе під KCC (описано в git), а що — "чуже" (створено
|
|
3
|
+
// повз KCC). Суто читання, нічого не видаляє — вхідний контракт для
|
|
4
|
+
// окремого cleanup-рішення, яке вирішує, що з DRIFT можна прибрати.
|
|
5
|
+
import { collectNamespace, listKccNamespaces, KIND_ORDER } from './get-resources.mjs';
|
|
10
6
|
|
|
11
7
|
export const HELP = `cfr kcc-inventory — GCP Config Connector (KCC) drift inventory
|
|
12
8
|
|
|
@@ -31,9 +27,13 @@ By default GCP-managed system noise is filtered out (Google-owned service
|
|
|
31
27
|
accounts, gcr.io shims, GKE-managed node pools/DNS zones, legacy bucket
|
|
32
28
|
ACL entries, ...) — pass --include-system to see it anyway.
|
|
33
29
|
|
|
34
|
-
Requires \`
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
Requires \`kubectl\` on PATH, pointed at the target cluster (set KUBE_CONTEXT
|
|
31
|
+
to target a specific kubeconfig context explicitly instead of relying on
|
|
32
|
+
the current one). The GCP side talks to Cloud Asset Inventory, IAM, and
|
|
33
|
+
Compute Engine directly over REST — no \`gcloud\` CLI needed, just
|
|
34
|
+
Application Default Credentials (\`gcloud auth application-default login\`,
|
|
35
|
+
a service account key via GOOGLE_APPLICATION_CREDENTIALS, or the ambient
|
|
36
|
+
credentials on GCE/GKE/Cloud Build).
|
|
37
37
|
|
|
38
38
|
Options:
|
|
39
39
|
--json Emit a JSON array of {kind, id, project, status} instead
|
|
@@ -45,86 +45,7 @@ Exits 0 on a completed scan (DRIFT/ORPHAN findings are not failures — this
|
|
|
45
45
|
is a report, not a gate), 2 on a usage error.
|
|
46
46
|
`;
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
const ctx = process.env.KUBE_CONTEXT;
|
|
50
|
-
return ctx ? ['kubectl', '--context', ctx] : ['kubectl'];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// maxBuffer: дефолтні 1MB замалі для search-all-resources на проєкті з
|
|
54
|
-
// сотнями активів — spawnSync мовчки падає в ENOBUFS, і виклик, що його не
|
|
55
|
-
// перевіряє (як тут), бачить порожній результат замість помилки.
|
|
56
|
-
function runCmd(cmd, args) {
|
|
57
|
-
return spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 100 * 1024 * 1024 });
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function kubectlJson(...args) {
|
|
61
|
-
const [bin, ...base] = kubectlBase();
|
|
62
|
-
const proc = runCmd(bin, [...base, ...args, '-o', 'json']);
|
|
63
|
-
if (proc.status !== 0 || !proc.stdout || !proc.stdout.trim()) return null;
|
|
64
|
-
try {
|
|
65
|
-
return JSON.parse(proc.stdout);
|
|
66
|
-
} catch {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function kubectlField(crd, namespace, fieldFn) {
|
|
72
|
-
const data = kubectlJson('get', crd, '-n', namespace);
|
|
73
|
-
if (!data) return [];
|
|
74
|
-
return (data.items || []).map(fieldFn).filter(Boolean);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function gcloudJson(...args) {
|
|
78
|
-
const proc = runCmd('gcloud', [...args, '--format=json']);
|
|
79
|
-
try {
|
|
80
|
-
return JSON.parse(proc.stdout || '[]');
|
|
81
|
-
} catch {
|
|
82
|
-
return [];
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function gcloudLines(...args) {
|
|
87
|
-
const proc = runCmd('gcloud', args);
|
|
88
|
-
return (proc.stdout || '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function stripPrefix(s, prefix) {
|
|
92
|
-
return s.startsWith(prefix) ? s.slice(prefix.length) : s;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// --- фільтри GCP-системного шуму (див. --include-system) ------------------
|
|
96
|
-
|
|
97
|
-
const SA_SYSTEM = [
|
|
98
|
-
/^[0-9]+-compute@developer\.gserviceaccount\.com$/,
|
|
99
|
-
/@appspot\.gserviceaccount\.com$/,
|
|
100
|
-
/@cloudservices\.gserviceaccount\.com$/,
|
|
101
|
-
/^firebase-adminsdk-/,
|
|
102
|
-
];
|
|
103
|
-
|
|
104
|
-
const AR_SYSTEM = [/(^|\.)gcr\.io$/, /^gcf-artifacts$/];
|
|
105
|
-
|
|
106
|
-
const NODEPOOL_SYSTEM = /^nap-/;
|
|
107
|
-
|
|
108
|
-
function isSystemBucket(name, project) {
|
|
109
|
-
return name.endsWith('.appspot.com') || name.startsWith('gcf-sources-') || name === `${project}_cloudbuild`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// projectEditor:/projectOwner:/projectViewer: — legacy bucket ACL, GCP
|
|
113
|
-
// навішує на кожен бакет сам; решта — Google-керовані service agents.
|
|
114
|
-
const IAM_SYSTEM = [
|
|
115
|
-
/^project(Editor|Owner|Viewer):/,
|
|
116
|
-
/@system\.gserviceaccount\.com$/,
|
|
117
|
-
/serviceAccount:service-[0-9]+@/,
|
|
118
|
-
/@gcp-sa-[a-z0-9-]+\.iam\.gserviceaccount\.com$/,
|
|
119
|
-
/@(cloudbuild|cloudservices|developer|appspot)\.gserviceaccount\.com$/,
|
|
120
|
-
/@(container-engine-robot|serverless-robot-prod|gcf-admin-robot|firebase-rules|firebase-sa-management)\.iam\.gserviceaccount\.com$/,
|
|
121
|
-
];
|
|
122
|
-
|
|
123
|
-
function isSystem(patterns, value) {
|
|
124
|
-
return patterns.some((p) => p.test(value));
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// --- report: sort+diff двох списків, людський звіт і/або JSON -------------
|
|
48
|
+
// --- diff+звіт по одному kind: sort+diff двох списків, людський звіт і/або JSON
|
|
128
49
|
|
|
129
50
|
function report(kind, liveIds, kccIds, project, jsonMode, results) {
|
|
130
51
|
const live = [...new Set(liveIds.filter(Boolean))].sort();
|
|
@@ -154,274 +75,59 @@ function report(kind, liveIds, kccIds, project, jsonMode, results) {
|
|
|
154
75
|
for (const x of orphan) results.push({ kind, id: x, project, status: 'orphan' });
|
|
155
76
|
}
|
|
156
77
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (res.endsWith(`/projects/${project}`)) return `project/${project}`;
|
|
163
|
-
const mSa = res.match(/\/serviceAccounts\/([^/]+)$/);
|
|
164
|
-
if (mSa && assetType === 'iam.googleapis.com/ServiceAccount') return `sa/${mSa[1]}`;
|
|
165
|
-
const mAr = res.match(/\/repositories\/([^/]+)$/);
|
|
166
|
-
if (mAr && assetType === 'artifactregistry.googleapis.com/Repository') return `ar/${mAr[1]}`;
|
|
167
|
-
if (assetType === 'storage.googleapis.com/Bucket') return 'bucket/' + stripPrefix(res, '//storage.googleapis.com/');
|
|
168
|
-
return 'other/' + stripPrefix(res, '//');
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function kccArId(ref) {
|
|
172
|
-
const ext = ref.external || '';
|
|
173
|
-
const m = ext.match(/\/repositories\/([^/]+)$/);
|
|
174
|
-
if (m) return m[1];
|
|
175
|
-
if (ref.name) return ref.name;
|
|
176
|
-
return '?';
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function kccSaId(ref, project) {
|
|
180
|
-
const ext = ref.external || '';
|
|
181
|
-
const m = ext.match(/\/serviceAccounts\/([^/]+)$/);
|
|
182
|
-
if (m) return m[1];
|
|
183
|
-
if (ext) return ext;
|
|
184
|
-
if (ref.name) return `${ref.name}@${project}.iam.gserviceaccount.com`;
|
|
185
|
-
return '?';
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function kccPolicyMemberId(item, project) {
|
|
189
|
-
const ref = (item.spec && item.spec.resourceRef) || {};
|
|
190
|
-
switch (ref.kind) {
|
|
191
|
-
case 'Project':
|
|
192
|
-
return `project/${project}`;
|
|
193
|
-
case 'IAMServiceAccount':
|
|
194
|
-
return 'sa/' + kccSaId(ref, project);
|
|
195
|
-
case 'ArtifactRegistryRepository':
|
|
196
|
-
return 'ar/' + kccArId(ref);
|
|
197
|
-
case 'StorageBucket':
|
|
198
|
-
return 'bucket/' + (ref.external || ref.name || '?');
|
|
199
|
-
default:
|
|
200
|
-
return 'other/' + (ref.external || ref.name || '?');
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// --- по одному namespace ----------------------------------------------
|
|
205
|
-
|
|
206
|
-
function scanNamespace(namespace, includeSystem, jsonMode, results) {
|
|
207
|
-
const ns = kubectlJson('get', 'namespace', namespace);
|
|
208
|
-
const project = ns && ns.metadata && ns.metadata.annotations
|
|
209
|
-
&& ns.metadata.annotations['cnrm.cloud.google.com/project-id'];
|
|
210
|
-
if (!project) {
|
|
211
|
-
console.error(`namespace ${namespace} не має cnrm.cloud.google.com/project-id`);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (!jsonMode) console.log(`### namespace ${namespace} -> проєкт ${project} ###`);
|
|
216
|
-
|
|
217
|
-
const assets = gcloudJson('asset', 'search-all-resources', `--scope=projects/${project}`);
|
|
218
|
-
const byType = (...types) => assets.filter((a) => types.includes(a.assetType));
|
|
219
|
-
|
|
220
|
-
// --- IAMServiceAccount ------------------------------------------------
|
|
221
|
-
const allLiveSa = byType('iam.googleapis.com/ServiceAccount')
|
|
222
|
-
.map((a) => a.additionalAttributes && a.additionalAttributes.email)
|
|
223
|
-
.filter(Boolean);
|
|
224
|
-
let liveSa = allLiveSa;
|
|
225
|
-
if (!includeSystem) liveSa = liveSa.filter((e) => !isSystem(SA_SYSTEM, e));
|
|
226
|
-
const kccSa = kubectlField(
|
|
227
|
-
'iamserviceaccounts.iam.cnrm.cloud.google.com', namespace,
|
|
228
|
-
(i) => i.status && i.status.email,
|
|
229
|
-
);
|
|
230
|
-
report('IAMServiceAccount', liveSa, kccSa, project, jsonMode, results);
|
|
231
|
-
|
|
232
|
-
// --- IAMServiceAccountKey (тільки user-managed) ------------------------
|
|
233
|
-
// Живі SA для циклу ключів беремо БЕЗ фільтра system — дешевше не
|
|
234
|
-
// ускладнювати, ключ системного SA все одно рідкість.
|
|
235
|
-
const liveKey = [];
|
|
236
|
-
for (const email of allLiveSa) {
|
|
237
|
-
liveKey.push(...gcloudLines(
|
|
238
|
-
'iam', 'service-accounts', 'keys', 'list',
|
|
239
|
-
`--iam-account=${email}`, '--managed-by=user',
|
|
240
|
-
`--project=${project}`, '--format=value(name)',
|
|
241
|
-
));
|
|
242
|
-
}
|
|
243
|
-
const kccKey = kubectlField(
|
|
244
|
-
'iamserviceaccountkeys.iam.cnrm.cloud.google.com', namespace,
|
|
245
|
-
(i) => i.status && i.status.name,
|
|
246
|
-
).map((n) => n.split('/').pop());
|
|
247
|
-
report('IAMServiceAccountKey', liveKey, kccKey, project, jsonMode, results);
|
|
248
|
-
|
|
249
|
-
// --- ArtifactRegistryRepository -----------------------------------------
|
|
250
|
-
let liveAr = byType('artifactregistry.googleapis.com/Repository')
|
|
251
|
-
.map((a) => {
|
|
252
|
-
const m = (a.name || '').match(/\/repositories\/([^/]+)$/);
|
|
253
|
-
return m && m[1];
|
|
254
|
-
})
|
|
255
|
-
.filter(Boolean);
|
|
256
|
-
if (!includeSystem) liveAr = liveAr.filter((r) => !isSystem(AR_SYSTEM, r));
|
|
257
|
-
const kccAr = kubectlField(
|
|
258
|
-
'artifactregistryrepositories.artifactregistry.cnrm.cloud.google.com', namespace,
|
|
259
|
-
(i) => i.spec && i.spec.resourceID,
|
|
260
|
-
);
|
|
261
|
-
report('ArtifactRegistryRepository', liveAr, kccAr, project, jsonMode, results);
|
|
262
|
-
|
|
263
|
-
// --- ContainerCluster + ContainerNodePool -------------------------------
|
|
264
|
-
const liveCluster = byType('container.googleapis.com/Cluster').map((a) => a.displayName);
|
|
265
|
-
const kccCluster = kubectlField(
|
|
266
|
-
'containerclusters.container.cnrm.cloud.google.com', namespace,
|
|
267
|
-
(i) => i.spec && i.spec.resourceID,
|
|
268
|
-
);
|
|
269
|
-
report('ContainerCluster', liveCluster, kccCluster, project, jsonMode, results);
|
|
270
|
-
|
|
271
|
-
const livePool = [];
|
|
272
|
-
for (const a of byType('container.googleapis.com/NodePool')) {
|
|
273
|
-
const m = (a.name || '').match(/\/clusters\/([^/]+)\/nodePools\/([^/]+)$/);
|
|
274
|
-
if (!m) continue;
|
|
275
|
-
const [, cluster, pool] = m;
|
|
276
|
-
if (includeSystem || !NODEPOOL_SYSTEM.test(pool)) livePool.push(`${cluster}/${pool}`);
|
|
277
|
-
}
|
|
278
|
-
const kccPool = kubectlField(
|
|
279
|
-
'containernodepools.container.cnrm.cloud.google.com', namespace,
|
|
280
|
-
(i) => {
|
|
281
|
-
const ref = (i.spec && i.spec.clusterRef) || {};
|
|
282
|
-
return `${ref.name || ref.external}/${i.spec && i.spec.resourceID}`;
|
|
283
|
-
},
|
|
284
|
-
);
|
|
285
|
-
report('ContainerNodePool', livePool, kccPool, project, jsonMode, results);
|
|
286
|
-
|
|
287
|
-
// --- StorageBucket -------------------------------------------------------
|
|
288
|
-
let liveBucket = byType('storage.googleapis.com/Bucket').map((a) => a.displayName);
|
|
289
|
-
if (!includeSystem) liveBucket = liveBucket.filter((b) => !isSystemBucket(b, project));
|
|
290
|
-
const kccBucket = kubectlField(
|
|
291
|
-
'storagebuckets.storage.cnrm.cloud.google.com', namespace,
|
|
292
|
-
(i) => i.spec && i.spec.resourceID,
|
|
293
|
-
);
|
|
294
|
-
report('StorageBucket', liveBucket, kccBucket, project, jsonMode, results);
|
|
295
|
-
|
|
296
|
-
// --- ComputeAddress (регіональні й глобальні) -----------------------------
|
|
297
|
-
// Asset Inventory інколи повертає вже видалені адреси (перевірено
|
|
298
|
-
// емпірично на azovmemo: search-all-resources показав адресу без
|
|
299
|
-
// читабельного імені, якої `gcloud compute addresses describe` вже не
|
|
300
|
-
// знаходить — той самий клас застарілого кешу, що й ResourceRecordSet).
|
|
301
|
-
// Два дешевих прямих виклики (regional + global), не по одному на
|
|
302
|
-
// ресурс, — фільтруємо ними AR-подібні привиди CAI.
|
|
303
|
-
const liveAddrIds = new Set([
|
|
304
|
-
...gcloudLines('compute', 'addresses', 'list', `--project=${project}`, '--format=csv[no-heading](name,region.basename())'),
|
|
305
|
-
...gcloudLines('compute', 'addresses', 'list', '--global', `--project=${project}`, '--format=value(name)').map((n) => `${n},`),
|
|
306
|
-
].map((row) => {
|
|
307
|
-
const [name, region] = row.split(',');
|
|
308
|
-
return `${name}/${region || 'global'}`;
|
|
309
|
-
}));
|
|
310
|
-
const staleAddrCount0 = byType('compute.googleapis.com/Address', 'compute.googleapis.com/GlobalAddress').length;
|
|
311
|
-
const liveAddr = byType('compute.googleapis.com/Address', 'compute.googleapis.com/GlobalAddress')
|
|
312
|
-
.map((a) => `${a.displayName}/${a.location === 'global' ? 'global' : a.location}`)
|
|
313
|
-
.filter((id) => liveAddrIds.has(id));
|
|
314
|
-
const staleAddrCount = staleAddrCount0 - liveAddr.length;
|
|
315
|
-
if (staleAddrCount) {
|
|
78
|
+
function printDiagnostic(diag, jsonMode, results, project) {
|
|
79
|
+
if (diag.type === 'stale-cache') {
|
|
80
|
+
const label = diag.kind === 'ComputeAddress'
|
|
81
|
+
? `проігноровано ${diag.count} запис(ів), яких уже нема в GCP (застарілий кеш Asset Inventory)`
|
|
82
|
+
: `проігноровано ${diag.count} запис(ів) від зон, яких уже нема (застарілий кеш Asset Inventory)`;
|
|
316
83
|
if (jsonMode) {
|
|
317
|
-
results.push({ kind:
|
|
84
|
+
results.push({ kind: diag.kind, id: `stale_asset_inventory_cache:${diag.count}`, project, status: 'ignored' });
|
|
318
85
|
} else {
|
|
319
|
-
console.log(`==
|
|
86
|
+
console.log(`== ${diag.kind}: ${label} ==`);
|
|
320
87
|
console.log('');
|
|
321
88
|
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
(i) => `${i.spec && i.spec.resourceID}/${(i.spec && i.spec.location) || 'global'}`,
|
|
326
|
-
);
|
|
327
|
-
report('ComputeAddress', liveAddr, kccAddr, project, jsonMode, results);
|
|
328
|
-
|
|
329
|
-
// --- DNSManagedZone + DNSRecordSet ---------------------------------------
|
|
330
|
-
// Зони, які GKE заводить і веде сам (label goog-gke-node) — не в diff
|
|
331
|
-
// узагалі: не KCC-кандидат, і рекордсетів там сотні на Service.
|
|
332
|
-
const zones = byType('dns.googleapis.com/ManagedZone');
|
|
333
|
-
const gkeZoneNames = new Set(
|
|
334
|
-
zones.filter((z) => z.labels && z.labels['goog-gke-node'] !== undefined).map((z) => z.displayName),
|
|
335
|
-
);
|
|
336
|
-
const liveZone = zones.map((z) => z.displayName).filter((n) => !gkeZoneNames.has(n));
|
|
337
|
-
if (gkeZoneNames.size && !jsonMode) {
|
|
338
|
-
console.log('== DNSManagedZone: пропущено (goog-gke-node, керує сам GKE) ==');
|
|
339
|
-
for (const z of [...gkeZoneNames].sort()) console.log(` ${z}`);
|
|
89
|
+
} else if (diag.type === 'gke-managed-skip' && !jsonMode) {
|
|
90
|
+
console.log(`== ${diag.kind}: пропущено (goog-gke-node, керує сам GKE) ==`);
|
|
91
|
+
for (const z of diag.zones) console.log(` ${z}`);
|
|
340
92
|
console.log('');
|
|
341
93
|
}
|
|
342
|
-
|
|
343
|
-
'dnsmanagedzones.dns.cnrm.cloud.google.com', namespace,
|
|
344
|
-
(i) => i.spec && i.spec.resourceID,
|
|
345
|
-
);
|
|
346
|
-
report('DNSManagedZone', liveZone, kccZone, project, jsonMode, results);
|
|
94
|
+
}
|
|
347
95
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
// zoneNameById — не дрейф, а сміття CAI, тому пропускаємо мовчки, а не
|
|
360
|
-
// фолбечимось на сирий id (інакше він ніколи не збіжиться з
|
|
361
|
-
// gkeZoneNames і завжди рахувався б за DRIFT).
|
|
362
|
-
let staleRrsetCount = 0;
|
|
363
|
-
const liveRrset = [];
|
|
364
|
-
for (const a of byType('dns.googleapis.com/ResourceRecordSet')) {
|
|
365
|
-
const mZid = (a.parentFullResourceName || '').match(/\/managedZones\/([^/]+)$/);
|
|
366
|
-
if (!mZid) continue;
|
|
367
|
-
const zname = zoneNameById.get(mZid[1]);
|
|
368
|
-
if (zname === undefined) {
|
|
369
|
-
staleRrsetCount += 1;
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
if (gkeZoneNames.has(zname)) continue;
|
|
373
|
-
const mRr = (a.name || '').match(/\/rrsets\/(.+)\/([A-Za-z]+)$/);
|
|
374
|
-
if (!mRr) continue;
|
|
375
|
-
liveRrset.push(`${zname}/${mRr[1]}/${mRr[2]}`);
|
|
376
|
-
}
|
|
377
|
-
if (staleRrsetCount) {
|
|
378
|
-
if (jsonMode) {
|
|
379
|
-
results.push({
|
|
380
|
-
kind: 'DNSRecordSet', id: `stale_asset_inventory_cache:${staleRrsetCount}`,
|
|
381
|
-
project, status: 'ignored',
|
|
382
|
-
});
|
|
383
|
-
} else {
|
|
384
|
-
console.log(`== DNSRecordSet: проігноровано ${staleRrsetCount} запис(ів) від зон, яких уже нема (застарілий кеш Asset Inventory) ==`);
|
|
385
|
-
console.log('');
|
|
386
|
-
}
|
|
96
|
+
// Groups the flat resources list from get-resources.mjs back into
|
|
97
|
+
// per-kind {live, kcc} arrays, diffs and prints each kind in KIND_ORDER,
|
|
98
|
+
// interleaving each kind's diagnostics (if any) right before its report —
|
|
99
|
+
// same order a human reading top to bottom expects.
|
|
100
|
+
function diffAndReport(collected, jsonMode, results) {
|
|
101
|
+
const { project, resources, diagnostics } = collected;
|
|
102
|
+
const byKind = new Map(KIND_ORDER.map((k) => [k, { live: [], kcc: [] }]));
|
|
103
|
+
for (const { kind, id, source } of resources) {
|
|
104
|
+
const bucket = byKind.get(kind);
|
|
105
|
+
if (!bucket) continue;
|
|
106
|
+
bucket[source === 'kcc' ? 'kcc' : 'live'].push(id);
|
|
387
107
|
}
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
(i) => {
|
|
391
|
-
const zref = (i.spec && i.spec.managedZoneRef) || {};
|
|
392
|
-
return `${zref.name}/${i.spec && i.spec.name}/${i.spec && i.spec.type}`;
|
|
393
|
-
},
|
|
394
|
-
);
|
|
395
|
-
report('DNSRecordSet', liveRrset, kccRrset, project, jsonMode, results);
|
|
108
|
+
const diagByKind = new Map();
|
|
109
|
+
for (const d of diagnostics) diagByKind.set(d.kind, d);
|
|
396
110
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
for (const entry of iamPolicies) {
|
|
403
|
-
const rid = assetResId(entry, project);
|
|
404
|
-
for (const binding of (entry.policy && entry.policy.bindings) || []) {
|
|
405
|
-
for (const member of binding.members || []) {
|
|
406
|
-
if (includeSystem || !isSystem(IAM_SYSTEM, member)) {
|
|
407
|
-
liveIam.push(`${rid}/${binding.role}/${member}`);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
111
|
+
for (const kind of KIND_ORDER) {
|
|
112
|
+
const diag = diagByKind.get(kind);
|
|
113
|
+
if (diag) printDiagnostic(diag, jsonMode, results, project);
|
|
114
|
+
const { live, kcc } = byKind.get(kind);
|
|
115
|
+
report(kind, live, kcc, project, jsonMode, results);
|
|
411
116
|
}
|
|
117
|
+
}
|
|
412
118
|
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
return
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
119
|
+
async function scanNamespace(namespace, includeSystem, jsonMode, results) {
|
|
120
|
+
const collected = await collectNamespace(namespace, { includeSystem });
|
|
121
|
+
if (!collected.project) {
|
|
122
|
+
console.error(`namespace ${namespace} не має cnrm.cloud.google.com/project-id`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!jsonMode) console.log(`### namespace ${namespace} -> проєкт ${collected.project} ###`);
|
|
126
|
+
diffAndReport(collected, jsonMode, results);
|
|
421
127
|
if (!jsonMode) console.log('');
|
|
422
128
|
}
|
|
423
129
|
|
|
424
|
-
export function run(argv) {
|
|
130
|
+
export async function run(argv) {
|
|
425
131
|
if (argv.includes('-h') || argv.includes('--help')) {
|
|
426
132
|
process.stdout.write(HELP);
|
|
427
133
|
return 0;
|
|
@@ -437,10 +143,7 @@ export function run(argv) {
|
|
|
437
143
|
|
|
438
144
|
let namespaces;
|
|
439
145
|
if (target === '--all') {
|
|
440
|
-
|
|
441
|
-
namespaces = ((data && data.items) || [])
|
|
442
|
-
.filter((i) => i.metadata && i.metadata.annotations && i.metadata.annotations['cnrm.cloud.google.com/project-id'])
|
|
443
|
-
.map((i) => i.metadata.name);
|
|
146
|
+
namespaces = listKccNamespaces();
|
|
444
147
|
if (!namespaces.length) {
|
|
445
148
|
console.error('жоден namespace не має cnrm.cloud.google.com/project-id');
|
|
446
149
|
return 2;
|
|
@@ -450,7 +153,7 @@ export function run(argv) {
|
|
|
450
153
|
}
|
|
451
154
|
|
|
452
155
|
const results = [];
|
|
453
|
-
for (const ns of namespaces) scanNamespace(ns, includeSystem, jsonMode, results);
|
|
156
|
+
for (const ns of namespaces) await scanNamespace(ns, includeSystem, jsonMode, results);
|
|
454
157
|
|
|
455
158
|
if (jsonMode) console.log(JSON.stringify(results, null, 2));
|
|
456
159
|
return 0;
|
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.4.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",
|
|
@@ -37,5 +37,8 @@
|
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"test": "node --test test/cli.test.mjs"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"google-auth-library": "^10.9.1"
|
|
40
43
|
}
|
|
41
44
|
}
|