diff --git a/.changeset/fuzzy-pots-worry.md b/.changeset/fuzzy-pots-worry.md new file mode 100644 index 0000000000..f84531b502 --- /dev/null +++ b/.changeset/fuzzy-pots-worry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +--- + +K8s proxy HEADER_KUBERNETES_CLUSTER is now optional in single-cluster setups. diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts index 4ed1c8a7ae..aaf3843e61 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -104,6 +104,56 @@ describe('KubernetesProxy', () => { ).rejects.toThrow(NotFoundError); }); + it('should return a ERROR_NOT_FOUND if multi-cluster & no cluster selected', async () => { + clusterSupplier.getClusters.mockResolvedValue([ + { + name: 'local', + url: 'http:/localhost:8001', + authProvider: 'localKubectlProxy', + skipMetricsLookup: true, + } as ClusterDetails, + { + name: 'cluster1', + url: 'https://localhost:9999', + serviceAccountToken: 'tokenA', + authProvider: 'googleServiceAccount', + } as ClusterDetails, + ]); + + permissionApi.authorize.mockReturnValue( + Promise.resolve([{ result: AuthorizeResult.ALLOW }]), + ); + + const req = buildMockRequest(undefined, 'api'); + const { res, next } = getMockRes(); + + await expect( + proxy.createRequestHandler({ permissionApi })(req, res, next), + ).rejects.toThrow(NotFoundError); + }); + + it('should return a ERROR_NOT_FOUND if selected cluster not in config', async () => { + clusterSupplier.getClusters.mockResolvedValue([ + { + name: 'cluster1', + url: 'https://localhost:9999', + serviceAccountToken: 'tokenA', + authProvider: 'googleServiceAccount', + } as ClusterDetails, + ]); + + permissionApi.authorize.mockReturnValue( + Promise.resolve([{ result: AuthorizeResult.ALLOW }]), + ); + + const req = buildMockRequest('test', 'api'); + const { res, next } = getMockRes(); + + await expect( + proxy.createRequestHandler({ permissionApi })(req, res, next), + ).rejects.toThrow(NotFoundError); + }); + it('should pass the exact response from Kubernetes', async () => { const apiResponse = { kind: 'APIVersions', @@ -155,6 +205,55 @@ describe('KubernetesProxy', () => { expect(response.body).toStrictEqual(apiResponse); }); + it('should pass the exact response from Kubernetes default cluster & no cluster selected in single cluster setup', async () => { + const apiResponse = { + kind: 'APIVersions', + versions: ['v1'], + serverAddressByClientCIDRs: [ + { + clientCIDR: '0.0.0.0/0', + serverAddress: '192.168.0.1:3333', + }, + ], + }; + + clusterSupplier.getClusters.mockResolvedValue([ + { + name: 'cluster1', + url: 'https://localhost:9999', + serviceAccountToken: '', + authProvider: 'serviceAccount', + }, + ] as ClusterDetails[]); + + permissionApi.authorize.mockReturnValue( + Promise.resolve([{ result: AuthorizeResult.ALLOW }]), + ); + + authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({ + name: 'cluster1', + url: 'https://localhost:9999', + serviceAccountToken: '', + authProvider: 'serviceAccount', + } as ClusterDetails); + + const router = Router(); + router.use('/mountpath', proxy.createRequestHandler({ permissionApi })); + const app = express().use(router); + const requestPromise = request(app).get('/mountpath/api'); + worker.use( + rest.get('https://localhost:9999/api', (_: any, res: any, ctx: any) => + res(ctx.status(299), ctx.json(apiResponse)), + ), + rest.all(requestPromise.url, (req: any) => req.passthrough()), + ); + + const response = await requestPromise; + + expect(response.status).toEqual(299); + expect(response.body).toStrictEqual(apiResponse); + }); + it('sets host header to support clusters behind name-based virtual hosts', async () => { worker.use( rest.get( @@ -481,55 +580,6 @@ describe('KubernetesProxy', () => { }); }); - it('should return a 400 error if Backstage-Kubernetes-Cluster field isnt provided in request', async () => { - worker.use( - rest.get('https://localhost:9999/api/v1/namespaces', (_req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - kind: 'NamespaceList', - apiVersion: 'v1', - items: [], - }), - ); - }), - ); - - permissionApi.authorize.mockReturnValue( - Promise.resolve([{ result: AuthorizeResult.ALLOW }]), - ); - - clusterSupplier.getClusters.mockResolvedValue([ - { - name: 'cluster1', - url: 'https://localhost:9999', - authProvider: 'googleServiceAccount', - }, - ] as ClusterDetails[]); - - authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({ - name: 'cluster1', - url: 'https://localhost:9999', - serviceAccountToken: 'tokenA', - authProvider: 'googleServiceAccount', - } as ClusterDetails); - - const router = Router(); - router.use('/mountpath', proxy.createRequestHandler({ permissionApi })); - router.use(errorHandler()); - const app = express().use(router); - - const requestPromise = request(app) - .get('/mountpath/api/v1/namespaces') - .set(HEADER_KUBERNETES_AUTH, 'tokenB'); - - worker.use(rest.all(requestPromise.url, (req: any) => req.passthrough())); - - const response = await requestPromise; - - expect(response.status).toEqual(400); - }); - it('returns a 500 error if authTranslator errors out and Backstage-Kubernetes-Authorization field is not provided', async () => { worker.use( rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => { diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index b4ca87d6f0..8e8fd54c77 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -17,7 +17,6 @@ import { ErrorResponseBody, ForwardedError, - InputError, NotAllowedError, NotFoundError, serializeError, @@ -175,13 +174,23 @@ export class KubernetesProxy { private async getClusterForRequest(req: Request): Promise { const clusterName = req.header(HEADER_KUBERNETES_CLUSTER); - if (!clusterName) { - throw new InputError(`Missing '${HEADER_KUBERNETES_CLUSTER}' header.`); + const clusters = await this.clusterSupplier.getClusters(); + + if (!clusters || clusters.length <= 0) { + throw new NotFoundError(`No Clusters configured`); + } + + const hasClusterNameHeader = + typeof clusterName === 'string' && clusterName.length > 0; + + let cluster: ClusterDetails | undefined; + + if (hasClusterNameHeader) { + cluster = clusters.find(c => c.name === clusterName); + } else if (clusters.length === 1) { + cluster = clusters.at(0); } - const cluster = await this.clusterSupplier - .getClusters() - .then(clusters => clusters.find(c => c.name === clusterName)); if (!cluster) { throw new NotFoundError(`Cluster '${clusterName}' not found`); }