diff --git a/.changeset/gke-dns-endpoint-support.md b/.changeset/gke-dns-endpoint-support.md new file mode 100644 index 0000000000..54361c5115 --- /dev/null +++ b/.changeset/gke-dns-endpoint-support.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +--- + +Added `endpointType` config option to the GKE cluster locator, allowing use of DNS-based control plane endpoints instead of public IP endpoints. Set `endpointType: 'dns'` to use GKE DNS endpoints (e.g. `gke-..gke.goog`) which provide proper TLS certificates and IAM-based access control. diff --git a/plugins/kubernetes-backend/config.d.ts b/plugins/kubernetes-backend/config.d.ts index 74b2f57a31..7ab482822e 100644 --- a/plugins/kubernetes-backend/config.d.ts +++ b/plugins/kubernetes-backend/config.d.ts @@ -90,6 +90,13 @@ export interface Config { skipTLSVerify?: boolean; /** @visibility frontend */ skipMetricsLookup?: boolean; + /** + * The type of endpoint to use for connecting to the cluster. + * 'public' uses the public IP endpoint (default). + * 'dns' uses the DNS-based control plane endpoint. + * @visibility frontend + */ + endpointType?: 'public' | 'dns'; } >; customResources?: Array<{ diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts index 2b63814f62..b976532a47 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts @@ -17,6 +17,7 @@ import { ANNOTATION_KUBERNETES_AUTH_PROVIDER } from '@backstage/plugin-kubernetes-common'; import { ConfigReader, Config } from '@backstage/config'; import { GkeClusterLocator } from './GkeClusterLocator'; +import { mockServices } from '@backstage/backend-test-utils'; import * as container from '@google-cloud/container'; const mockedListClusters = jest.fn(); @@ -31,8 +32,11 @@ jest.mock('@google-cloud/container', () => { }); describe('GkeClusterLocator', () => { + const logger = mockServices.logger.mock(); + beforeEach(() => { mockedListClusters.mockRestore(); + jest.clearAllMocks(); }); describe('config-parsing', () => { it('should accept missing region', async () => { @@ -41,9 +45,13 @@ describe('GkeClusterLocator', () => { projectId: 'some-project', }); - GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); expect(mockedListClusters).toHaveBeenCalledTimes(0); }); @@ -53,13 +61,34 @@ describe('GkeClusterLocator', () => { }); expect(() => - GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any), + GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ), ).toThrow("Missing required config value at 'projectId'"); expect(mockedListClusters).toHaveBeenCalledTimes(0); }); + it('should reject invalid endpointType', async () => { + const config: Config = new ConfigReader({ + type: 'gke', + projectId: 'some-project', + endpointType: 'invalid', + }); + + expect(() => + GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ), + ).toThrow("Invalid endpointType 'invalid', must be one of: public, dns"); + }); }); describe('listClusters', () => { it('empty clusters returns empty cluster details', async () => { @@ -75,9 +104,13 @@ describe('GkeClusterLocator', () => { region: 'some-region', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -106,9 +139,13 @@ describe('GkeClusterLocator', () => { skipMetricsLookup: true, }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -143,9 +180,13 @@ describe('GkeClusterLocator', () => { projectId: 'some-project', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -185,9 +226,13 @@ describe('GkeClusterLocator', () => { region: 'some-region', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -240,9 +285,13 @@ describe('GkeClusterLocator', () => { region: 'some-region', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -301,9 +350,13 @@ describe('GkeClusterLocator', () => { ], }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -332,9 +385,13 @@ describe('GkeClusterLocator', () => { region: 'some-region', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); await expect(sut.getClusters()).rejects.toThrow( 'There was an error retrieving clusters from GKE for projectId=some-project region=some-region; caused by Error: some error', @@ -365,9 +422,13 @@ describe('GkeClusterLocator', () => { exposeDashboard: true, }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -408,9 +469,13 @@ describe('GkeClusterLocator', () => { projectId: 'some-project', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -442,9 +507,13 @@ describe('GkeClusterLocator', () => { authProvider: 'googleServiceAccount', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -478,9 +547,13 @@ describe('GkeClusterLocator', () => { authProvider: 'differentValue', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -494,13 +567,99 @@ describe('GkeClusterLocator', () => { }, ]); }); + it('uses DNS endpoint when endpointType is dns', async () => { + mockedListClusters.mockReturnValueOnce([ + { + clusters: [ + { + name: 'some-cluster', + endpoint: '1.2.3.4', + controlPlaneEndpointsConfig: { + dnsEndpointConfig: { + endpoint: 'gke-abc123.us-central1.gke.goog', + }, + }, + }, + ], + }, + ]); + + const config: Config = new ConfigReader({ + type: 'gke', + projectId: 'some-project', + region: 'some-region', + endpointType: 'dns', + }); + + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); + + const result = await sut.getClusters(); + + expect(result).toStrictEqual([ + { + name: 'some-cluster', + url: 'https://gke-abc123.us-central1.gke.goog', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, + skipTLSVerify: false, + skipMetricsLookup: false, + }, + ]); + }); + it('falls back to public IP with warning when endpointType is dns but no DNS endpoint available', async () => { + mockedListClusters.mockReturnValueOnce([ + { + clusters: [ + { + name: 'some-cluster', + endpoint: '1.2.3.4', + }, + ], + }, + ]); + + const config: Config = new ConfigReader({ + type: 'gke', + projectId: 'some-project', + region: 'some-region', + endpointType: 'dns', + }); + + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); + + const result = await sut.getClusters(); + + expect(result).toStrictEqual([ + { + name: 'some-cluster', + url: 'https://1.2.3.4', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, + skipTLSVerify: false, + skipMetricsLookup: false, + }, + ]); + expect(logger.info).toHaveBeenCalledWith( + "Cluster 'some-cluster' has endpointType 'dns' configured but no DNS endpoint available, falling back to public IP", + ); + }); it('constructs ClusterManagerClient with identifying metadata', async () => { const configs: Config = new ConfigReader({ type: 'gke', projectId: 'some-project', }); - GkeClusterLocator.fromConfig(configs); + GkeClusterLocator.fromConfig(configs, logger); expect(container.v1.ClusterManagerClient).toHaveBeenCalledWith({ libName: 'backstage/kubernetes-backend.GkeClusterLocator', diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 2022d5f14a..844b1f8e82 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -24,6 +24,7 @@ import { ClusterDetails, KubernetesClustersSupplier, } from '@backstage/plugin-kubernetes-node'; +import { LoggerService } from '@backstage/backend-plugin-api'; import packageinfo from '../../package.json'; interface MatchResourceLabelEntry { @@ -39,22 +40,28 @@ type GkeClusterLocatorOptions = { skipMetricsLookup?: boolean; exposeDashboard?: boolean; matchingResourceLabels?: MatchResourceLabelEntry[]; + endpointType?: 'public' | 'dns'; }; +const VALID_ENDPOINT_TYPES = ['public', 'dns'] as const; + export class GkeClusterLocator implements KubernetesClustersSupplier { private readonly options: GkeClusterLocatorOptions; private readonly client: container.v1.ClusterManagerClient; + private readonly logger: LoggerService; private clusterDetails: ClusterDetails[] | undefined; private hasClusterDetails: boolean; constructor( options: GkeClusterLocatorOptions, client: container.v1.ClusterManagerClient, + logger: LoggerService, clusterDetails: ClusterDetails[] | undefined = undefined, hasClusterDetails: boolean = false, ) { this.options = options; this.client = client; + this.logger = logger; this.clusterDetails = clusterDetails; this.hasClusterDetails = hasClusterDetails; } @@ -62,6 +69,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { static fromConfigWithClient( config: Config, client: container.v1.ClusterManagerClient, + logger: LoggerService, refreshInterval?: Duration, ): GkeClusterLocator { const matchingResourceLabels: MatchResourceLabelEntry[] = @@ -83,8 +91,9 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { config.getOptionalBoolean('skipMetricsLookup') ?? false, exposeDashboard: config.getOptionalBoolean('exposeDashboard') ?? false, matchingResourceLabels, + endpointType: parseEndpointType(config.getOptionalString('endpointType')), }; - const gkeClusterLocator = new GkeClusterLocator(options, client); + const gkeClusterLocator = new GkeClusterLocator(options, client, logger); if (refreshInterval) { runPeriodically( () => gkeClusterLocator.refreshClusters(), @@ -97,6 +106,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { // Added an `x-goog-api-client` header to API requests made by the GKE cluster locator to clearly identify API requests from this plugin. static fromConfig( config: Config, + logger: LoggerService, refreshInterval: Duration | undefined = undefined, ): GkeClusterLocator { return GkeClusterLocator.fromConfigWithClient( @@ -105,6 +115,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { libName: `backstage/kubernetes-backend.GkeClusterLocator`, libVersion: packageinfo.version, }), + logger, refreshInterval, ); } @@ -117,6 +128,24 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { return this.clusterDetails ?? []; } + private getClusterUrl( + cluster: container.protos.google.container.v1.ICluster, + ): string { + if (this.options.endpointType === 'dns') { + const dnsEndpoint = + cluster.controlPlaneEndpointsConfig?.dnsEndpointConfig?.endpoint; + if (dnsEndpoint) { + return `https://${dnsEndpoint}`; + } + this.logger.info( + `Cluster '${ + cluster.name ?? 'unknown' + }' has endpointType 'dns' configured but no DNS endpoint available, falling back to public IP`, + ); + } + return `https://${cluster.endpoint ?? ''}`; + } + // TODO pass caData into the object async refreshClusters(): Promise { const { @@ -146,7 +175,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { .map(r => ({ // TODO filter out clusters which don't have name or endpoint name: r.name ?? 'unknown', - url: `https://${r.endpoint ?? ''}`, + url: this.getClusterUrl(r), authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: authProvider }, skipTLSVerify, skipMetricsLookup, @@ -170,3 +199,25 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { } } } + +function isValidEndpointType( + value: string, +): value is (typeof VALID_ENDPOINT_TYPES)[number] { + return VALID_ENDPOINT_TYPES.includes( + value as (typeof VALID_ENDPOINT_TYPES)[number], + ); +} + +function parseEndpointType(value: string | undefined): 'public' | 'dns' { + if (value === undefined) { + return 'public'; + } + if (isValidEndpointType(value)) { + return value; + } + throw new Error( + `Invalid endpointType '${value}', must be one of: ${VALID_ENDPOINT_TYPES.join( + ', ', + )}`, + ); +} diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.ts b/plugins/kubernetes-backend/src/cluster-locator/index.ts index c18ff035e7..8437c81f45 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.ts @@ -101,6 +101,7 @@ export const getCombinedClusterSupplier = ( case 'gke': return GkeClusterLocator.fromConfig( clusterLocatorMethod, + logger, refreshInterval, ); default: