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