diff --git a/.changeset/big-teachers-dress.md b/.changeset/big-teachers-dress.md new file mode 100644 index 0000000000..418b6a2f3f --- /dev/null +++ b/.changeset/big-teachers-dress.md @@ -0,0 +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 diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index cb30451c7d..5b60316fd0 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -91,6 +91,7 @@ cluster. Valid values are: | `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 | ##### `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/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-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..20b519c269 --- /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-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 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..60401bbe4d --- /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 azure auth, server's Azure 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..00d5a17f72 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,10 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { new GoogleServiceAccountAuthProvider(), ); this.kubernetesAuthProviderMap.set('aws', new AwsKubernetesAuthProvider()); + this.kubernetesAuthProviderMap.set( + 'azure', + new AzureKubernetesAuthProvider(), + ); } async decorateRequestBodyForAuth( 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), + )}`, + ); }