diff --git a/CHANGELOG.md b/CHANGELOG.md index 562d970..af9276b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Recognize OpenTofu-managed GKE clusters and node pools, preserving their + canonical location-scoped IDs when comparing them with live GCP and KCC. + ## [0.7.0] - 2026-09-05 ### Added diff --git a/README.md b/README.md index 3c60f72..603c90b 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,9 @@ npx @nitra/cfr kcc-inventory nitraai --show-covered version-1 `{resources: [...]}` catalog. OpenTofu roots are read from actual state via `tofu -chdir=DIR show -json`; parsing `.tf` configuration alone would incorrectly mark resources that were never imported or applied as -covered. Consequently, `tofu` must be available on `PATH` only when +covered. GKE clusters and node pools in OpenTofu state use the same canonical +`location/cluster` and `location/cluster/pool` IDs as live GCP and KCC +resources. Consequently, `tofu` must be available on `PATH` only when `--tofu` is used. Overlapping declarations are an error: a canonical diff --git a/lib/controller-inventory.mjs b/lib/controller-inventory.mjs index 9cd3c2e..50c224a 100644 --- a/lib/controller-inventory.mjs +++ b/lib/controller-inventory.mjs @@ -42,6 +42,10 @@ function flattenModules(module, resources = []) { return resources; } +function scopedId(...parts) { + return parts.every((part) => typeof part === 'string' && part) ? parts.join('/') : undefined; +} + const TOFU_TYPES = { google_iam_workload_identity_pool(values) { return { @@ -67,6 +71,21 @@ const TOFU_TYPES = { id: `${values.location}/${values.repository_id}`, }; }, + google_container_cluster(values) { + return { + project: values.project, + kind: 'ContainerCluster', + id: scopedId(values.location, values.name), + }; + }, + google_container_node_pool(values) { + const cluster = typeof values.cluster === 'string' ? values.cluster.split('/').at(-1) : undefined; + return { + project: values.project, + kind: 'ContainerNodePool', + id: scopedId(values.location, cluster, values.name), + }; + }, }; export function normalizeTofuState(state, source) { diff --git a/lib/get-resources.mjs b/lib/get-resources.mjs index cad532c..d5ca1c4 100644 --- a/lib/get-resources.mjs +++ b/lib/get-resources.mjs @@ -149,6 +149,11 @@ function gkeNodePoolIdentity(name, parent) { return cluster && name && !name.includes('/') ? `${cluster}/${name}` : null; } +export function gkeNodePoolScopedId(name) { + const match = name.match(/\/(?:locations|zones|regions)\/([^/]+)\/clusters\/([^/]+)\/nodePools\/([^/]+)$/); + return match && `${match[1]}/${match[2]}/${match[3]}`; +} + function gkeClusterParent(name) { const match = name.match(/\/projects\/([^/]+)\/(?:locations|zones|regions)\/([^/]+)\/clusters\/([^/]+)\/nodePools\/[^/]+$/); return match && `projects/${match[1]}/locations/${match[2]}/clusters/${match[3]}`; @@ -445,10 +450,9 @@ export async function collectNamespace(namespace, { includeSystem = false } = {} const m = (a.name || '').match(/\/clusters\/([^/]+)\/nodePools\/([^/]+)$/); if (!m) continue; const [, cluster, pool] = m; - const location = (a.name || '').match(/\/locations\/([^/]+)\/clusters\/[^/]+\/nodePools\/[^/]+$/)?.[1]; const parent = gkeClusterParent(a.name || ''); if (parent) gkeClusterParents.add(parent); - poolAssets.push({ id: location ? `${location}/${cluster}/${pool}` : `${cluster}/${pool}`, identity: `${cluster}/${pool}`, pool }); + poolAssets.push({ id: gkeNodePoolScopedId(a.name || '') || `${cluster}/${pool}`, identity: `${cluster}/${pool}`, pool }); } const gkeNodePoolIds = await listGkeNodePoolIds(gkeClusterParents); const stalePoolCount = poolAssets.filter(({ identity }) => !gkeNodePoolIds.has(identity)).length; @@ -463,7 +467,7 @@ export async function collectNamespace(namespace, { includeSystem = false } = {} (i) => { const ref = (i.spec && i.spec.clusterRef) || {}; const external = ref.external || ''; - const m = external.match(/\/locations\/([^/]+)\/clusters\/([^/]+)$/); + const m = external.match(/\/(?:locations|zones|regions)\/([^/]+)\/clusters\/([^/]+)$/); return m ? `${m[1]}/${m[2]}/${resourceId(i)}` : `${clusterRefs.get(ref.name) || ref.name || external}/${resourceId(i)}`; }, ), 'kcc'); diff --git a/test/controller-inventory.test.mjs b/test/controller-inventory.test.mjs index 54f643f..b9e6416 100644 --- a/test/controller-inventory.test.mjs +++ b/test/controller-inventory.test.mjs @@ -75,6 +75,41 @@ test('normalizes root and child OpenTofu modules and diagnoses unsupported resou assert.equal(result.diagnostics[0].resourceType, 'google_unknown'); }); +test('normalizes OpenTofu GKE resources to canonical inventory IDs', () => { + const state = { + values: { + root_module: { + resources: [ + { + address: 'google_container_cluster.main', + mode: 'managed', + type: 'google_container_cluster', + values: { project: 'nitraai', location: 'us-central1-a', name: 'main' }, + }, + { + address: 'google_container_node_pool.pools["general-arm64"]', + mode: 'managed', + type: 'google_container_node_pool', + values: { + project: 'nitraai', + location: 'us-central1-a', + cluster: 'projects/nitraai/locations/us-central1-a/clusters/main', + name: 'general-arm64', + }, + }, + ], + }, + }, + }; + + const result = normalizeTofuState(state, 'tofu/gke-main'); + assert.deepEqual(result.resources.map(({ kind, id }) => ({ kind, id })), [ + { kind: 'ContainerCluster', id: 'us-central1-a/main' }, + { kind: 'ContainerNodePool', id: 'us-central1-a/main/general-arm64' }, + ]); + assert.deepEqual(result.diagnostics, []); +}); + test('runs tofu show against every repeatable --tofu directory', () => { const calls = []; const spawn = (command, args) => { diff --git a/test/gke-gateway-filter.test.mjs b/test/gke-gateway-filter.test.mjs index eacf212..0e16c49 100644 --- a/test/gke-gateway-filter.test.mjs +++ b/test/gke-gateway-filter.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + gkeNodePoolScopedId, isDefaultNetwork, isGkeGatewayManaged, isGkeNodePoolName, @@ -31,6 +32,17 @@ test('recognizes NodePool resource names that need direct GKE verification', () assert.equal(isGkeNodePoolName('projects/nitraai/locations/us-central1-a/clusters/main'), false); }); +test('keeps the location in canonical GKE NodePool IDs', () => { + assert.equal( + gkeNodePoolScopedId('//container.googleapis.com/projects/nitraai/zones/us-central1-a/clusters/main/nodePools/general-arm64'), + 'us-central1-a/main/general-arm64', + ); + assert.equal( + gkeNodePoolScopedId('//container.googleapis.com/projects/nitraai/regions/us-central1/clusters/main/nodePools/general-arm64'), + 'us-central1/main/general-arm64', + ); +}); + test('recognizes the GKE-managed Workload Identity pool', () => { assert.equal(isGkeWorkloadIdentityPool({ name: 'projects/123/locations/global/workloadIdentityPools/nitraai.svc.id.goog' }), true); assert.equal(isGkeWorkloadIdentityPool({ name: 'projects/123/locations/global/workloadIdentityPools/forgejo-pool' }), false);