feat(kubernetes): add DNS endpoint support to GKE cluster locator (#33297)

* feat(kubernetes): add DNS endpoint support to GKE cluster locator

Add endpointType config option to GkeClusterLocator allowing use of
DNS-based control plane endpoints instead of public IP endpoints.
When set to 'dns', the locator uses the cluster's DNS endpoint
(e.g. gke-<uid>.<region>.gke.goog) with fallback to public IP if
no DNS endpoint is available.

Signed-off-by: Rickard Dybeck <dybeck@spotify.com>

* fix: fix relevant AI review comments

Signed-off-by: Rickard Dybeck <dybeck@spotify.com>

---------

Signed-off-by: Rickard Dybeck <dybeck@spotify.com>
This commit is contained in:
Rickard Dybeck
2026-03-11 12:09:52 -04:00
committed by GitHub
parent 57fee86920
commit 6b6b5de514
5 changed files with 265 additions and 42 deletions
+5
View File
@@ -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-<uid>.<region>.gke.goog`) which provide proper TLS certificates and IAM-based access control.
+7
View File
@@ -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<{
@@ -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',
@@ -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<void> {
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(
', ',
)}`,
);
}
@@ -101,6 +101,7 @@ export const getCombinedClusterSupplier = (
case 'gke':
return GkeClusterLocator.fromConfig(
clusterLocatorMethod,
logger,
refreshInterval,
);
default: