diff --git a/.changeset/fluffy-bananas-shake.md b/.changeset/fluffy-bananas-shake.md new file mode 100644 index 0000000000..f7723ded60 --- /dev/null +++ b/.changeset/fluffy-bananas-shake.md @@ -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. diff --git a/plugins/catalog-backend-module-gcp/README.md b/plugins/catalog-backend-module-gcp/README.md index d1ef926310..0f9ed67576 100644 --- a/plugins/catalog-backend-module-gcp/README.md +++ b/plugins/catalog-backend-module-gcp/README.md @@ -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 ``` diff --git a/plugins/catalog-backend-module-gcp/config.d.ts b/plugins/catalog-backend-module-gcp/config.d.ts index dd657420f7..4cf1815980 100644 --- a/plugins/catalog-backend-module-gcp/config.d.ts +++ b/plugins/catalog-backend-module-gcp/config.d.ts @@ -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; }; }; }; diff --git a/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.test.ts b/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.test.ts index b9eb4cf2aa..90b0f81352 100644 --- a/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.test.ts +++ b/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.test.ts @@ -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(); + }); + }); }); diff --git a/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.ts b/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.ts index 13bf245f4e..bde0c7fc12 100644 --- a/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.ts +++ b/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.ts @@ -47,6 +47,8 @@ export class GkeEntityProvider implements EntityProvider { private readonly logger: LoggerService; private readonly scheduleFn: () => Promise; 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', }, }, }; diff --git a/plugins/catalog-backend-module-gcp/src/providers/__snapshots__/GkeEntityProvider.test.ts.snap b/plugins/catalog-backend-module-gcp/src/providers/__snapshots__/GkeEntityProvider.test.ts.snap index d20efb9c8d..c903bcd192 100644 --- a/plugins/catalog-backend-module-gcp/src/providers/__snapshots__/GkeEntityProvider.test.ts.snap +++ b/plugins/catalog-backend-module-gcp/src/providers/__snapshots__/GkeEntityProvider.test.ts.snap @@ -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, + }, + ], +} +`;