Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions lib/controller-inventory.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
10 changes: 7 additions & 3 deletions lib/get-resources.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]}`;
Expand Down Expand Up @@ -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;
Expand All @@ -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');
Expand Down
35 changes: 35 additions & 0 deletions test/controller-inventory.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
12 changes: 12 additions & 0 deletions test/gke-gateway-filter.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
gkeNodePoolScopedId,
isDefaultNetwork,
isGkeGatewayManaged,
isGkeNodePoolName,
Expand Down Expand Up @@ -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);
Expand Down
Loading