diff --git a/.changeset/kind-tips-pump.md b/.changeset/kind-tips-pump.md new file mode 100644 index 0000000000..c61ed97774 --- /dev/null +++ b/.changeset/kind-tips-pump.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +--- + +Clusters declared in the app-config can now have their CA configured via a local filesystem path using the `caFile` property. diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 55843f3b24..41d72b2be2 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -31,6 +31,7 @@ kubernetes: dashboardUrl: http://127.0.0.1:64713 # url copied from running the command: minikube service kubernetes-dashboard -n kubernetes-dashboard dashboardApp: standard caData: ${K8S_CONFIG_CA_DATA} + caFile: '' # local path to CA file customResources: - group: 'argoproj.io' apiVersion: 'v1alpha1' @@ -248,8 +249,8 @@ kubernetes: ##### `clusters.\*.caData` (optional) Base64-encoded certificate authority bundle in PEM format. The Kubernetes client -will verify that TLS certificate presented by the API server is signed by this -CA. +will verify that the TLS certificate presented by the API server is signed by +this CA. This value could be obtained via inspecting the kubeconfig file (usually at `~/.kube/config`) under `clusters[*].cluster.certificate-authority-data`. For @@ -265,6 +266,14 @@ See also https://cloud.google.com/kubernetes-engine/docs/how-to/api-server-authentication#environments-without-gcloud for complete docs about GKE without `gcloud`. +##### `clusters.\*.caFile` (optional) + +Filesystem path (on the host where the Backstage process is running) to a +certificate authority bundle in PEM format. The Kubernetes client will verify +that the TLS certificate presented by the API server is signed by this CA. Note +that only clusters defined in the app-config via the [`config`](#config) +cluster locator method can be configured in this way. + ##### `clusters.\*.customResources` (optional) Configures which [custom resources][3] to look for when returning an entity's diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index b88b3f2a02..2161df7c9a 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -78,6 +78,8 @@ export interface ClusterDetails { authProvider: string; // (undocumented) caData?: string | undefined; + // (undocumented) + caFile?: string | undefined; customResources?: CustomResourceMatcher[]; dashboardApp?: string; dashboardParameters?: JsonObject; diff --git a/plugins/kubernetes-backend/config.d.ts b/plugins/kubernetes-backend/config.d.ts index 2a514394d8..cc6c8bcd46 100644 --- a/plugins/kubernetes-backend/config.d.ts +++ b/plugins/kubernetes-backend/config.d.ts @@ -54,6 +54,10 @@ export interface Config { skipTLSVerify?: boolean; /** @visibility frontend */ skipMetricsLookup?: boolean; + /** @visibility secret */ + caData?: string; + /** @visibility secret */ + caFile?: string; }>; } | { diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 5d88c9ca13..895340bd5d 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -70,6 +70,7 @@ "@types/aws4": "^1.5.1", "@types/http-proxy-middleware": "^0.19.3", "aws-sdk-mock": "^5.2.1", + "mock-fs": "^5.2.0", "msw": "^0.49.0", "supertest": "^6.1.3" }, diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index 6ece380f28..5d8fefc5d2 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -55,6 +55,7 @@ describe('ConfigClusterLocator', () => { skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, + caFile: undefined, }, ]); }); @@ -95,6 +96,7 @@ describe('ConfigClusterLocator', () => { skipTLSVerify: false, skipMetricsLookup: true, caData: undefined, + caFile: undefined, }, { name: 'cluster2', @@ -104,6 +106,7 @@ describe('ConfigClusterLocator', () => { skipTLSVerify: true, skipMetricsLookup: false, caData: undefined, + caFile: undefined, }, ]); }); @@ -151,6 +154,7 @@ describe('ConfigClusterLocator', () => { skipTLSVerify: false, skipMetricsLookup: false, caData: undefined, + caFile: undefined, }, { assumeRole: 'SomeRole', @@ -162,6 +166,7 @@ describe('ConfigClusterLocator', () => { skipTLSVerify: true, skipMetricsLookup: false, caData: undefined, + caFile: undefined, }, { assumeRole: 'SomeRole', @@ -173,6 +178,7 @@ describe('ConfigClusterLocator', () => { skipTLSVerify: true, skipMetricsLookup: false, caData: undefined, + caFile: undefined, }, ]); }); @@ -207,6 +213,7 @@ describe('ConfigClusterLocator', () => { skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, + caFile: undefined, dashboardApp: 'gke', dashboardParameters: { projectId: 'some-project', @@ -243,6 +250,7 @@ describe('ConfigClusterLocator', () => { skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, + caFile: undefined, dashboardApp: 'standard', dashboardUrl: 'http://someurl', }, diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index de5cac6d8d..b0143c2010 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -37,6 +37,7 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { skipTLSVerify: c.getOptionalBoolean('skipTLSVerify') ?? false, skipMetricsLookup: c.getOptionalBoolean('skipMetricsLookup') ?? false, caData: c.getOptionalString('caData'), + caFile: c.getOptionalString('caFile'), authProvider: authProvider, }; const dashboardUrl = c.getOptionalString('dashboardUrl'); diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index a43e475d8e..6847b0e0a3 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -60,6 +60,7 @@ describe('getCombinedClusterSupplier', () => { skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, + caFile: undefined, }, { name: 'cluster2', @@ -69,6 +70,7 @@ describe('getCombinedClusterSupplier', () => { skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, + caFile: undefined, }, ]); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts index e813a63f0d..0f73d129b3 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts @@ -16,26 +16,29 @@ import '@backstage/backend-common'; import { KubernetesClientProvider } from './KubernetesClientProvider'; +import { ClusterDetails } from '../types/types'; +import * as https from 'https'; +import mockFs from 'mock-fs'; describe('KubernetesClientProvider', () => { beforeEach(() => { jest.resetAllMocks(); }); + afterEach(() => { + mockFs.restore(); + }); - it('can get core client by cluster details', async () => { + it('can get core client by cluster details', () => { const sut = new KubernetesClientProvider(); + const getKubeConfig = jest.spyOn(sut, 'getKubeConfig'); - const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({})); - - sut.getKubeConfig = mockGetKubeConfig; - - const result = sut.getCoreClientByClusterDetails({ + const clusterDetails: ClusterDetails = { name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', authProvider: 'serviceAccount', - skipTLSVerify: false, - }); + }; + const result = sut.getCoreClientByClusterDetails(clusterDetails); expect(result.basePath).toBe('http://localhost:9999'); // These fields aren't on the type but are there @@ -44,23 +47,21 @@ describe('KubernetesClientProvider', () => { expect(auth.clusters[0].name).toBe('cluster-name'); expect(auth.clusters[0].skipTLSVerify).toBe(false); - expect(mockGetKubeConfig.mock.calls.length).toBe(1); + expect(getKubeConfig).toHaveBeenCalledTimes(1); }); - it('can get custom objects client by cluster details', async () => { + it('can get custom objects client by cluster details', () => { const sut = new KubernetesClientProvider(); + const getKubeConfig = jest.spyOn(sut, 'getKubeConfig'); - const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({})); - - sut.getKubeConfig = mockGetKubeConfig; - - const result = sut.getCustomObjectsClient({ + const clusterDetails: ClusterDetails = { name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', authProvider: 'serviceAccount', skipTLSVerify: false, - }); + }; + const result = sut.getCustomObjectsClient(clusterDetails); expect(result.basePath).toBe('http://localhost:9999'); // These fields aren't on the type but are there @@ -68,6 +69,27 @@ describe('KubernetesClientProvider', () => { expect(auth.users[0].token).toBe('TOKEN'); expect(auth.clusters[0].name).toBe('cluster-name'); - expect(mockGetKubeConfig.mock.calls.length).toBe(1); + expect(getKubeConfig).toHaveBeenCalledTimes(1); + }); + + it('respects caFile', async () => { + mockFs({ + '/path/to/ca.crt': 'my-ca', + }); + const clusterDetails: ClusterDetails = { + name: 'cluster-name', + url: 'https://localhost:9999', + authProvider: 'serviceAccount', + serviceAccountToken: 'TOKEN', + caFile: '/path/to/ca.crt', + }; + const kubeConfig = new KubernetesClientProvider().getKubeConfig( + clusterDetails, + ); + + const options: https.RequestOptions = {}; + await kubeConfig.applytoHTTPSOptions(options); + + expect(options.ca?.toString()).toEqual('my-ca'); }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts index 522925beb5..14c8e422fc 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts @@ -15,10 +15,13 @@ */ import { + Cluster, + Context, CoreV1Api, + CustomObjectsApi, KubeConfig, Metrics, - CustomObjectsApi, + User, } from '@kubernetes/client-node'; import { ClusterDetails } from '../types/types'; @@ -28,27 +31,28 @@ import { ClusterDetails } from '../types/types'; */ export class KubernetesClientProvider { // visible for testing - getKubeConfig(clusterDetails: ClusterDetails) { - const cluster = { + getKubeConfig(clusterDetails: ClusterDetails): KubeConfig { + const cluster: Cluster = { name: clusterDetails.name, server: clusterDetails.url, - skipTLSVerify: clusterDetails.skipTLSVerify, + skipTLSVerify: clusterDetails.skipTLSVerify || false, caData: clusterDetails.caData, + caFile: clusterDetails.caFile, }; // TODO configure - const user = { + const user: User = { name: 'backstage', token: clusterDetails.serviceAccountToken, }; - const context = { + const context: Context = { name: `${clusterDetails.name}`, user: user.name, cluster: cluster.name, }; - const kc = new KubeConfig(); + const kc: KubeConfig = new KubeConfig(); if (clusterDetails.serviceAccountToken) { kc.loadFromOptions({ clusters: [cluster], @@ -63,19 +67,19 @@ export class KubernetesClientProvider { return kc; } - getCoreClientByClusterDetails(clusterDetails: ClusterDetails) { + getCoreClientByClusterDetails(clusterDetails: ClusterDetails): CoreV1Api { const kc = this.getKubeConfig(clusterDetails); return kc.makeApiClient(CoreV1Api); } - getMetricsClient(clusterDetails: ClusterDetails) { + getMetricsClient(clusterDetails: ClusterDetails): Metrics { const kc = this.getKubeConfig(clusterDetails); return new Metrics(kc); } - getCustomObjectsClient(clusterDetails: ClusterDetails) { + getCustomObjectsClient(clusterDetails: ClusterDetails): CustomObjectsApi { const kc = this.getKubeConfig(clusterDetails); return kc.makeApiClient(CustomObjectsApi); diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index ed43935202..d76bfc8149 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -167,6 +167,7 @@ export interface ClusterDetails { */ skipMetricsLookup?: boolean; caData?: string | undefined; + caFile?: string | undefined; /** * Specifies the link to the Kubernetes dashboard managing this cluster. * @remarks diff --git a/yarn.lock b/yarn.lock index ef0f0deb4f..7f8e02f3a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6725,6 +6725,7 @@ __metadata: http-proxy-middleware: ^2.0.6 lodash: ^4.17.21 luxon: ^3.0.0 + mock-fs: ^5.2.0 morgan: ^1.10.0 msw: ^0.49.0 node-fetch: ^2.6.7 @@ -29058,7 +29059,7 @@ __metadata: languageName: node linkType: hard -"mock-fs@npm:^5.1.0, mock-fs@npm:^5.1.1": +"mock-fs@npm:^5.1.0, mock-fs@npm:^5.1.1, mock-fs@npm:^5.2.0": version: 5.2.0 resolution: "mock-fs@npm:5.2.0" checksum: c25835247bd26fa4e0189addd61f98973f61a72741e4d2a5694b143a2069b84978443a7ac0fdb1a71aead99273ec22ff4e9c968de11bbd076db020264c5b8312