Merge pull request #30862 from backstage/catalog-provider-gcp-support-creds-json

[Catalog provider gcp] Support google service account creds json
This commit is contained in:
Patrik Oldsberg
2025-08-15 00:27:15 +02:00
committed by GitHub
6 changed files with 599 additions and 3 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog-backend-module-gcp': patch
---
Added support for Google Service account credentials config used in GkeEntityProvider.
Added support for additional metadata `authProvider` and `owner` to be set for the GKE cluster entities.
@@ -2,6 +2,15 @@
This is an extension module to the plugin-catalog-backend plugin, containing catalog processors and providers to ingest GCP resources as `Resource` kind entities.
## Authentication
The GKE Entity Provider supports two authentication methods:
1. **Service Account Credentials** (recommended for production): Provide Google Service Account credentials directly in the configuration
2. **Application Default Credentials**: If no credentials are provided, the provider falls back to:
- `GOOGLE_APPLICATION_CREDENTIALS` environment variable pointing to a service account key file
- Google Cloud SDK default credentials (when running on Google Cloud Platform)
## installation
Register the plugin in `catalog.ts``
@@ -38,4 +47,26 @@ catalog:
frequency: { minutes: 30 }
# supports ISO duration, "human duration" as used in code
timeout: { minutes: 3 }
# Optional: Google Service Account credentials for authentication
# If not provided, falls back to Application Default Credentials or GOOGLE_APPLICATION_CREDENTIALS
googleServiceAccountCredentials: |
{
"type": "service_account",
"project_id": "your-project-id",
"private_key_id": "key-id",
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
"client_email": "your-service-account@your-project.iam.gserviceaccount.com",
"client_id": "client-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/..."
}
# Optional: Authentication provider for Kubernetes clusters
# Defaults to 'google' if not specified
# Common values: 'google', 'googleServiceAccount'
authProvider: googleServiceAccount
# Optional: Owner of the discovered GKE clusters
# Defaults to 'unknown' if not specified
owner: platform-team
```
+16
View File
@@ -38,6 +38,22 @@ export interface Config {
* (Optional) TaskScheduleDefinition for the refresh.
*/
schedule: SchedulerServiceTaskScheduleDefinitionConfig;
/**
* (Optional) Google Service Account credentials for authentication
* JSON string containing the service account key
* @visibility secret
*/
googleServiceAccountCredentials?: string;
/**
* (Optional) Authentication provider to use for Kubernetes clusters
* Defaults to 'google' for backward compatibility
*/
authProvider?: string;
/**
* (Optional) Owner of the discovered clusters
* Defaults to 'unknown' if not specified
*/
owner?: string;
};
};
};
@@ -19,6 +19,13 @@ import { SchedulerServiceTaskRunner } from '@backstage/backend-plugin-api';
import * as container from '@google-cloud/container';
import { ConfigReader } from '@backstage/config';
// Mock the container module
jest.mock('@google-cloud/container', () => ({
v1: {
ClusterManagerClient: jest.fn(),
},
}));
describe('GkeEntityProvider', () => {
const clusterManagerClientMock = {
listClusters: jest.fn(),
@@ -189,6 +196,275 @@ describe('GkeEntityProvider', () => {
},
);
it('should use configured values for authProvider and owner', async () => {
const customGkeEntityProvider = GkeEntityProvider.fromConfigWithClient({
logger: logger as any,
config: new ConfigReader({
catalog: {
providers: {
gcp: {
gke: {
parents: ['projects/parent1/locations/-'],
authProvider: 'googleServiceAccount',
owner: 'sre',
schedule: {
frequency: {
minutes: 3,
},
timeout: {
minutes: 3,
},
},
},
},
},
},
}),
scheduler: schedulerMock,
clusterManagerClient: clusterManagerClientMock as any,
});
clusterManagerClientMock.listClusters.mockImplementation(() => [
{
clusters: [
{
name: 'some-cluster',
endpoint: '127.0.0.1',
location: 'some-location',
selfLink: 'https://127.0.0.1/some-link',
masterAuth: {
clusterCaCertificate: 'abcdefg',
},
},
],
},
]);
await customGkeEntityProvider.connect(connectionMock);
await customGkeEntityProvider.refresh();
expect(connectionMock.applyMutation).toMatchSnapshot();
});
describe('authProvider and owner configuration', () => {
it('should use default values when authProvider and owner not configured', async () => {
const defaultGkeEntityProvider = GkeEntityProvider.fromConfigWithClient({
logger: logger as any,
config: new ConfigReader({
catalog: {
providers: {
gcp: {
gke: {
parents: ['projects/parent1/locations/-'],
schedule: {
frequency: { minutes: 3 },
timeout: { minutes: 3 },
},
},
},
},
},
}),
scheduler: schedulerMock,
clusterManagerClient: clusterManagerClientMock as any,
});
clusterManagerClientMock.listClusters.mockImplementation(() => [
{
clusters: [
{
name: 'default-cluster',
endpoint: '127.0.0.1',
location: 'us-central1',
selfLink: 'https://127.0.0.1/default-link',
masterAuth: {
clusterCaCertificate: 'defaultcert',
},
},
],
},
]);
await defaultGkeEntityProvider.connect(connectionMock);
await defaultGkeEntityProvider.refresh();
expect(connectionMock.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities: [
{
entity: expect.objectContaining({
metadata: expect.objectContaining({
name: 'default-cluster',
annotations: expect.objectContaining({
'kubernetes.io/auth-provider': 'google', // Default value
}),
}),
spec: expect.objectContaining({
owner: 'unknown', // Default value
type: 'kubernetes-cluster',
}),
}),
locationKey: 'gcp-gke:us-central1',
},
],
});
});
it('should use both custom authProvider and owner when configured', async () => {
const fullyCustomGkeEntityProvider =
GkeEntityProvider.fromConfigWithClient({
logger: logger as any,
config: new ConfigReader({
catalog: {
providers: {
gcp: {
gke: {
parents: ['projects/parent1/locations/-'],
authProvider: 'aws',
owner: 'devops-team',
schedule: {
frequency: { minutes: 3 },
timeout: { minutes: 3 },
},
},
},
},
},
}),
scheduler: schedulerMock,
clusterManagerClient: clusterManagerClientMock as any,
});
clusterManagerClientMock.listClusters.mockImplementation(() => [
{
clusters: [
{
name: 'aws-auth-cluster',
endpoint: '127.0.0.1',
location: 'asia-southeast1',
selfLink: 'https://127.0.0.1/aws-link',
masterAuth: {
clusterCaCertificate: 'awscert',
},
},
],
},
]);
await fullyCustomGkeEntityProvider.connect(connectionMock);
await fullyCustomGkeEntityProvider.refresh();
expect(connectionMock.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities: [
{
entity: expect.objectContaining({
metadata: expect.objectContaining({
name: 'aws-auth-cluster',
annotations: expect.objectContaining({
'kubernetes.io/auth-provider': 'aws',
}),
}),
spec: expect.objectContaining({
owner: 'devops-team',
type: 'kubernetes-cluster',
}),
}),
locationKey: 'gcp-gke:asia-southeast1',
},
],
});
});
it('should apply authProvider and owner to multiple clusters', async () => {
const multiClusterGkeEntityProvider =
GkeEntityProvider.fromConfigWithClient({
logger: logger as any,
config: new ConfigReader({
catalog: {
providers: {
gcp: {
gke: {
parents: ['projects/parent1/locations/-'],
authProvider: 'oidc',
owner: 'sre-team',
schedule: {
frequency: { minutes: 3 },
timeout: { minutes: 3 },
},
},
},
},
},
}),
scheduler: schedulerMock,
clusterManagerClient: clusterManagerClientMock as any,
});
clusterManagerClientMock.listClusters.mockImplementation(() => [
{
clusters: [
{
name: 'cluster-1',
endpoint: '127.0.0.1',
location: 'us-central1',
selfLink: 'https://127.0.0.1/cluster1-link',
masterAuth: {
clusterCaCertificate: 'cert1',
},
},
{
name: 'cluster-2',
endpoint: '127.0.0.2',
location: 'us-east1',
selfLink: 'https://127.0.0.2/cluster2-link',
masterAuth: {
clusterCaCertificate: 'cert2',
},
},
],
},
]);
await multiClusterGkeEntityProvider.connect(connectionMock);
await multiClusterGkeEntityProvider.refresh();
expect(connectionMock.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities: [
{
entity: expect.objectContaining({
metadata: expect.objectContaining({
name: 'cluster-1',
annotations: expect.objectContaining({
'kubernetes.io/auth-provider': 'oidc',
}),
}),
spec: expect.objectContaining({
owner: 'sre-team',
type: 'kubernetes-cluster',
}),
}),
locationKey: 'gcp-gke:us-central1',
},
{
entity: expect.objectContaining({
metadata: expect.objectContaining({
name: 'cluster-2',
annotations: expect.objectContaining({
'kubernetes.io/auth-provider': 'oidc',
}),
}),
spec: expect.objectContaining({
owner: 'sre-team',
type: 'kubernetes-cluster',
}),
}),
locationKey: 'gcp-gke:us-east1',
},
],
});
});
});
it('should log GKE API errors', async () => {
clusterManagerClientMock.listClusters.mockRejectedValue(
new Error('some-error'),
@@ -197,4 +473,192 @@ describe('GkeEntityProvider', () => {
expect(connectionMock.applyMutation).toHaveBeenCalledTimes(0);
expect(logger.error).toHaveBeenCalledTimes(1);
});
describe('credentials support', () => {
const MockedClusterManagerClient = container.v1
.ClusterManagerClient as jest.MockedClass<
typeof container.v1.ClusterManagerClient
>;
beforeEach(() => {
jest.resetAllMocks();
MockedClusterManagerClient.mockClear();
schedulerMock.createScheduledTaskRunner.mockReturnValue(taskRunner);
});
it('should use credentials from config when provided', () => {
const mockCredentials = {
type: 'service_account',
project_id: 'test-project',
private_key_id: 'key-id',
private_key:
'-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----\n',
client_email: 'test@test-project.iam.gserviceaccount.com',
client_id: 'client-id',
auth_uri: 'https://accounts.google.com/o/oauth2/auth',
token_uri: 'https://oauth2.googleapis.com/token',
auth_provider_x509_cert_url:
'https://www.googleapis.com/oauth2/v1/certs',
client_x509_cert_url:
'https://www.googleapis.com/robot/v1/metadata/x509/test%40test-project.iam.gserviceaccount.com',
};
GkeEntityProvider.fromConfig({
logger: logger as any,
config: new ConfigReader({
catalog: {
providers: {
gcp: {
gke: {
parents: ['projects/test-project/locations/-'],
schedule: {
frequency: { minutes: 30 },
timeout: { minutes: 3 },
},
googleServiceAccountCredentials:
JSON.stringify(mockCredentials),
},
},
},
},
}),
scheduler: schedulerMock,
});
expect(MockedClusterManagerClient).toHaveBeenCalledWith({
credentials: mockCredentials,
});
});
it('should fall back to default credentials when no credentials provided', () => {
GkeEntityProvider.fromConfig({
logger: logger as any,
config: new ConfigReader({
catalog: {
providers: {
gcp: {
gke: {
parents: ['projects/test-project/locations/-'],
schedule: {
frequency: { minutes: 30 },
timeout: { minutes: 3 },
},
// No googleServiceAccountCredentials provided
},
},
},
},
}),
scheduler: schedulerMock,
});
expect(MockedClusterManagerClient).toHaveBeenCalledWith();
});
it('should throw error for invalid JSON credentials', () => {
expect(() => {
GkeEntityProvider.fromConfig({
logger: logger as any,
config: new ConfigReader({
catalog: {
providers: {
gcp: {
gke: {
parents: ['projects/test-project/locations/-'],
schedule: {
frequency: { minutes: 30 },
timeout: { minutes: 3 },
},
googleServiceAccountCredentials: 'invalid-json',
},
},
},
},
}),
scheduler: schedulerMock,
});
}).toThrow(
'Failed to parse Google Service Account credentials from config:',
);
});
it('should throw error for malformed JSON credentials', () => {
expect(() => {
GkeEntityProvider.fromConfig({
logger: logger as any,
config: new ConfigReader({
catalog: {
providers: {
gcp: {
gke: {
parents: ['projects/test-project/locations/-'],
schedule: {
frequency: { minutes: 30 },
timeout: { minutes: 3 },
},
googleServiceAccountCredentials: '{"incomplete": "json"',
},
},
},
},
}),
scheduler: schedulerMock,
});
}).toThrow(
'Failed to parse Google Service Account credentials from config:',
);
});
it('should handle undefined credentials as fallback to default', () => {
GkeEntityProvider.fromConfig({
logger: logger as any,
config: new ConfigReader({
catalog: {
providers: {
gcp: {
gke: {
parents: ['projects/test-project/locations/-'],
schedule: {
frequency: { minutes: 30 },
timeout: { minutes: 3 },
},
// googleServiceAccountCredentials is undefined
},
},
},
},
}),
scheduler: schedulerMock,
});
expect(MockedClusterManagerClient).toHaveBeenCalledWith();
});
it('should read authProvider and owner from config', () => {
const provider = GkeEntityProvider.fromConfig({
logger: logger as any,
config: new ConfigReader({
catalog: {
providers: {
gcp: {
gke: {
parents: ['projects/test-project/locations/-'],
schedule: {
frequency: { minutes: 30 },
timeout: { minutes: 3 },
},
authProvider: 'googleServiceAccount',
owner: 'platform-team',
},
},
},
},
}),
scheduler: schedulerMock,
});
expect(provider).toBeDefined();
expect(MockedClusterManagerClient).toHaveBeenCalledWith();
});
});
});
@@ -47,6 +47,8 @@ export class GkeEntityProvider implements EntityProvider {
private readonly logger: LoggerService;
private readonly scheduleFn: () => Promise<void>;
private readonly gkeParents: string[];
private readonly gkeAuthProvider: string | undefined;
private readonly gkeOwner: string | undefined;
private readonly clusterManagerClient: container.v1.ClusterManagerClient;
private connection?: EntityProviderConnection;
@@ -54,11 +56,15 @@ export class GkeEntityProvider implements EntityProvider {
logger: LoggerService,
taskRunner: SchedulerServiceTaskRunner,
gkeParents: string[],
gkeAuthProvider: string | undefined,
gkeOwner: string | undefined,
clusterManagerClient: container.v1.ClusterManagerClient,
) {
this.logger = logger;
this.scheduleFn = this.createScheduleFn(taskRunner);
this.gkeParents = gkeParents;
this.gkeAuthProvider = gkeAuthProvider;
this.gkeOwner = gkeOwner;
this.clusterManagerClient = clusterManagerClient;
}
@@ -71,11 +77,37 @@ export class GkeEntityProvider implements EntityProvider {
scheduler: SchedulerService;
config: Config;
}) {
const gkeProviderConfig = config.getConfig('catalog.providers.gcp.gke');
const credentials = gkeProviderConfig.getOptionalString(
'googleServiceAccountCredentials',
);
let clusterManagerClient: container.v1.ClusterManagerClient;
if (credentials && credentials.trim()) {
// Use credentials from config
try {
const credentialsObject = JSON.parse(credentials);
clusterManagerClient = new container.v1.ClusterManagerClient({
credentials: credentialsObject,
});
} catch (error) {
throw new Error(
`Failed to parse Google Service Account credentials from config: ${
error instanceof Error ? error.message : 'Invalid JSON'
}`,
);
}
} else {
// Fall back to Application Default Credentials or GOOGLE_APPLICATION_CREDENTIALS
clusterManagerClient = new container.v1.ClusterManagerClient();
}
return GkeEntityProvider.fromConfigWithClient({
logger,
scheduler: scheduler,
config,
clusterManagerClient: new container.v1.ClusterManagerClient(),
clusterManagerClient,
});
}
@@ -98,6 +130,8 @@ export class GkeEntityProvider implements EntityProvider {
logger,
scheduler.createScheduledTaskRunner(schedule),
gkeProviderConfig.getStringArray('parents'),
gkeProviderConfig.getOptionalString('authProvider'),
gkeProviderConfig.getOptionalString('owner'),
clusterManagerClient,
);
}
@@ -152,7 +186,8 @@ export class GkeEntityProvider implements EntityProvider {
[ANNOTATION_KUBERNETES_API_SERVER]: `https://${cluster.endpoint}`,
[ANNOTATION_KUBERNETES_API_SERVER_CA]:
cluster.masterAuth?.clusterCaCertificate || '',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]:
this.gkeAuthProvider || 'google',
[ANNOTATION_KUBERNETES_DASHBOARD_APP]: 'gke',
[ANNOTATION_LOCATION]: location,
[ANNOTATION_ORIGIN_LOCATION]: location,
@@ -167,7 +202,7 @@ export class GkeEntityProvider implements EntityProvider {
},
spec: {
type: 'kubernetes-cluster',
owner: 'unknown',
owner: this.gkeOwner || 'unknown',
},
},
};
@@ -67,3 +67,47 @@ exports[`GkeEntityProvider should return clusters as Resources 1`] = `
],
}
`;
exports[`GkeEntityProvider should use configured values for authProvider and owner 1`] = `
[MockFunction] {
"calls": [
[
{
"entities": [
{
"entity": {
"apiVersion": "backstage.io/v1alpha1",
"kind": "Resource",
"metadata": {
"annotations": {
"backstage.io/managed-by-location": "gcp-gke:some-location",
"backstage.io/managed-by-origin-location": "gcp-gke:some-location",
"kubernetes.io/api-server": "https://127.0.0.1",
"kubernetes.io/api-server-certificate-authority": "abcdefg",
"kubernetes.io/auth-provider": "googleServiceAccount",
"kubernetes.io/dashboard-app": "gke",
"kubernetes.io/dashboard-parameters": "{"projectId":"parent1","region":"some-location","clusterName":"some-cluster"}",
},
"name": "some-cluster",
"namespace": "default",
},
"spec": {
"owner": "sre",
"type": "kubernetes-cluster",
},
},
"locationKey": "gcp-gke:some-location",
},
],
"type": "full",
},
],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
}
`;