From 68b442c1723b26d5d2af23b0e4586ef8d97d40fe Mon Sep 17 00:00:00 2001 From: Guilherme Oenning Date: Wed, 4 May 2022 12:23:51 +0100 Subject: [PATCH 01/15] add azure auth provider Signed-off-by: Guilherme Oenning --- .changeset/big-teachers-dress.md | 6 +++ docs/features/kubernetes/configuration.md | 3 +- plugins/kubernetes-backend/api-report.md | 5 +++ plugins/kubernetes-backend/package.json | 1 + .../cluster-locator/ConfigClusterLocator.ts | 3 ++ .../AzureIdentityKubernetesAuthTranslator.ts | 41 +++++++++++++++++++ .../KubernetesAuthTranslatorGenerator.ts | 4 ++ plugins/kubernetes-backend/src/types/types.ts | 1 + .../AzureKubernetesAuthProvider.ts | 27 ++++++++++++ .../KubernetesAuthProviders.ts | 2 + 10 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 .changeset/big-teachers-dress.md create mode 100644 plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts create mode 100644 plugins/kubernetes/src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts diff --git a/.changeset/big-teachers-dress.md b/.changeset/big-teachers-dress.md new file mode 100644 index 0000000000..4545939c0a --- /dev/null +++ b/.changeset/big-teachers-dress.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-kubernetes-backend': patch +--- + +add Azure Identity auth provider diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index cb30451c7d..d880aaf6cd 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -90,7 +90,8 @@ cluster. Valid values are: | `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. | | `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. | | `aws` | This will use AWS credentials to access resources in EKS clusters | -| `googleServiceAccount` | This will use the Google Cloud service account credentials to access resources in clusters | +| `googleServiceAccount` | This will use the Google Cloud service account credentials to access resources in clusters +| `azure` | This will use [Azure Identity](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) to access resources in clusters | ##### `clusters.\*.skipTLSVerify` diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 9eca295f96..03682807e2 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -24,6 +24,11 @@ export interface AWSClusterDetails extends ClusterDetails { externalId?: string; } +// Warning: (ae-missing-release-tag) "AzureClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface AzureClusterDetails extends ClusterDetails {} + // Warning: (ae-missing-release-tag) "ClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 125f03322e..4d4cd8dfd0 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -35,6 +35,7 @@ "clean": "backstage-cli package clean" }, "dependencies": { + "@azure/identity": "^2.0.4", "@backstage/backend-common": "^0.13.3-next.0", "@backstage/catalog-model": "^1.0.1", "@backstage/config": "^1.0.0", diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 1bde1226dd..5598740873 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -61,6 +61,9 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { return { assumeRole, externalId, ...clusterDetails }; } + case 'azure': { + return clusterDetails; + } case 'serviceAccount': { return clusterDetails; } diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts new file mode 100644 index 0000000000..ab6469b4e4 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { KubernetesAuthTranslator } from './types'; +import { AzureClusterDetails } from '../types/types'; +import { DefaultAzureCredential } from '@azure/identity'; + +const aksScope = "6dae42f8-4368-4678-94ff-3960e28e3630/.default" // This scope is the same for all Azure Managed Kubernetes + +export class AzureIdentityKubernetesAuthTranslator + implements KubernetesAuthTranslator +{ + async decorateClusterDetailsWithAuth( + clusterDetails: AzureClusterDetails, + ): Promise { + const clusterDetailsWithAuthToken: AzureClusterDetails = Object.assign( + {}, + clusterDetails, + ); + + const credentials = new DefaultAzureCredential(); + + // TODO: can we cache this? It's inneficiant to get a new token every time + const accessToken = await credentials.getToken(aksScope); + clusterDetailsWithAuthToken.serviceAccountToken = accessToken.token + return clusterDetailsWithAuthToken; + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts index 337ae899dc..e9a8a00ae9 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts @@ -19,6 +19,7 @@ import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; import { GoogleServiceAccountAuthTranslator } from './GoogleServiceAccountAuthProvider'; +import { AzureIdentityKubernetesAuthTranslator } from './AzureIdentityKubernetesAuthTranslator'; export class KubernetesAuthTranslatorGenerator { static getKubernetesAuthTranslatorInstance( @@ -31,6 +32,9 @@ export class KubernetesAuthTranslatorGenerator { case 'aws': { return new AwsIamKubernetesAuthTranslator(); } + case 'azure': { + return new AzureIdentityKubernetesAuthTranslator(); + } case 'serviceAccount': { return new ServiceAccountKubernetesAuthTranslator(); } diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 12c4d2fb48..706582f439 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -147,6 +147,7 @@ export interface ClusterDetails { } export interface GKEClusterDetails extends ClusterDetails {} +export interface AzureClusterDetails extends ClusterDetails {} export interface ServiceAccountClusterDetails extends ClusterDetails {} export interface AWSClusterDetails extends ClusterDetails { assumeRole?: string; diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts new file mode 100644 index 0000000000..59dd0321b3 --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { KubernetesAuthProvider } from './types'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; + +export class AzureKubernetesAuthProvider implements KubernetesAuthProvider { + async decorateRequestBodyForAuth( + requestBody: KubernetesRequestBody, + ): Promise { + // No-op, with aws auth, server's Azire credentials are used for access + return requestBody; + } +} diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts index be6712f103..5bccccc43d 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -21,6 +21,7 @@ import { ServiceAccountKubernetesAuthProvider } from './ServiceAccountKubernetes import { AwsKubernetesAuthProvider } from './AwsKubernetesAuthProvider'; import { OAuthApi } from '@backstage/core-plugin-api'; import { GoogleServiceAccountAuthProvider } from './GoogleServiceAccountAuthProvider'; +import { AzureKubernetesAuthProvider } from './AzureKubernetesAuthProvider'; export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { private readonly kubernetesAuthProviderMap: Map< @@ -43,6 +44,7 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { new GoogleServiceAccountAuthProvider(), ); this.kubernetesAuthProviderMap.set('aws', new AwsKubernetesAuthProvider()); + this.kubernetesAuthProviderMap.set('azure', new AzureKubernetesAuthProvider()); } async decorateRequestBodyForAuth( From 8bc3cd1dce565a95e518034e81db6abd14890f49 Mon Sep 17 00:00:00 2001 From: Guilherme Oenning Date: Wed, 4 May 2022 14:32:33 +0100 Subject: [PATCH 02/15] fix prettier-S Signed-off-by: Guilherme Oenning --- docs/features/kubernetes/configuration.md | 4 ++-- .../AzureIdentityKubernetesAuthTranslator.ts | 4 ++-- .../src/kubernetes-auth-provider/KubernetesAuthProviders.ts | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index d880aaf6cd..5b60316fd0 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -90,8 +90,8 @@ cluster. Valid values are: | `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. | | `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. | | `aws` | This will use AWS credentials to access resources in EKS clusters | -| `googleServiceAccount` | This will use the Google Cloud service account credentials to access resources in clusters -| `azure` | This will use [Azure Identity](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) to access resources in clusters | +| `googleServiceAccount` | This will use the Google Cloud service account credentials to access resources in clusters | +| `azure` | This will use [Azure Identity](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) to access resources in clusters | ##### `clusters.\*.skipTLSVerify` diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts index ab6469b4e4..20b519c269 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts @@ -18,7 +18,7 @@ import { KubernetesAuthTranslator } from './types'; import { AzureClusterDetails } from '../types/types'; import { DefaultAzureCredential } from '@azure/identity'; -const aksScope = "6dae42f8-4368-4678-94ff-3960e28e3630/.default" // This scope is the same for all Azure Managed Kubernetes +const aksScope = '6dae42f8-4368-4678-94ff-3960e28e3630/.default'; // This scope is the same for all Azure Managed Kubernetes export class AzureIdentityKubernetesAuthTranslator implements KubernetesAuthTranslator @@ -35,7 +35,7 @@ export class AzureIdentityKubernetesAuthTranslator // TODO: can we cache this? It's inneficiant to get a new token every time const accessToken = await credentials.getToken(aksScope); - clusterDetailsWithAuthToken.serviceAccountToken = accessToken.token + clusterDetailsWithAuthToken.serviceAccountToken = accessToken.token; return clusterDetailsWithAuthToken; } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts index 5bccccc43d..00d5a17f72 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -44,7 +44,10 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { new GoogleServiceAccountAuthProvider(), ); this.kubernetesAuthProviderMap.set('aws', new AwsKubernetesAuthProvider()); - this.kubernetesAuthProviderMap.set('azure', new AzureKubernetesAuthProvider()); + this.kubernetesAuthProviderMap.set( + 'azure', + new AzureKubernetesAuthProvider(), + ); } async decorateRequestBodyForAuth( From ce1e8df8a583e7c1ade76cfa11a45aeec468c29d Mon Sep 17 00:00:00 2001 From: Guilherme Oenning Date: Wed, 4 May 2022 15:08:35 +0100 Subject: [PATCH 03/15] add aks dashboard formatter Signed-off-by: Guilherme Oenning --- .../utils/clusterLinks/formatters/aks.test.ts | 89 ++++++++++++++++++- .../src/utils/clusterLinks/formatters/aks.ts | 37 +++++++- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts index 320c06e54c..6f90ab1f1d 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts @@ -16,10 +16,9 @@ import { aksFormatter } from './aks'; describe('clusterLinks - AKS formatter', () => { - it('should return an url on the workloads when there is a namespace only', () => { + it('should provide a dashboardParameters in the options', () => { expect(() => aksFormatter({ - dashboardUrl: new URL('https://k8s.foo.com'), object: { metadata: { name: 'foobar', @@ -28,6 +27,90 @@ describe('clusterLinks - AKS formatter', () => { }, kind: 'Deployment', }), - ).toThrowError('AKS formatter is not yet implemented. Please, contribute!'); + ).toThrowError('AKS dashboard requires a dashboardParameters option'); + }); + it('should provide a subscriptionId in the dashboardParameters options', () => { + expect(() => + aksFormatter({ + dashboardParameters: { + resourceGroup: 'rg-1', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError( + 'AKS dashboard requires a "subscriptionId" of type string in the dashboardParameters option', + ); + }); + it('should provide a resourceGroup in the dashboardParameters options', () => { + expect(() => + aksFormatter({ + dashboardParameters: { + subscriptionId: '1234-GUID-5678', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError( + 'AKS dashboard requires a "resourceGroup" of type string in the dashboardParameters option', + ); + }); + it('should provide a clusterName in the dashboardParameters options', () => { + expect(() => + aksFormatter({ + dashboardParameters: { + subscriptionId: '1234-GUID-5678', + resourceGroup: 'us-east1-c', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError( + 'AKS dashboard requires a "clusterName" of type string in the dashboardParameters option', + ); + }); + it('should return an url on the cluster with object details', () => { + const url = aksFormatter({ + dashboardParameters: { + subscriptionId: '1234-GUID-5678', + resourceGroup: 'rg-1', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'my-deployment', + namespace: 'my-namespace', + uid: '111-GUID-222', + }, + spec: { + selector: { + matchLabels: { + app: 'foo', + }, + }, + }, + }, + kind: 'Deployment', + }); + expect(url.href).toBe( + 'https://portal.azure.com/#blade/Microsoft_Azure_ContainerService/AksK8ResourceMenuBlade/overview-Deployment/aksClusterId/%2Fsubscriptions%2F1234-GUID-5678%2FresourceGroups%2Frg-1%2Fproviders%2FMicrosoft.ContainerService%2FmanagedClusters%2Fcluster-1/resource/%7B%22kind%22%3A%22Deployment%22%2C%22metadata%22%3A%7B%22name%22%3A%22my-deployment%22%2C%22namespace%22%3A%22my-namespace%22%2C%22uid%22%3A%22111-GUID-222%22%7D%2C%22spec%22%3A%7B%22selector%22%3A%7B%22matchLabels%22%3A%7B%22app%22%3A%22foo%22%7D%7D%7D%7D', + ); }); }); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts index d6f39ab72c..71fffb00e6 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts @@ -15,6 +15,39 @@ */ import { ClusterLinksFormatterOptions } from '../../../types/types'; -export function aksFormatter(_options: ClusterLinksFormatterOptions): URL { - throw new Error('AKS formatter is not yet implemented. Please, contribute!'); +const basePath = + 'https://portal.azure.com/#blade/Microsoft_Azure_ContainerService/AksK8ResourceMenuBlade/overview-Deployment/aksClusterId'; + +const requiredParams = ['subscriptionId', 'resourceGroup', 'clusterName']; + +export function aksFormatter(options: ClusterLinksFormatterOptions): URL { + if (!options.dashboardParameters) { + throw new Error('AKS dashboard requires a dashboardParameters option'); + } + const args = options.dashboardParameters; + for (const param of requiredParams) { + if (typeof args[param] !== 'string') { + throw new Error( + `AKS dashboard requires a "${param}" of type string in the dashboardParameters option`, + ); + } + } + + const path = `/subscriptions/${args.subscriptionId}/resourceGroups/${args.resourceGroup}/providers/Microsoft.ContainerService/managedClusters/${args.clusterName}`; + + const { name, namespace, uid } = options.object.metadata; + const { selector } = options.object.spec; + const params = { + kind: options.kind, + metadata: { name, namespace, uid }, + spec: { + selector, + }, + }; + + return new URL( + `${basePath}/${encodeURIComponent(path)}/resource/${encodeURIComponent( + JSON.stringify(params), + )}`, + ); } From df35b3b30d452ce80192c5a0074ce5305cb654b4 Mon Sep 17 00:00:00 2001 From: Guilherme Oenning Date: Wed, 4 May 2022 15:10:40 +0100 Subject: [PATCH 04/15] ammend patch notes Signed-off-by: Guilherme Oenning --- .changeset/big-teachers-dress.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/big-teachers-dress.md b/.changeset/big-teachers-dress.md index 4545939c0a..6522efa056 100644 --- a/.changeset/big-teachers-dress.md +++ b/.changeset/big-teachers-dress.md @@ -3,4 +3,4 @@ '@backstage/plugin-kubernetes-backend': patch --- -add Azure Identity auth provider +add Azure Identity auth provider and AKS dashboard formatter From 0a336bf640e2df8c4cb14e5f6a28ad3f764ed03c Mon Sep 17 00:00:00 2001 From: Guilherme Oenning Date: Wed, 4 May 2022 15:11:17 +0100 Subject: [PATCH 05/15] typo Signed-off-by: Guilherme Oenning --- .../src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts index 59dd0321b3..ee184b3f57 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts @@ -21,7 +21,7 @@ export class AzureKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise { - // No-op, with aws auth, server's Azire credentials are used for access + // No-op, with aws auth, server's Azure credentials are used for access return requestBody; } } From 403e5de2630beb8367291a34fd04bdb0619de857 Mon Sep 17 00:00:00 2001 From: goenning Date: Thu, 5 May 2022 10:34:21 +0100 Subject: [PATCH 06/15] update enum to include azure Signed-off-by: goenning --- plugins/kubernetes-backend/schema.d.ts | 2 +- plugins/kubernetes-common/api-report.md | 2 +- plugins/kubernetes-common/src/types.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/kubernetes-backend/schema.d.ts b/plugins/kubernetes-backend/schema.d.ts index 7f5183e337..c2ac017713 100644 --- a/plugins/kubernetes-backend/schema.d.ts +++ b/plugins/kubernetes-backend/schema.d.ts @@ -52,7 +52,7 @@ export interface Config { /** @visibility secret */ serviceAccountToken?: string; /** @visibility frontend */ - authProvider: 'aws' | 'google' | 'serviceAccount'; + authProvider: 'aws' | 'google' | 'serviceAccount' | 'azure'; /** @visibility frontend */ skipTLSVerify?: boolean; }>; diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 15019d71c3..639c3f63c2 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -18,7 +18,7 @@ import { V1Service } from '@kubernetes/client-node'; // Warning: (ae-missing-release-tag) "AuthProviderType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; +export type AuthProviderType = 'google' | 'serviceAccount' | 'aws' | 'azure'; // Warning: (ae-missing-release-tag) "ClientContainerStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index afda9b5299..cb388bad0a 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -84,7 +84,7 @@ export interface ObjectsByEntityResponse { items: ClusterObjects[]; } -export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; +export type AuthProviderType = 'google' | 'serviceAccount' | 'aws' | 'azure'; export type FetchResponse = | PodFetchResponse From 88c539a2db6c40f6b1bfbbb9005d7bb2e559159b Mon Sep 17 00:00:00 2001 From: Guilherme Oenning Date: Fri, 6 May 2022 15:18:06 +0100 Subject: [PATCH 07/15] fix typo Signed-off-by: goenning --- .../src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts index ee184b3f57..60401bbe4d 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/AzureKubernetesAuthProvider.ts @@ -21,7 +21,7 @@ export class AzureKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise { - // No-op, with aws auth, server's Azure credentials are used for access + // No-op, with azure auth, server's Azure credentials are used for access return requestBody; } } From 31d1b31392fd0d9706ef73546d3fbcd4b88a469b Mon Sep 17 00:00:00 2001 From: goenning Date: Fri, 6 May 2022 15:21:08 +0100 Subject: [PATCH 08/15] add plugin to changeset Signed-off-by: goenning --- .changeset/big-teachers-dress.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/big-teachers-dress.md b/.changeset/big-teachers-dress.md index 6522efa056..418b6a2f3f 100644 --- a/.changeset/big-teachers-dress.md +++ b/.changeset/big-teachers-dress.md @@ -1,6 +1,7 @@ --- '@backstage/plugin-kubernetes': patch '@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-common': patch --- add Azure Identity auth provider and AKS dashboard formatter From 0c70cd8e1d8f9a1960788e701d1babc3f67d788c Mon Sep 17 00:00:00 2001 From: goenning Date: Thu, 12 May 2022 13:44:48 +0100 Subject: [PATCH 09/15] cache azure token for kubernetes Signed-off-by: goenning --- .changeset/healthy-pets-mix.md | 5 ++ ...reIdentityKubernetesAuthTranslator.test.ts | 84 +++++++++++++++++++ .../AzureIdentityKubernetesAuthTranslator.ts | 33 ++++++-- .../src/service/KubernetesFanOutHandler.ts | 25 ++++-- 4 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 .changeset/healthy-pets-mix.md create mode 100644 plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts diff --git a/.changeset/healthy-pets-mix.md b/.changeset/healthy-pets-mix.md new file mode 100644 index 0000000000..057cd5f775 --- /dev/null +++ b/.changeset/healthy-pets-mix.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +--- + +cache and refresh Azure tokens to avoid excesive calls to Azure Identity diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts new file mode 100644 index 0000000000..b9c8df2431 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AccessToken, TokenCredential } from '@azure/identity'; +import { AzureIdentityKubernetesAuthTranslator } from './AzureIdentityKubernetesAuthTranslator'; + +class StaticTokenCredential implements TokenCredential { + private count: number = 0; + + constructor(private expiryInMs: number) {} + + getToken(): Promise { + this.count++; + + return Promise.resolve({ + token: `MY_TOKEN_${this.count}`, + expiresOnTimestamp: Date.now() + this.expiryInMs, + }); + } +} + +describe('AzureIdentityKubernetesAuthTranslator tests', () => { + const cd = { + authProvider: 'Azure', + name: 'My Cluster', + url: 'mycluster.privatelink.westeurope.azmk8s.io', + }; + + it('should decorate cluster with Azure token', async () => { + const authTranslator = new AzureIdentityKubernetesAuthTranslator( + new StaticTokenCredential(5 * 60 * 1000), + ); + + const response = await authTranslator.decorateClusterDetailsWithAuth(cd); + expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); + }); + + it('should re-use token before expiry', async () => { + const authTranslator = new AzureIdentityKubernetesAuthTranslator( + new StaticTokenCredential(5 * 60 * 1000), + ); + + const response = await authTranslator.decorateClusterDetailsWithAuth(cd); + expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); + + const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd); + expect(response2.serviceAccountToken).toEqual('MY_TOKEN_1'); + }); + + it('should reissue new token 2 minutes befory expiry', async () => { + const authTranslator = new AzureIdentityKubernetesAuthTranslator( + new StaticTokenCredential(3 * 60 * 1000), // token expires in 3m + ); + + const response = await authTranslator.decorateClusterDetailsWithAuth({ + authProvider: 'Azure', + name: 'My Cluster', + url: 'mycluster.privatelink.westeurope.azmk8s.io', + }); + expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); + + jest.useFakeTimers().setSystemTime(Date.now() + 1 * 60 * 1000); // advance time by 1min + + const response2 = await authTranslator.decorateClusterDetailsWithAuth({ + authProvider: 'Azure', + name: 'My Cluster', + url: 'mycluster.privatelink.westeurope.azmk8s.io', + }); + expect(response2.serviceAccountToken).toEqual('MY_TOKEN_2'); + }); +}); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts index 20b519c269..d33ce5fe5a 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts @@ -16,13 +16,24 @@ import { KubernetesAuthTranslator } from './types'; import { AzureClusterDetails } from '../types/types'; -import { DefaultAzureCredential } from '@azure/identity'; +import { + AccessToken, + DefaultAzureCredential, + TokenCredential, +} from '@azure/identity'; const aksScope = '6dae42f8-4368-4678-94ff-3960e28e3630/.default'; // This scope is the same for all Azure Managed Kubernetes export class AzureIdentityKubernetesAuthTranslator implements KubernetesAuthTranslator { + private tokenCredential: TokenCredential; + private accessToken: AccessToken | null = null; + + constructor(tokenCredential?: TokenCredential) { + this.tokenCredential = tokenCredential || new DefaultAzureCredential(); + } + async decorateClusterDetailsWithAuth( clusterDetails: AzureClusterDetails, ): Promise { @@ -31,11 +42,23 @@ export class AzureIdentityKubernetesAuthTranslator clusterDetails, ); - const credentials = new DefaultAzureCredential(); + if (!this.accessToken || this.tokenExpired()) { + this.accessToken = await this.tokenCredential.getToken(aksScope); - // TODO: can we cache this? It's inneficiant to get a new token every time - const accessToken = await credentials.getToken(aksScope); - clusterDetailsWithAuthToken.serviceAccountToken = accessToken.token; + if (!this.accessToken) { + throw new Error('Unable to retrieve Azure token'); + } + } + + clusterDetailsWithAuthToken.serviceAccountToken = this.accessToken.token; return clusterDetailsWithAuthToken; } + + private tokenExpired(): boolean { + if (!this.accessToken) return true; + + // Set tokens to expire 2 minutes before its actual expiry time + const expiresOn = this.accessToken.expiresOnTimestamp - 2 * 60 * 1000; + return Date.now() >= expiresOn; + } } diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 00090e57ac..7a20db3b94 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -155,6 +155,7 @@ export class KubernetesFanOutHandler { private readonly serviceLocator: KubernetesServiceLocator; private readonly customResources: CustomResource[]; private readonly objectTypesToFetch: Set; + private readonly authTranslators: Record; constructor({ logger, @@ -168,6 +169,7 @@ export class KubernetesFanOutHandler { this.serviceLocator = serviceLocator; this.customResources = customResources; this.objectTypesToFetch = new Set(objectTypesToFetch); + this.authTranslators = {}; } async getKubernetesObjectsByEntity( @@ -183,14 +185,9 @@ export class KubernetesFanOutHandler { // Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them const promises: Promise[] = clusterDetails.map(cd => { - const kubernetesAuthTranslator: KubernetesAuthTranslator = - KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance( - cd.authProvider, - ); - return kubernetesAuthTranslator.decorateClusterDetailsWithAuth( - cd, - requestBody, - ); + return this.getAuthTranslator( + cd.authProvider, + ).decorateClusterDetailsWithAuth(cd, requestBody); }); const clusterDetailsDecoratedForAuth: ClusterDetails[] = await Promise.all( promises, @@ -288,4 +285,16 @@ export class KubernetesFanOutHandler { return Promise.all([result, Promise.all(podMetrics)]); } + + private getAuthTranslator(provider: string): KubernetesAuthTranslator { + if (this.authTranslators[provider]) { + return this.authTranslators[provider]; + } + + this.authTranslators[provider] = + KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance( + provider, + ); + return this.authTranslators[provider]; + } } From 6e495a1c650d158d0880e1d1b0a77cb800ec4d44 Mon Sep 17 00:00:00 2001 From: goenning Date: Thu, 12 May 2022 13:56:56 +0100 Subject: [PATCH 10/15] typo Signed-off-by: goenning --- .changeset/healthy-pets-mix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/healthy-pets-mix.md b/.changeset/healthy-pets-mix.md index 057cd5f775..44075b6a76 100644 --- a/.changeset/healthy-pets-mix.md +++ b/.changeset/healthy-pets-mix.md @@ -2,4 +2,4 @@ '@backstage/plugin-kubernetes-backend': patch --- -cache and refresh Azure tokens to avoid excesive calls to Azure Identity +cache and refresh Azure tokens to avoid excessive calls to Azure Identity From 81b8a9ded30479caa82b1a6dbc6f2e7665ae47a7 Mon Sep 17 00:00:00 2001 From: goenning Date: Thu, 12 May 2022 14:05:43 +0100 Subject: [PATCH 11/15] simplify test Signed-off-by: goenning --- .../AzureIdentityKubernetesAuthTranslator.test.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts index b9c8df2431..8a2387723c 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts @@ -60,25 +60,17 @@ describe('AzureIdentityKubernetesAuthTranslator tests', () => { expect(response2.serviceAccountToken).toEqual('MY_TOKEN_1'); }); - it('should reissue new token 2 minutes befory expiry', async () => { + it('should issue new token 2 minutes befory expiry', async () => { const authTranslator = new AzureIdentityKubernetesAuthTranslator( new StaticTokenCredential(3 * 60 * 1000), // token expires in 3m ); - const response = await authTranslator.decorateClusterDetailsWithAuth({ - authProvider: 'Azure', - name: 'My Cluster', - url: 'mycluster.privatelink.westeurope.azmk8s.io', - }); + const response = await authTranslator.decorateClusterDetailsWithAuth(cd); expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); jest.useFakeTimers().setSystemTime(Date.now() + 1 * 60 * 1000); // advance time by 1min - const response2 = await authTranslator.decorateClusterDetailsWithAuth({ - authProvider: 'Azure', - name: 'My Cluster', - url: 'mycluster.privatelink.westeurope.azmk8s.io', - }); + const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd); expect(response2.serviceAccountToken).toEqual('MY_TOKEN_2'); }); }); From 6e284e2a3aa3d11f451c7805fb3adef40b471c0c Mon Sep 17 00:00:00 2001 From: goenning Date: Mon, 16 May 2022 15:51:54 +0100 Subject: [PATCH 12/15] code review Signed-off-by: goenning --- .../AzureIdentityKubernetesAuthTranslator.test.ts | 6 +++--- .../AzureIdentityKubernetesAuthTranslator.ts | 15 +++++++-------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts index 8a2387723c..c5183f8a22 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts @@ -50,7 +50,7 @@ describe('AzureIdentityKubernetesAuthTranslator tests', () => { it('should re-use token before expiry', async () => { const authTranslator = new AzureIdentityKubernetesAuthTranslator( - new StaticTokenCredential(5 * 60 * 1000), + new StaticTokenCredential(20 * 60 * 1000), ); const response = await authTranslator.decorateClusterDetailsWithAuth(cd); @@ -60,9 +60,9 @@ describe('AzureIdentityKubernetesAuthTranslator tests', () => { expect(response2.serviceAccountToken).toEqual('MY_TOKEN_1'); }); - it('should issue new token 2 minutes befory expiry', async () => { + it('should issue new token 15 minutes befory expiry', async () => { const authTranslator = new AzureIdentityKubernetesAuthTranslator( - new StaticTokenCredential(3 * 60 * 1000), // token expires in 3m + new StaticTokenCredential(16 * 60 * 1000), // token expires in 11m ); const response = await authTranslator.decorateClusterDetailsWithAuth(cd); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts index d33ce5fe5a..027bf03bb6 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts @@ -27,12 +27,11 @@ const aksScope = '6dae42f8-4368-4678-94ff-3960e28e3630/.default'; // This scope export class AzureIdentityKubernetesAuthTranslator implements KubernetesAuthTranslator { - private tokenCredential: TokenCredential; private accessToken: AccessToken | null = null; - constructor(tokenCredential?: TokenCredential) { - this.tokenCredential = tokenCredential || new DefaultAzureCredential(); - } + constructor( + private readonly tokenCredential: TokenCredential = new DefaultAzureCredential(), + ) {} async decorateClusterDetailsWithAuth( clusterDetails: AzureClusterDetails, @@ -42,7 +41,7 @@ export class AzureIdentityKubernetesAuthTranslator clusterDetails, ); - if (!this.accessToken || this.tokenExpired()) { + if (this.tokenExpired()) { this.accessToken = await this.tokenCredential.getToken(aksScope); if (!this.accessToken) { @@ -50,15 +49,15 @@ export class AzureIdentityKubernetesAuthTranslator } } - clusterDetailsWithAuthToken.serviceAccountToken = this.accessToken.token; + clusterDetailsWithAuthToken.serviceAccountToken = this.accessToken!.token; return clusterDetailsWithAuthToken; } private tokenExpired(): boolean { if (!this.accessToken) return true; - // Set tokens to expire 2 minutes before its actual expiry time - const expiresOn = this.accessToken.expiresOnTimestamp - 2 * 60 * 1000; + // Set tokens to expire 15 minutes before its actual expiry time + const expiresOn = this.accessToken.expiresOnTimestamp - 15 * 60 * 1000; return Date.now() >= expiresOn; } } From 1b173f8b5dc0543ac228b88e301972e5528252a4 Mon Sep 17 00:00:00 2001 From: goenning Date: Fri, 20 May 2022 14:21:46 +0100 Subject: [PATCH 13/15] refactor null check and race condition Signed-off-by: goenning --- ...reIdentityKubernetesAuthTranslator.test.ts | 39 +++++++++++++- .../AzureIdentityKubernetesAuthTranslator.ts | 51 ++++++++++++++----- .../KubernetesAuthTranslatorGenerator.test.ts | 15 ++++-- .../KubernetesAuthTranslatorGenerator.ts | 4 +- .../src/service/KubernetesFanOutHandler.ts | 1 + 5 files changed, 89 insertions(+), 21 deletions(-) diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts index c5183f8a22..574b5838f9 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts @@ -15,8 +15,11 @@ */ import { AccessToken, TokenCredential } from '@azure/identity'; +import { getVoidLogger } from '@backstage/backend-common'; import { AzureIdentityKubernetesAuthTranslator } from './AzureIdentityKubernetesAuthTranslator'; +const logger = getVoidLogger(); + class StaticTokenCredential implements TokenCredential { private count: number = 0; @@ -25,6 +28,10 @@ class StaticTokenCredential implements TokenCredential { getToken(): Promise { this.count++; + if (this.count === 3) { + return Promise.reject(new Error('Third time never works.')); + } + return Promise.resolve({ token: `MY_TOKEN_${this.count}`, expiresOnTimestamp: Date.now() + this.expiryInMs, @@ -41,6 +48,7 @@ describe('AzureIdentityKubernetesAuthTranslator tests', () => { it('should decorate cluster with Azure token', async () => { const authTranslator = new AzureIdentityKubernetesAuthTranslator( + logger, new StaticTokenCredential(5 * 60 * 1000), ); @@ -50,6 +58,7 @@ describe('AzureIdentityKubernetesAuthTranslator tests', () => { it('should re-use token before expiry', async () => { const authTranslator = new AzureIdentityKubernetesAuthTranslator( + logger, new StaticTokenCredential(20 * 60 * 1000), ); @@ -62,15 +71,41 @@ describe('AzureIdentityKubernetesAuthTranslator tests', () => { it('should issue new token 15 minutes befory expiry', async () => { const authTranslator = new AzureIdentityKubernetesAuthTranslator( - new StaticTokenCredential(16 * 60 * 1000), // token expires in 11m + logger, + new StaticTokenCredential(16 * 60 * 1000), // token expires in 16m ); const response = await authTranslator.decorateClusterDetailsWithAuth(cd); expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); - jest.useFakeTimers().setSystemTime(Date.now() + 1 * 60 * 1000); // advance time by 1min + jest.useFakeTimers().setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2mins const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd); expect(response2.serviceAccountToken).toEqual('MY_TOKEN_2'); }); + + it('should re-use existing token if there is afailure', async () => { + const authTranslator = new AzureIdentityKubernetesAuthTranslator( + logger, + new StaticTokenCredential(16 * 60 * 1000), // new tokens expires in 16m + ); + + const response = await authTranslator.decorateClusterDetailsWithAuth(cd); + expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); + + jest.useFakeTimers().setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2mins + + const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd); + expect(response2.serviceAccountToken).toEqual('MY_TOKEN_2'); + + jest.useFakeTimers().setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2mins + + const response3 = await authTranslator.decorateClusterDetailsWithAuth(cd); + expect(response3.serviceAccountToken).toEqual('MY_TOKEN_2'); + + jest.useFakeTimers().setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2mins + + const response4 = await authTranslator.decorateClusterDetailsWithAuth(cd); + expect(response4.serviceAccountToken).toEqual('MY_TOKEN_4'); + }); }); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts index 027bf03bb6..00c67f1328 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Logger } from 'winston'; import { KubernetesAuthTranslator } from './types'; import { AzureClusterDetails } from '../types/types'; import { @@ -27,9 +28,11 @@ const aksScope = '6dae42f8-4368-4678-94ff-3960e28e3630/.default'; // This scope export class AzureIdentityKubernetesAuthTranslator implements KubernetesAuthTranslator { - private accessToken: AccessToken | null = null; + private accessToken: AccessToken = { token: '', expiresOnTimestamp: 0 }; + private newToken: Promise | undefined; constructor( + private readonly logger: Logger, private readonly tokenCredential: TokenCredential = new DefaultAzureCredential(), ) {} @@ -41,23 +44,45 @@ export class AzureIdentityKubernetesAuthTranslator clusterDetails, ); - if (this.tokenExpired()) { - this.accessToken = await this.tokenCredential.getToken(aksScope); - - if (!this.accessToken) { - throw new Error('Unable to retrieve Azure token'); - } - } - - clusterDetailsWithAuthToken.serviceAccountToken = this.accessToken!.token; + clusterDetailsWithAuthToken.serviceAccountToken = await this.getToken(); return clusterDetailsWithAuthToken; } - private tokenExpired(): boolean { - if (!this.accessToken) return true; + private async getToken(): Promise { + if (this.isTokenValid()) { + return this.accessToken.token; + } + if (!this.newToken) { + this.newToken = this.fetchNewToken(); + } + + return this.newToken; + } + + private async fetchNewToken(): Promise { + try { + this.logger.info('Fetching new Azure token for AKS'); + + const newAccessToken = await this.tokenCredential.getToken(aksScope, { + requestOptions: { timeout: 10_000 }, // 10 seconds + }); + if (!newAccessToken) { + throw new Error('AccessToken is null'); + } + + this.accessToken = newAccessToken; + } catch (err) { + this.logger.error('Unable to fetch Azure token', err); + } + + this.newToken = undefined; + return this.accessToken.token; + } + + private isTokenValid(): boolean { // Set tokens to expire 15 minutes before its actual expiry time const expiresOn = this.accessToken.expiresOnTimestamp - 15 * 60 * 1000; - return Date.now() >= expiresOn; + return expiresOn >= Date.now(); } } diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts index b31443cdd1..dc0d589148 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts @@ -20,25 +20,28 @@ import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGen import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; import { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator'; +import { getVoidLogger } from '@backstage/backend-common'; + +const logger = getVoidLogger(); describe('getKubernetesAuthTranslatorInstance', () => { const sut = KubernetesAuthTranslatorGenerator; it('can return an auth translator for google auth', () => { const authTranslator: KubernetesAuthTranslator = - sut.getKubernetesAuthTranslatorInstance('google'); + sut.getKubernetesAuthTranslatorInstance(logger, 'google'); expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true); }); it('can return an auth translator for aws auth', () => { const authTranslator: KubernetesAuthTranslator = - sut.getKubernetesAuthTranslatorInstance('aws'); + sut.getKubernetesAuthTranslatorInstance(logger, 'aws'); expect(authTranslator instanceof AwsIamKubernetesAuthTranslator).toBe(true); }); it('can return an auth translator for serviceAccount auth', () => { const authTranslator: KubernetesAuthTranslator = - sut.getKubernetesAuthTranslatorInstance('serviceAccount'); + sut.getKubernetesAuthTranslatorInstance(logger, 'serviceAccount'); expect( authTranslator instanceof ServiceAccountKubernetesAuthTranslator, ).toBe(true); @@ -46,12 +49,14 @@ describe('getKubernetesAuthTranslatorInstance', () => { it('can return an auth translator for oidc auth', () => { const authTranslator: KubernetesAuthTranslator = - sut.getKubernetesAuthTranslatorInstance('oidc'); + sut.getKubernetesAuthTranslatorInstance(logger, 'oidc'); expect(authTranslator instanceof OidcKubernetesAuthTranslator).toBe(true); }); it('throws an error when asked for an auth translator for an unsupported auth type', () => { - expect(() => sut.getKubernetesAuthTranslatorInstance('linode')).toThrow( + expect(() => + sut.getKubernetesAuthTranslatorInstance(logger, 'linode'), + ).toThrow( 'authProvider "linode" has no KubernetesAuthTranslator associated with it', ); }); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts index 4b17653359..92c267cec9 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Logger } from 'winston'; import { KubernetesAuthTranslator } from './types'; import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator'; import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; @@ -24,6 +25,7 @@ import { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator'; export class KubernetesAuthTranslatorGenerator { static getKubernetesAuthTranslatorInstance( + logger: Logger, authProvider: string, ): KubernetesAuthTranslator { switch (authProvider) { @@ -34,7 +36,7 @@ export class KubernetesAuthTranslatorGenerator { return new AwsIamKubernetesAuthTranslator(); } case 'azure': { - return new AzureIdentityKubernetesAuthTranslator(); + return new AzureIdentityKubernetesAuthTranslator(logger); } case 'serviceAccount': { return new ServiceAccountKubernetesAuthTranslator(); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 7a20db3b94..e65754a8cb 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -293,6 +293,7 @@ export class KubernetesFanOutHandler { this.authTranslators[provider] = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance( + this.logger, provider, ); return this.authTranslators[provider]; From f3b42c2cdbe95ea8c67f3cb78c3e917fe6c991a4 Mon Sep 17 00:00:00 2001 From: goenning Date: Fri, 20 May 2022 14:28:33 +0100 Subject: [PATCH 14/15] add explanation Signed-off-by: goenning --- .../AzureIdentityKubernetesAuthTranslator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts index 00c67f1328..9b913cccff 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts @@ -74,6 +74,7 @@ export class AzureIdentityKubernetesAuthTranslator this.accessToken = newAccessToken; } catch (err) { this.logger.error('Unable to fetch Azure token', err); + // don't throw the error, so the existing token will be re-used until we're able to fetch a new token } this.newToken = undefined; From 63aa03cc82f277457933f129c41bd48e4ccc6686 Mon Sep 17 00:00:00 2001 From: goenning Date: Fri, 20 May 2022 16:13:31 +0100 Subject: [PATCH 15/15] code review Signed-off-by: goenning --- ...reIdentityKubernetesAuthTranslator.test.ts | 31 +++++++++++++++---- .../AzureIdentityKubernetesAuthTranslator.ts | 26 ++++++++++------ .../KubernetesAuthTranslatorGenerator.test.ts | 10 +++--- .../KubernetesAuthTranslatorGenerator.ts | 6 ++-- .../src/service/KubernetesFanOutHandler.ts | 4 ++- 5 files changed, 54 insertions(+), 23 deletions(-) diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts index 574b5838f9..a28f825434 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.test.ts @@ -72,7 +72,7 @@ describe('AzureIdentityKubernetesAuthTranslator tests', () => { it('should issue new token 15 minutes befory expiry', async () => { const authTranslator = new AzureIdentityKubernetesAuthTranslator( logger, - new StaticTokenCredential(16 * 60 * 1000), // token expires in 16m + new StaticTokenCredential(16 * 60 * 1000), // token expires in 16min ); const response = await authTranslator.decorateClusterDetailsWithAuth(cd); @@ -87,25 +87,44 @@ describe('AzureIdentityKubernetesAuthTranslator tests', () => { it('should re-use existing token if there is afailure', async () => { const authTranslator = new AzureIdentityKubernetesAuthTranslator( logger, - new StaticTokenCredential(16 * 60 * 1000), // new tokens expires in 16m + new StaticTokenCredential(16 * 60 * 1000), // new tokens expires in 16min ); const response = await authTranslator.decorateClusterDetailsWithAuth(cd); expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); - jest.useFakeTimers().setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2mins + jest.useFakeTimers().setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2min const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd); expect(response2.serviceAccountToken).toEqual('MY_TOKEN_2'); - jest.useFakeTimers().setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2mins + jest.useFakeTimers().setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2min const response3 = await authTranslator.decorateClusterDetailsWithAuth(cd); expect(response3.serviceAccountToken).toEqual('MY_TOKEN_2'); - jest.useFakeTimers().setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2mins - const response4 = await authTranslator.decorateClusterDetailsWithAuth(cd); expect(response4.serviceAccountToken).toEqual('MY_TOKEN_4'); }); + + it('should throw if existing token expired and failed to fetch a new one', async () => { + const authTranslator = new AzureIdentityKubernetesAuthTranslator( + logger, + new StaticTokenCredential(16 * 60 * 1000), // new tokens expires in 16min + ); + + const response = await authTranslator.decorateClusterDetailsWithAuth(cd); + expect(response.serviceAccountToken).toEqual('MY_TOKEN_1'); + + jest.useFakeTimers().setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2min + + const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd); + expect(response2.serviceAccountToken).toEqual('MY_TOKEN_2'); + + jest.useFakeTimers().setSystemTime(Date.now() + 17 * 60 * 1000); // advance time by 17min + + await expect( + authTranslator.decorateClusterDetailsWithAuth(cd), + ).rejects.toThrow(); + }); }); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts index 9b913cccff..5b8a709270 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator.ts @@ -29,7 +29,7 @@ export class AzureIdentityKubernetesAuthTranslator implements KubernetesAuthTranslator { private accessToken: AccessToken = { token: '', expiresOnTimestamp: 0 }; - private newToken: Promise | undefined; + private newTokenPromise: Promise | undefined; constructor( private readonly logger: Logger, @@ -49,15 +49,15 @@ export class AzureIdentityKubernetesAuthTranslator } private async getToken(): Promise { - if (this.isTokenValid()) { + if (!this.tokenRequiresRefresh()) { return this.accessToken.token; } - if (!this.newToken) { - this.newToken = this.fetchNewToken(); + if (!this.newTokenPromise) { + this.newTokenPromise = this.fetchNewToken(); } - return this.newToken; + return this.newTokenPromise; } private async fetchNewToken(): Promise { @@ -74,16 +74,24 @@ export class AzureIdentityKubernetesAuthTranslator this.accessToken = newAccessToken; } catch (err) { this.logger.error('Unable to fetch Azure token', err); - // don't throw the error, so the existing token will be re-used until we're able to fetch a new token + + // only throw the error if the token has already expired, otherwise re-use existing until we're able to fetch a new token + if (this.tokenExpired()) { + throw err; + } } - this.newToken = undefined; + this.newTokenPromise = undefined; return this.accessToken.token; } - private isTokenValid(): boolean { + private tokenRequiresRefresh(): boolean { // Set tokens to expire 15 minutes before its actual expiry time const expiresOn = this.accessToken.expiresOnTimestamp - 15 * 60 * 1000; - return expiresOn >= Date.now(); + return Date.now() >= expiresOn; + } + + private tokenExpired(): boolean { + return Date.now() >= this.accessToken.expiresOnTimestamp; } } diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts index dc0d589148..123b05456c 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts @@ -29,19 +29,19 @@ describe('getKubernetesAuthTranslatorInstance', () => { it('can return an auth translator for google auth', () => { const authTranslator: KubernetesAuthTranslator = - sut.getKubernetesAuthTranslatorInstance(logger, 'google'); + sut.getKubernetesAuthTranslatorInstance('google', { logger }); expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true); }); it('can return an auth translator for aws auth', () => { const authTranslator: KubernetesAuthTranslator = - sut.getKubernetesAuthTranslatorInstance(logger, 'aws'); + sut.getKubernetesAuthTranslatorInstance('aws', { logger }); expect(authTranslator instanceof AwsIamKubernetesAuthTranslator).toBe(true); }); it('can return an auth translator for serviceAccount auth', () => { const authTranslator: KubernetesAuthTranslator = - sut.getKubernetesAuthTranslatorInstance(logger, 'serviceAccount'); + sut.getKubernetesAuthTranslatorInstance('serviceAccount', { logger }); expect( authTranslator instanceof ServiceAccountKubernetesAuthTranslator, ).toBe(true); @@ -49,13 +49,13 @@ describe('getKubernetesAuthTranslatorInstance', () => { it('can return an auth translator for oidc auth', () => { const authTranslator: KubernetesAuthTranslator = - sut.getKubernetesAuthTranslatorInstance(logger, 'oidc'); + sut.getKubernetesAuthTranslatorInstance('oidc', { logger }); expect(authTranslator instanceof OidcKubernetesAuthTranslator).toBe(true); }); it('throws an error when asked for an auth translator for an unsupported auth type', () => { expect(() => - sut.getKubernetesAuthTranslatorInstance(logger, 'linode'), + sut.getKubernetesAuthTranslatorInstance('linode', { logger }), ).toThrow( 'authProvider "linode" has no KubernetesAuthTranslator associated with it', ); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts index 92c267cec9..fa69ad42b5 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts @@ -25,8 +25,10 @@ import { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator'; export class KubernetesAuthTranslatorGenerator { static getKubernetesAuthTranslatorInstance( - logger: Logger, authProvider: string, + options: { + logger: Logger; + }, ): KubernetesAuthTranslator { switch (authProvider) { case 'google': { @@ -36,7 +38,7 @@ export class KubernetesAuthTranslatorGenerator { return new AwsIamKubernetesAuthTranslator(); } case 'azure': { - return new AzureIdentityKubernetesAuthTranslator(logger); + return new AzureIdentityKubernetesAuthTranslator(options.logger); } case 'serviceAccount': { return new ServiceAccountKubernetesAuthTranslator(); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index e65754a8cb..275347ff82 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -293,8 +293,10 @@ export class KubernetesFanOutHandler { this.authTranslators[provider] = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance( - this.logger, provider, + { + logger: this.logger, + }, ); return this.authTranslators[provider]; }