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:
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user