Merge pull request #19903 from jamieklassen/clusterdetails-relax-auth-type

Configurable Kubernetes Authentication Strategies
This commit is contained in:
Jamie Klassen
2023-09-15 13:01:08 -04:00
committed by GitHub
50 changed files with 1442 additions and 1131 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-kubernetes': patch
'@backstage/plugin-kubernetes-common': patch
---
Loosened the type of the `auth` field in the body of requests to the `retrieveObjectsByServiceId` endpoint. Now any JSON object is allowed, which should make it easier for integrators to write their own custom auth strategies for Kubernetes.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-kubernetes-backend': minor
---
Integrators can now bring their own auth strategies through the use of the `addAuthStrategy` method on `KubernetesBuilder`.
**BREAKING** on the slight chance you were using the `setAuthTranslatorMap` method on `KubernetesBuilder`, it has been removed along with the entire `KubernetesAuthTranslator` interface. This notion has been replaced with the more focused concept of an `AuthenticationStrategy`. Converting a translator to a strategy should not be especially difficult.
+92 -90
View File
@@ -22,51 +22,60 @@ import type { RequestHandler } from 'express';
import { TokenCredential } from '@azure/identity';
// @public (undocumented)
export class AksKubernetesAuthTranslator {
export class AksStrategy implements AuthenticationStrategy {
// (undocumented)
decorateClusterDetailsWithAuth(
getCredential(
_: ClusterDetails,
requestAuth: KubernetesRequestAuth,
): Promise<KubernetesCredential>;
// (undocumented)
validateCluster(): Error[];
}
// @public (undocumented)
export class AnonymousStrategy implements AuthenticationStrategy {
// (undocumented)
getCredential(): Promise<KubernetesCredential>;
// (undocumented)
validateCluster(): Error[];
}
// @public (undocumented)
export interface AuthenticationStrategy {
// (undocumented)
getCredential(
clusterDetails: ClusterDetails,
auth: KubernetesRequestAuth,
): Promise<ClusterDetails>;
authConfig: KubernetesRequestAuth,
): Promise<KubernetesCredential>;
// (undocumented)
validateCluster(authMetadata: AuthMetadata): Error[];
}
// @public (undocumented)
export interface AWSClusterDetails extends ClusterDetails {
// (undocumented)
assumeRole?: string;
// (undocumented)
externalId?: string;
}
// @public
export type AuthMetadata = Record<string, string>;
// @public (undocumented)
export class AwsIamKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
export class AwsIamStrategy implements AuthenticationStrategy {
constructor(opts: { config: Config });
// (undocumented)
decorateClusterDetailsWithAuth(
clusterDetails: AWSClusterDetails,
): Promise<AWSClusterDetails>;
getCredential(clusterDetails: ClusterDetails): Promise<KubernetesCredential>;
// (undocumented)
validateCluster(): Error[];
}
// @public (undocumented)
export interface AzureClusterDetails extends ClusterDetails {}
// @public (undocumented)
export class AzureIdentityKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
export class AzureIdentityStrategy implements AuthenticationStrategy {
constructor(logger: Logger, tokenCredential?: TokenCredential);
// (undocumented)
decorateClusterDetailsWithAuth(
clusterDetails: AzureClusterDetails,
): Promise<AzureClusterDetails>;
getCredential(): Promise<KubernetesCredential>;
// (undocumented)
validateCluster(): Error[];
}
// @public (undocumented)
export interface ClusterDetails {
// (undocumented)
authProvider: string;
authMetadata: AuthMetadata;
// (undocumented)
caData?: string | undefined;
// (undocumented)
@@ -76,9 +85,6 @@ export interface ClusterDetails {
dashboardParameters?: JsonObject;
dashboardUrl?: string;
name: string;
oidcTokenProvider?: string | undefined;
// (undocumented)
serviceAccountToken?: string | undefined;
skipMetricsLookup?: boolean;
// (undocumented)
skipTLSVerify?: boolean;
@@ -105,21 +111,21 @@ export interface CustomResourcesByEntity extends KubernetesObjectsByEntity {
export const DEFAULT_OBJECTS: ObjectToFetch[];
// @public
export class DispatchingKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
constructor(options: DispatchingKubernetesAuthTranslatorOptions);
export class DispatchStrategy implements AuthenticationStrategy {
constructor(options: DispatchStrategyOptions);
// (undocumented)
decorateClusterDetailsWithAuth(
getCredential(
clusterDetails: ClusterDetails,
auth: KubernetesRequestAuth,
): Promise<ClusterDetails>;
): Promise<KubernetesCredential>;
// (undocumented)
validateCluster(authMetadata: AuthMetadata): Error[];
}
// @public (undocumented)
export type DispatchingKubernetesAuthTranslatorOptions = {
authTranslatorMap: {
[key: string]: KubernetesAuthTranslator;
export type DispatchStrategyOptions = {
authStrategyMap: {
[key: string]: AuthenticationStrategy;
};
};
@@ -132,27 +138,22 @@ export interface FetchResponseWrapper {
}
// @public (undocumented)
export interface GKEClusterDetails extends ClusterDetails {}
// @public (undocumented)
export class GoogleKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
export class GoogleServiceAccountStrategy implements AuthenticationStrategy {
// (undocumented)
decorateClusterDetailsWithAuth(
clusterDetails: GKEClusterDetails,
authConfig: KubernetesRequestAuth,
): Promise<GKEClusterDetails>;
getCredential(): Promise<KubernetesCredential>;
// (undocumented)
validateCluster(): Error[];
}
// @public (undocumented)
export class GoogleServiceAccountAuthTranslator
implements KubernetesAuthTranslator
{
export class GoogleStrategy implements AuthenticationStrategy {
// (undocumented)
decorateClusterDetailsWithAuth(
clusterDetails: GKEClusterDetails,
): Promise<GKEClusterDetails>;
getCredential(
_: ClusterDetails,
requestAuth: KubernetesRequestAuth,
): Promise<KubernetesCredential>;
// (undocumented)
validateCluster(): Error[];
}
// @public
@@ -161,23 +162,16 @@ export const HEADER_KUBERNETES_AUTH: string;
// @public
export const HEADER_KUBERNETES_CLUSTER: string;
// @public (undocumented)
export interface KubernetesAuthTranslator {
// (undocumented)
decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
authConfig: KubernetesRequestAuth,
): Promise<ClusterDetails>;
}
// @public (undocumented)
export class KubernetesBuilder {
constructor(env: KubernetesEnvironment);
// (undocumented)
addAuthStrategy(key: string, strategy: AuthenticationStrategy): this;
// (undocumented)
build(): KubernetesBuilderReturn;
// (undocumented)
protected buildAuthTranslatorMap(): {
[key: string]: KubernetesAuthTranslator;
protected buildAuthStrategyMap(): {
[key: string]: AuthenticationStrategy;
};
// (undocumented)
protected buildClusterSupplier(
@@ -226,8 +220,8 @@ export class KubernetesBuilder {
clusterSupplier: KubernetesClustersSupplier,
): Promise<ClusterDetails[]>;
// (undocumented)
protected getAuthTranslatorMap(): {
[key: string]: KubernetesAuthTranslator;
protected getAuthStrategyMap(): {
[key: string]: AuthenticationStrategy;
};
// (undocumented)
protected getClusterSupplier(): KubernetesClustersSupplier;
@@ -249,8 +243,8 @@ export class KubernetesBuilder {
// (undocumented)
protected getServiceLocatorMethod(): ServiceLocatorMethod;
// (undocumented)
setAuthTranslatorMap(authTranslatorMap: {
[key: string]: KubernetesAuthTranslator;
setAuthStrategyMap(authStrategyMap: {
[key: string]: AuthenticationStrategy;
}): void;
// (undocumented)
setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this;
@@ -275,8 +269,8 @@ export type KubernetesBuilderReturn = Promise<{
proxy: KubernetesProxy;
objectsProvider: KubernetesObjectsProvider;
serviceLocator: KubernetesServiceLocator;
authTranslatorMap: {
[key: string]: KubernetesAuthTranslator;
authStrategyMap: {
[key: string]: AuthenticationStrategy;
};
}>;
@@ -285,6 +279,16 @@ export interface KubernetesClustersSupplier {
getClusters(): Promise<ClusterDetails[]>;
}
// @public
export type KubernetesCredential =
| {
type: 'bearer token';
token: string;
}
| {
type: 'anonymous';
};
// @public (undocumented)
export interface KubernetesEnvironment {
// (undocumented)
@@ -306,6 +310,7 @@ export interface KubernetesFetcher {
// (undocumented)
fetchPodMetricsByNamespaces(
clusterDetails: ClusterDetails,
credential: KubernetesCredential,
namespaces: Set<string>,
labelSelector?: string,
): Promise<FetchResponseWrapper>;
@@ -381,7 +386,7 @@ export type KubernetesProxyCreateRequestHandlerOptions = {
export type KubernetesProxyOptions = {
logger: Logger;
clusterSupplier: KubernetesClustersSupplier;
authTranslator: KubernetesAuthTranslator;
authStrategy: AuthenticationStrategy;
};
// @public
@@ -395,22 +400,12 @@ export interface KubernetesServiceLocator {
}>;
}
// @public (undocumented)
export class NoopKubernetesAuthTranslator implements KubernetesAuthTranslator {
// (undocumented)
decorateClusterDetailsWithAuth(
clusterDetails: ServiceAccountClusterDetails,
): Promise<ServiceAccountClusterDetails>;
}
// @public (undocumented)
export interface ObjectFetchParams {
// (undocumented)
clusterDetails:
| AWSClusterDetails
| GKEClusterDetails
| ServiceAccountClusterDetails
| ClusterDetails;
clusterDetails: ClusterDetails;
// (undocumented)
credential: KubernetesCredential;
// (undocumented)
customResources: CustomResource[];
// (undocumented)
@@ -439,12 +434,14 @@ export interface ObjectToFetch {
}
// @public (undocumented)
export class OidcKubernetesAuthTranslator implements KubernetesAuthTranslator {
export class OidcStrategy implements AuthenticationStrategy {
// (undocumented)
decorateClusterDetailsWithAuth(
getCredential(
clusterDetails: ClusterDetails,
authConfig: KubernetesRequestAuth,
): Promise<ClusterDetails>;
): Promise<KubernetesCredential>;
// (undocumented)
validateCluster(authMetadata: AuthMetadata): Error[];
}
// @public (undocumented)
@@ -464,7 +461,12 @@ export interface RouterOptions {
}
// @public (undocumented)
export interface ServiceAccountClusterDetails extends ClusterDetails {}
export class ServiceAccountStrategy implements AuthenticationStrategy {
// (undocumented)
getCredential(clusterDetails: ClusterDetails): Promise<KubernetesCredential>;
// (undocumented)
validateCluster(): Error[];
}
// @public (undocumented)
export type ServiceLocatorMethod = 'multiTenant' | 'http';
@@ -13,17 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AksKubernetesAuthTranslator } from './AksKubernetesAuthTranslator';
import { AksStrategy } from './AksStrategy';
describe('AksKubernetesAuthTranslator', () => {
describe('AksStrategy', () => {
it('uses auth.aks value as bearer token', async () => {
const translator = new AksKubernetesAuthTranslator();
const strategy = new AksStrategy();
const details = await translator.decorateClusterDetailsWithAuth(
{ name: '', authProvider: 'aks', url: '' },
const credential = await strategy.getCredential(
{ name: '', url: '', authMetadata: {} },
{ aks: 'aksToken' },
);
expect(details.serviceAccountToken).toBe('aksToken');
expect(credential).toStrictEqual({
type: 'bearer token',
token: 'aksToken',
});
});
});
@@ -14,17 +14,24 @@
* limitations under the License.
*/
import { ClusterDetails } from '../types/types';
import { AuthenticationStrategy, KubernetesCredential } from './types';
import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
/**
*
* @public
*/
export class AksKubernetesAuthTranslator {
async decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
auth: KubernetesRequestAuth,
): Promise<ClusterDetails> {
return { ...clusterDetails, serviceAccountToken: auth.aks };
export class AksStrategy implements AuthenticationStrategy {
public async getCredential(
_: ClusterDetails,
requestAuth: KubernetesRequestAuth,
): Promise<KubernetesCredential> {
const token = requestAuth.aks;
return token
? { type: 'bearer token', token: token as string }
: { type: 'anonymous' };
}
public validateCluster(): Error[] {
return [];
}
}
@@ -14,16 +14,18 @@
* limitations under the License.
*/
import { ClusterDetails } from '../types/types';
import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
import { AuthenticationStrategy, KubernetesCredential } from './types';
/**
*
* @public
*/
export interface KubernetesAuthTranslator {
decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
authConfig: KubernetesRequestAuth,
): Promise<ClusterDetails>;
export class AnonymousStrategy implements AuthenticationStrategy {
public async getCredential(): Promise<KubernetesCredential> {
return { type: 'anonymous' };
}
public validateCluster(): Error[] {
return [];
}
}
@@ -13,8 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator';
import { ConfigReader } from '@backstage/config';
import {
ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE,
ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID,
} from '@backstage/plugin-kubernetes-common';
import { AwsIamStrategy } from './AwsIamStrategy';
let presign = jest.fn(async () => ({
hostname: 'https://example.com',
@@ -51,33 +55,37 @@ jest.mock('@aws-sdk/credential-providers', () => ({
},
}));
describe('AwsIamKubernetesAuthTranslator tests', () => {
describe('AwsIamStrategy tests', () => {
beforeEach(() => {});
it('returns a signed url for AWS credentials without assume role', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator({ config });
const strategy = new AwsIamStrategy({ config });
const authPromise = authTranslator.decorateClusterDetailsWithAuth({
const credential = await strategy.getCredential({
name: 'test-cluster',
url: '',
authProvider: 'aws',
authMetadata: {},
});
expect(credential).toEqual({
type: 'bearer token',
token: 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
});
expect((await authPromise).serviceAccountToken).toEqual(
'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
);
});
it('returns a signed url for AWS credentials with assume role', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator({ config });
const strategy = new AwsIamStrategy({ config });
const authPromise = authTranslator.decorateClusterDetailsWithAuth({
assumeRole: 'SomeRole',
const credential = await strategy.getCredential({
name: 'test-cluster',
url: '',
authProvider: 'aws',
authMetadata: {
[ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: 'SomeRole',
},
});
expect(credential).toEqual({
type: 'bearer token',
token: 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
});
expect((await authPromise).serviceAccountToken).toEqual(
'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
);
expect(fromTemporaryCredentials).toHaveBeenCalledWith({
clientConfig: {
region: 'us-east-1',
@@ -93,18 +101,20 @@ describe('AwsIamKubernetesAuthTranslator tests', () => {
});
it('returns a signed url for AWS credentials and passes the external id', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator({ config });
const strategy = new AwsIamStrategy({ config });
const authPromise = authTranslator.decorateClusterDetailsWithAuth({
assumeRole: 'SomeRole',
externalId: 'external-id',
const credential = await strategy.getCredential({
name: 'test-cluster',
url: '',
authProvider: 'aws',
authMetadata: {
[ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: 'SomeRole',
[ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: 'external-id',
},
});
expect(credential).toEqual({
type: 'bearer token',
token: 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
});
expect((await authPromise).serviceAccountToken).toEqual(
'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
);
expect(fromTemporaryCredentials).toHaveBeenCalledWith({
clientConfig: {
region: 'us-east-1',
@@ -126,12 +136,12 @@ describe('AwsIamKubernetesAuthTranslator tests', () => {
});
});
it('throws the right error', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator({ config });
const strategy = new AwsIamStrategy({ config });
await expect(
authTranslator.decorateClusterDetailsWithAuth({
strategy.getCredential({
name: 'test-cluster',
url: '',
authProvider: 'aws',
authMetadata: {},
}),
).rejects.toThrow('no way');
});
@@ -13,8 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AWSClusterDetails } from '../types/types';
import { KubernetesAuthTranslator } from './types';
import { fromTemporaryCredentials } from '@aws-sdk/credential-providers';
import { SignatureV4 } from '@aws-sdk/signature-v4';
import { Sha256 } from '@aws-crypto/sha256-js';
@@ -23,6 +21,12 @@ import {
DefaultAwsCredentialsManager,
} from '@backstage/integration-aws-node';
import { Config } from '@backstage/config';
import {
ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE,
ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID,
} from '@backstage/plugin-kubernetes-common';
import { ClusterDetails } from '../types/types';
import { AuthenticationStrategy, KubernetesCredential } from './types';
/**
*
@@ -40,14 +44,30 @@ const defaultRegion = 'us-east-1';
*
* @public
*/
export class AwsIamKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
export class AwsIamStrategy implements AuthenticationStrategy {
private readonly credsManager: AwsCredentialsManager;
constructor(opts: { config: Config }) {
this.credsManager = DefaultAwsCredentialsManager.fromConfig(opts.config);
}
public async getCredential(
clusterDetails: ClusterDetails,
): Promise<KubernetesCredential> {
return {
type: 'bearer token',
token: await this.getBearerToken(
clusterDetails.name,
clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE],
clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID],
),
};
}
public validateCluster(): Error[] {
return [];
}
private async getBearerToken(
clusterName: string,
assumeRole?: string,
@@ -108,20 +128,4 @@ export class AwsIamKubernetesAuthTranslator
return `k8s-aws-v1.${Buffer.from(url).toString('base64url')}`;
}
async decorateClusterDetailsWithAuth(
clusterDetails: AWSClusterDetails,
): Promise<AWSClusterDetails> {
const clusterDetailsWithAuthToken: AWSClusterDetails = Object.assign(
{},
clusterDetails,
);
clusterDetailsWithAuthToken.serviceAccountToken = await this.getBearerToken(
clusterDetails.name,
clusterDetails.assumeRole,
clusterDetails.externalId,
);
return clusterDetailsWithAuthToken;
}
}
@@ -16,7 +16,7 @@
import { AccessToken, TokenCredential } from '@azure/identity';
import { getVoidLogger } from '@backstage/backend-common';
import { AzureIdentityKubernetesAuthTranslator } from './AzureIdentityKubernetesAuthTranslator';
import { AzureIdentityStrategy } from './AzureIdentityStrategy';
const logger = getVoidLogger();
@@ -39,102 +39,94 @@ class StaticTokenCredential implements TokenCredential {
}
}
describe('AzureIdentityKubernetesAuthTranslator tests', () => {
const cd = {
authProvider: 'Azure',
name: 'My Cluster',
url: 'mycluster.privatelink.westeurope.azmk8s.io',
};
describe('AzureIdentityStrategy tests', () => {
afterEach(() => {
jest.useRealTimers();
});
it('should decorate cluster with Azure token', async () => {
const authTranslator = new AzureIdentityKubernetesAuthTranslator(
it('should get Azure token', async () => {
const strategy = new AzureIdentityStrategy(
logger,
new StaticTokenCredential(5 * 60 * 1000),
);
const response = await authTranslator.decorateClusterDetailsWithAuth(cd);
expect(response.serviceAccountToken).toEqual('MY_TOKEN_1');
const credential = await strategy.getCredential();
expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' });
});
it('should re-use token before expiry', async () => {
const authTranslator = new AzureIdentityKubernetesAuthTranslator(
const strategy = new AzureIdentityStrategy(
logger,
new StaticTokenCredential(20 * 60 * 1000),
);
const response = await authTranslator.decorateClusterDetailsWithAuth(cd);
expect(response.serviceAccountToken).toEqual('MY_TOKEN_1');
const credential = await strategy.getCredential();
expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' });
const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd);
expect(response2.serviceAccountToken).toEqual('MY_TOKEN_1');
const credential2 = await strategy.getCredential();
expect(credential2).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' });
});
it('should issue new token 15 minutes befory expiry', async () => {
jest.useFakeTimers();
const authTranslator = new AzureIdentityKubernetesAuthTranslator(
const strategy = new AzureIdentityStrategy(
logger,
new StaticTokenCredential(16 * 60 * 1000), // token expires in 16min
);
const response = await authTranslator.decorateClusterDetailsWithAuth(cd);
expect(response.serviceAccountToken).toEqual('MY_TOKEN_1');
const credential = await strategy.getCredential();
expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' });
jest.setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2mins
const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd);
expect(response2.serviceAccountToken).toEqual('MY_TOKEN_2');
const credential2 = await strategy.getCredential();
expect(credential2).toEqual({ type: 'bearer token', token: 'MY_TOKEN_2' });
});
it('should re-use existing token if there is afailure', async () => {
jest.useFakeTimers();
const authTranslator = new AzureIdentityKubernetesAuthTranslator(
const strategy = new AzureIdentityStrategy(
logger,
new StaticTokenCredential(16 * 60 * 1000), // new tokens expires in 16min
);
const response = await authTranslator.decorateClusterDetailsWithAuth(cd);
expect(response.serviceAccountToken).toEqual('MY_TOKEN_1');
const credential = await strategy.getCredential();
expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' });
jest.setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2min
const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd);
expect(response2.serviceAccountToken).toEqual('MY_TOKEN_2');
const credential2 = await strategy.getCredential();
expect(credential2).toEqual({ type: 'bearer token', token: 'MY_TOKEN_2' });
jest.setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2min
const response3 = await authTranslator.decorateClusterDetailsWithAuth(cd);
expect(response3.serviceAccountToken).toEqual('MY_TOKEN_2');
const credential3 = await strategy.getCredential();
expect(credential3).toEqual({ type: 'bearer token', token: 'MY_TOKEN_2' });
const response4 = await authTranslator.decorateClusterDetailsWithAuth(cd);
expect(response4.serviceAccountToken).toEqual('MY_TOKEN_4');
const credential4 = await strategy.getCredential();
expect(credential4).toEqual({ type: 'bearer token', token: 'MY_TOKEN_4' });
});
it('should throw if existing token expired and failed to fetch a new one', async () => {
jest.useFakeTimers();
const authTranslator = new AzureIdentityKubernetesAuthTranslator(
const strategy = new AzureIdentityStrategy(
logger,
new StaticTokenCredential(16 * 60 * 1000), // new tokens expires in 16min
);
const response = await authTranslator.decorateClusterDetailsWithAuth(cd);
expect(response.serviceAccountToken).toEqual('MY_TOKEN_1');
const credential = await strategy.getCredential();
expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' });
jest.setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2min
const response2 = await authTranslator.decorateClusterDetailsWithAuth(cd);
expect(response2.serviceAccountToken).toEqual('MY_TOKEN_2');
const credential2 = await strategy.getCredential();
expect(credential2).toEqual({ type: 'bearer token', token: 'MY_TOKEN_2' });
jest.setSystemTime(Date.now() + 17 * 60 * 1000); // advance time by 17min
await expect(
authTranslator.decorateClusterDetailsWithAuth(cd),
).rejects.toThrow();
await expect(strategy.getCredential()).rejects.toThrow();
});
});
@@ -15,8 +15,7 @@
*/
import { Logger } from 'winston';
import { KubernetesAuthTranslator } from './types';
import { AzureClusterDetails } from '../types/types';
import { AuthenticationStrategy, KubernetesCredential } from './types';
import {
AccessToken,
DefaultAzureCredential,
@@ -29,9 +28,7 @@ const aksScope = '6dae42f8-4368-4678-94ff-3960e28e3630/.default'; // This scope
*
* @public
*/
export class AzureIdentityKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
export class AzureIdentityStrategy implements AuthenticationStrategy {
private accessToken: AccessToken = { token: '', expiresOnTimestamp: 0 };
private newTokenPromise: Promise<string> | undefined;
@@ -40,28 +37,22 @@ export class AzureIdentityKubernetesAuthTranslator
private readonly tokenCredential: TokenCredential = new DefaultAzureCredential(),
) {}
async decorateClusterDetailsWithAuth(
clusterDetails: AzureClusterDetails,
): Promise<AzureClusterDetails> {
const clusterDetailsWithAuthToken: AzureClusterDetails = Object.assign(
{},
clusterDetails,
);
clusterDetailsWithAuthToken.serviceAccountToken = await this.getToken();
return clusterDetailsWithAuthToken;
}
private async getToken(): Promise<string> {
public async getCredential(): Promise<KubernetesCredential> {
if (!this.tokenRequiresRefresh()) {
return this.accessToken.token;
return { type: 'bearer token', token: this.accessToken.token };
}
if (!this.newTokenPromise) {
this.newTokenPromise = this.fetchNewToken();
}
return this.newTokenPromise;
return this.newTokenPromise
? { type: 'bearer token', token: await this.newTokenPromise }
: { type: 'anonymous' };
}
public validateCluster(): Error[] {
return [];
}
private async fetchNewToken(): Promise<string> {
@@ -0,0 +1,80 @@
/*
* 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 {
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
KubernetesRequestAuth,
} from '@backstage/plugin-kubernetes-common';
import { DispatchStrategy } from './DispatchStrategy';
import { ClusterDetails } from '../types';
import { AuthenticationStrategy } from './types';
describe('getCredential', () => {
let strategy: DispatchStrategy;
let mockStrategy: jest.Mocked<AuthenticationStrategy>;
const authObject: KubernetesRequestAuth = {};
beforeEach(() => {
mockStrategy = {
getCredential: jest.fn(),
validateCluster: jest.fn(),
};
strategy = new DispatchStrategy({
authStrategyMap: { google: mockStrategy },
});
});
it('gets credential if specified auth provider is in the strategy map', async () => {
const clusterDetails: ClusterDetails = {
url: 'notanything.com',
name: 'randomName',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
};
mockStrategy.getCredential.mockResolvedValue({
type: 'bearer token',
token: 'added by mock strategy',
});
const returnedValue = await strategy.getCredential(
clusterDetails,
authObject,
);
expect(mockStrategy.getCredential).toHaveBeenCalledWith(
clusterDetails,
authObject,
);
expect(returnedValue).toStrictEqual({
type: 'bearer token',
token: 'added by mock strategy',
});
});
it('throws an error when asked for a strategy for an unsupported auth type', () => {
expect(() =>
strategy.getCredential(
{
name: 'test-cluster',
url: 'anything.com',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'linode' },
},
authObject,
),
).toThrow(
'authProvider "linode" has no AuthenticationStrategy associated with it',
);
});
});
@@ -0,0 +1,70 @@
/*
* 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 { AuthenticationStrategy, KubernetesCredential } from './types';
import { AuthMetadata, ClusterDetails } from '../types';
import {
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
KubernetesRequestAuth,
} from '@backstage/plugin-kubernetes-common';
/**
*
* @public
*/
export type DispatchStrategyOptions = {
authStrategyMap: {
[key: string]: AuthenticationStrategy;
};
};
/**
* used to direct a KubernetesAuthProvider to its corresponding AuthenticationStrategy
* @public
*/
export class DispatchStrategy implements AuthenticationStrategy {
private readonly strategyMap: { [key: string]: AuthenticationStrategy };
constructor(options: DispatchStrategyOptions) {
this.strategyMap = options.authStrategyMap;
}
public getCredential(
clusterDetails: ClusterDetails,
auth: KubernetesRequestAuth,
): Promise<KubernetesCredential> {
const authProvider =
clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AUTH_PROVIDER];
if (this.strategyMap[authProvider]) {
return this.strategyMap[authProvider].getCredential(clusterDetails, auth);
}
throw new Error(
`authProvider "${authProvider}" has no AuthenticationStrategy associated with it`,
);
}
public validateCluster(authMetadata: AuthMetadata): Error[] {
const authProvider = authMetadata[ANNOTATION_KUBERNETES_AUTH_PROVIDER];
const strategy = this.strategyMap[authProvider];
if (!strategy) {
return [
new Error(
`authProvider "${authProvider}" has no config associated with it`,
),
];
}
return strategy.validateCluster(authMetadata);
}
}
@@ -13,34 +13,27 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KubernetesAuthTranslator } from './types';
import { GKEClusterDetails } from '../types/types';
import { AuthenticationStrategy, KubernetesCredential } from './types';
import * as container from '@google-cloud/container';
/**
*
* @public
*/
export class GoogleServiceAccountAuthTranslator
implements KubernetesAuthTranslator
{
async decorateClusterDetailsWithAuth(
clusterDetails: GKEClusterDetails,
): Promise<GKEClusterDetails> {
const clusterDetailsWithAuthToken: GKEClusterDetails = Object.assign(
{},
clusterDetails,
);
export class GoogleServiceAccountStrategy implements AuthenticationStrategy {
public async getCredential(): Promise<KubernetesCredential> {
const client = new container.v1.ClusterManagerClient();
const accessToken = await client.auth.getAccessToken();
const token = await client.auth.getAccessToken();
if (accessToken) {
clusterDetailsWithAuthToken.serviceAccountToken = accessToken;
} else {
if (!token) {
throw new Error(
'Unable to obtain access token for the current Google Application Default Credentials',
);
}
return clusterDetailsWithAuthToken;
return { type: 'bearer token', token };
}
public validateCluster(): Error[] {
return [];
}
}
@@ -14,34 +14,28 @@
* limitations under the License.
*/
import { KubernetesAuthTranslator } from './types';
import { GKEClusterDetails } from '../types/types';
import { AuthenticationStrategy, KubernetesCredential } from './types';
import { ClusterDetails } from '../types/types';
import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
/**
*
* @public
*/
export class GoogleKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
async decorateClusterDetailsWithAuth(
clusterDetails: GKEClusterDetails,
authConfig: KubernetesRequestAuth,
): Promise<GKEClusterDetails> {
const clusterDetailsWithAuthToken: GKEClusterDetails = Object.assign(
{},
clusterDetails,
);
const authToken: string | undefined = authConfig.google;
if (authToken) {
clusterDetailsWithAuthToken.serviceAccountToken = authToken;
} else {
export class GoogleStrategy implements AuthenticationStrategy {
public async getCredential(
_: ClusterDetails,
requestAuth: KubernetesRequestAuth,
): Promise<KubernetesCredential> {
const token = requestAuth.google;
if (!token) {
throw new Error(
'Google token not found under auth.google in request body',
);
}
return clusterDetailsWithAuthToken;
return { type: 'bearer token', token: token as string };
}
public validateCluster(): Error[] {
return [];
}
}
@@ -0,0 +1,85 @@
/*
* 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 { ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER } from '@backstage/plugin-kubernetes-common';
import { OidcStrategy } from './OidcStrategy';
describe('OidcStrategy', () => {
let strategy: OidcStrategy;
beforeEach(() => {
strategy = new OidcStrategy();
});
describe('getCredential', () => {
it('returns auth token', async () => {
const credential = await strategy.getCredential(
{
name: 'test',
url: '',
authMetadata: {
[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'okta',
},
},
{
oidc: { okta: 'fakeToken' },
},
);
expect(credential).toStrictEqual({
type: 'bearer token',
token: 'fakeToken',
});
});
it('fails when token provider is not configured', async () => {
await expect(
strategy.getCredential(
{
name: 'test',
url: '',
authMetadata: {},
},
{},
),
).rejects.toThrow(
'oidc authProvider requires a configured oidcTokenProvider',
);
});
it('fails when token is not included in request body', async () => {
await expect(
strategy.getCredential(
{
name: 'test',
url: '',
authMetadata: {
[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'okta',
},
},
{},
),
).rejects.toThrow('Auth token not found under oidc.okta in request body');
});
});
describe('validateCluster', () => {
it('fails when token provider is not specified', () => {
expect(strategy.validateCluster({})).toContainEqual(
new Error(`Must specify a token provider for 'oidc' strategy`),
);
});
});
});
@@ -0,0 +1,60 @@
/*
* 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 { JsonObject } from '@backstage/types';
import {
ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER,
KubernetesRequestAuth,
} from '@backstage/plugin-kubernetes-common';
import { AuthenticationStrategy, KubernetesCredential } from './types';
import { AuthMetadata, ClusterDetails } from '../types/types';
/**
*
* @public
*/
export class OidcStrategy implements AuthenticationStrategy {
public async getCredential(
clusterDetails: ClusterDetails,
authConfig: KubernetesRequestAuth,
): Promise<KubernetesCredential> {
const oidcTokenProvider =
clusterDetails.authMetadata[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER];
if (!oidcTokenProvider || oidcTokenProvider === '') {
throw new Error(
`oidc authProvider requires a configured oidcTokenProvider`,
);
}
const token = (authConfig.oidc as JsonObject | null)?.[oidcTokenProvider];
if (!token) {
throw new Error(
`Auth token not found under oidc.${oidcTokenProvider} in request body`,
);
}
return { type: 'bearer token', token: token as string };
}
public validateCluster(authMetadata: AuthMetadata): Error[] {
const oidcTokenProvider =
authMetadata[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER];
if (!oidcTokenProvider || oidcTokenProvider === '') {
return [new Error(`Must specify a token provider for 'oidc' strategy`)];
}
return [];
}
}
@@ -0,0 +1,60 @@
/*
* Copyright 2023 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 { ServiceAccountStrategy } from './ServiceAccountStrategy';
import mockFs from 'mock-fs';
describe('ServiceAccountStrategy', () => {
describe('#getCredential', () => {
it('reads bearer token from config', async () => {
const strategy = new ServiceAccountStrategy();
const credential = await strategy.getCredential({
name: '',
url: '',
authMetadata: { serviceAccountToken: 'from config' },
});
expect(credential).toStrictEqual({
type: 'bearer token',
token: 'from config',
});
});
describe('when serviceAccountToken is absent from config', () => {
afterEach(() => {
mockFs.restore();
});
it('reads in-cluster token', async () => {
mockFs({
'/var/run/secrets/kubernetes.io/serviceaccount/token':
'in-cluster-token',
});
const strategy = new ServiceAccountStrategy();
const credential = await strategy.getCredential({
name: '',
url: '',
authMetadata: {},
});
expect(credential).toStrictEqual({
type: 'bearer token',
token: 'in-cluster-token',
});
});
});
});
});
@@ -0,0 +1,48 @@
/*
* 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 { KubeConfig, User } from '@kubernetes/client-node';
import fs from 'fs-extra';
import { AuthenticationStrategy, KubernetesCredential } from './types';
import { ClusterDetails } from '../types/types';
/**
*
* @public
*/
export class ServiceAccountStrategy implements AuthenticationStrategy {
public async getCredential(
clusterDetails: ClusterDetails,
): Promise<KubernetesCredential> {
const token = clusterDetails.authMetadata.serviceAccountToken;
if (token) {
return { type: 'bearer token', token };
}
const kc = new KubeConfig();
kc.loadFromCluster();
// loadFromCluster is guaranteed to populate the user
const user = kc.getCurrentUser() as User;
return {
type: 'bearer token',
token: fs.readFileSync(user.authProvider.config.tokenFile).toString(),
};
}
public validateCluster(): Error[] {
return [];
}
}
@@ -14,12 +14,13 @@
* limitations under the License.
*/
export * from './AksKubernetesAuthTranslator';
export * from './AwsIamKubernetesAuthTranslator';
export * from './AzureIdentityKubernetesAuthTranslator';
export * from './GoogleKubernetesAuthTranslator';
export * from './GoogleServiceAccountAuthProvider';
export * from './DispatchingKubernetesAuthTranslator';
export * from './NoopKubernetesAuthTranslator';
export * from './OidcKubernetesAuthTranslator';
export * from './AksStrategy';
export * from './AnonymousStrategy';
export * from './AwsIamStrategy';
export * from './AzureIdentityStrategy';
export * from './GoogleStrategy';
export * from './GoogleServiceAccountStrategy';
export * from './DispatchStrategy';
export * from './ServiceAccountStrategy';
export * from './OidcStrategy';
export * from './types';
@@ -14,17 +14,25 @@
* limitations under the License.
*/
import { KubernetesAuthTranslator } from './types';
import { ServiceAccountClusterDetails } from '../types/types';
import { AuthMetadata, ClusterDetails } from '../types/types';
import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
/**
* Authentication data used to make a request to Kubernetes
* @public
*/
export type KubernetesCredential =
| { type: 'bearer token'; token: string }
| { type: 'anonymous' };
/**
*
* @public
*/
export class NoopKubernetesAuthTranslator implements KubernetesAuthTranslator {
async decorateClusterDetailsWithAuth(
clusterDetails: ServiceAccountClusterDetails,
): Promise<ServiceAccountClusterDetails> {
return clusterDetails;
}
export interface AuthenticationStrategy {
getCredential(
clusterDetails: ClusterDetails,
authConfig: KubernetesRequestAuth,
): Promise<KubernetesCredential>;
validateCluster(authMetadata: AuthMetadata): Error[];
}
@@ -15,22 +15,29 @@
*/
import '@backstage/backend-common';
import {
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE,
ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID,
ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER,
} from '@backstage/plugin-kubernetes-common';
import { CatalogClusterLocator } from './CatalogClusterLocator';
import { CatalogApi } from '@backstage/catalog-client';
import { ClusterDetails } from '../types/types';
const mockCatalogApi = {
getEntityByRef: jest.fn(),
getEntities: async () => ({
items: [
{
apiVersion: 'version',
kind: 'User',
apiVersion: 'backstage.io/v1alpha1',
kind: 'Resource',
metadata: {
annotations: {
'kubernetes.io/api-server': 'https://apiserver.com',
'kubernetes.io/api-server-certificate-authority': 'caData',
'kubernetes.io/auth-provider': 'oidc',
'kubernetes.io/oidc-token-provider': 'google',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'oidc',
[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google',
'kubernetes.io/skip-metrics-lookup': 'true',
'kubernetes.io/skip-tls-verify': 'true',
'kubernetes.io/dashboard-url': 'my-url',
@@ -41,16 +48,16 @@ const mockCatalogApi = {
},
},
{
apiVersion: 'version',
kind: 'User',
apiVersion: 'backstage.io/v1alpha1',
kind: 'Resource',
metadata: {
annotations: {
'kubernetes.io/api-server': 'https://apiserver.com',
'kubernetes.io/api-server-certificate-authority': 'caData',
'kubernetes.io/auth-provider': 'aws',
'kubernetes.io/aws-assume-role': 'my-role',
'kubernetes.io/aws-external-id': 'my-id',
'kubernetes.io/oidc-token-provider': 'google',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws',
[ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: 'my-role',
[ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: 'my-id',
[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google',
'kubernetes.io/dashboard-url': 'my-url',
'kubernetes.io/dashboard-app': 'my-app',
},
@@ -86,12 +93,20 @@ describe('CatalogClusterLocator', () => {
const result = await clusterSupplier.getClusters();
expect(result).toHaveLength(2);
expect(result[0]).toStrictEqual({
expect(result[0]).toStrictEqual<ClusterDetails>({
name: 'owned',
url: 'https://apiserver.com',
caData: 'caData',
authProvider: 'oidc',
oidcTokenProvider: 'google',
authMetadata: {
'kubernetes.io/api-server': 'https://apiserver.com',
'kubernetes.io/api-server-certificate-authority': 'caData',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'oidc',
[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google',
'kubernetes.io/skip-metrics-lookup': 'true',
'kubernetes.io/skip-tls-verify': 'true',
'kubernetes.io/dashboard-url': 'my-url',
'kubernetes.io/dashboard-app': 'my-app',
},
skipMetricsLookup: true,
skipTLSVerify: true,
dashboardUrl: 'my-url',
@@ -105,14 +120,20 @@ describe('CatalogClusterLocator', () => {
const result = await clusterSupplier.getClusters();
expect(result).toHaveLength(2);
expect(result[1]).toStrictEqual({
expect(result[1]).toStrictEqual<ClusterDetails>({
name: 'owned',
url: 'https://apiserver.com',
caData: 'caData',
authProvider: 'aws',
assumeRole: 'my-role',
externalId: 'my-id',
oidcTokenProvider: 'google',
authMetadata: {
'kubernetes.io/api-server': 'https://apiserver.com',
'kubernetes.io/api-server-certificate-authority': 'caData',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws',
[ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: 'my-role',
[ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: 'my-id',
[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google',
'kubernetes.io/dashboard-url': 'my-url',
'kubernetes.io/dashboard-app': 'my-app',
},
skipMetricsLookup: false,
skipTLSVerify: false,
dashboardUrl: 'my-url',
@@ -14,23 +14,16 @@
* limitations under the License.
*/
import {
AWSClusterDetails,
ClusterDetails,
KubernetesClustersSupplier,
} from '../types/types';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
import { CATALOG_FILTER_EXISTS, CatalogApi } from '@backstage/catalog-client';
import {
ANNOTATION_KUBERNETES_API_SERVER,
ANNOTATION_KUBERNETES_API_SERVER_CA,
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER,
ANNOTATION_KUBERNETES_SKIP_METRICS_LOOKUP,
ANNOTATION_KUBERNETES_SKIP_TLS_VERIFY,
ANNOTATION_KUBERNETES_DASHBOARD_URL,
ANNOTATION_KUBERNETES_DASHBOARD_APP,
ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID,
ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE,
} from '@backstage/plugin-kubernetes-common';
export class CatalogClusterLocator implements KubernetesClustersSupplier {
@@ -64,14 +57,9 @@ export class CatalogClusterLocator implements KubernetesClustersSupplier {
const clusterDetails: ClusterDetails = {
name: entity.metadata.name,
url: entity.metadata.annotations![ANNOTATION_KUBERNETES_API_SERVER]!,
authMetadata: entity.metadata.annotations!,
caData:
entity.metadata.annotations![ANNOTATION_KUBERNETES_API_SERVER_CA]!,
authProvider:
entity.metadata.annotations![ANNOTATION_KUBERNETES_AUTH_PROVIDER]!,
oidcTokenProvider:
entity.metadata.annotations![
ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER
]!,
skipMetricsLookup:
entity.metadata.annotations![
ANNOTATION_KUBERNETES_SKIP_METRICS_LOOKUP
@@ -86,20 +74,6 @@ export class CatalogClusterLocator implements KubernetesClustersSupplier {
entity.metadata.annotations![ANNOTATION_KUBERNETES_DASHBOARD_APP]!,
};
if (clusterDetails.authProvider === 'aws') {
return {
...clusterDetails,
assumeRole:
entity.metadata.annotations![
ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE
]!,
externalId:
entity.metadata.annotations![
ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID
]!,
} as AWSClusterDetails;
}
return clusterDetails;
});
}
@@ -16,15 +16,31 @@
import '@backstage/backend-common';
import { ConfigReader, Config } from '@backstage/config';
import {
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE,
ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID,
} from '@backstage/plugin-kubernetes-common';
import { ConfigClusterLocator } from './ConfigClusterLocator';
import { ClusterDetails } from '../types/types';
import { AuthenticationStrategy } from '../auth';
describe('ConfigClusterLocator', () => {
let authStrategy: jest.Mocked<AuthenticationStrategy>;
beforeEach(() => {
authStrategy = {
getCredential: jest.fn(),
validateCluster: jest.fn().mockReturnValue([]),
};
});
it('empty clusters returns empty cluster details', async () => {
const config: Config = new ConfigReader({
clusters: [],
});
const sut = ConfigClusterLocator.fromConfig(config);
const sut = ConfigClusterLocator.fromConfig(config, authStrategy);
const result = await sut.getClusters();
@@ -42,16 +58,17 @@ describe('ConfigClusterLocator', () => {
],
});
const sut = ConfigClusterLocator.fromConfig(config);
const sut = ConfigClusterLocator.fromConfig(config, authStrategy);
const result = await sut.getClusters();
expect(result).toStrictEqual([
expect(result).toStrictEqual<ClusterDetails[]>([
{
name: 'cluster1',
serviceAccountToken: undefined,
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount',
},
skipMetricsLookup: false,
skipTLSVerify: false,
caData: undefined,
@@ -82,17 +99,19 @@ describe('ConfigClusterLocator', () => {
],
});
const sut = ConfigClusterLocator.fromConfig(config);
const sut = ConfigClusterLocator.fromConfig(config, authStrategy);
const result = await sut.getClusters();
expect(result).toStrictEqual([
expect(result).toStrictEqual<ClusterDetails[]>([
{
name: 'cluster1',
dashboardUrl: 'https://k8s.foo.com',
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount',
serviceAccountToken: 'token',
},
skipTLSVerify: false,
skipMetricsLookup: true,
caData: undefined,
@@ -100,9 +119,8 @@ describe('ConfigClusterLocator', () => {
},
{
name: 'cluster2',
serviceAccountToken: undefined,
url: 'http://localhost:8081',
authProvider: 'google',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
skipTLSVerify: true,
skipMetricsLookup: false,
caData: undefined,
@@ -111,74 +129,50 @@ describe('ConfigClusterLocator', () => {
]);
});
it('one aws cluster with assumeRole and one without', async () => {
it('copies "assumeRole" config value into metadata', async () => {
const config: Config = new ConfigReader({
clusters: [
{
name: 'cluster1',
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'aws',
skipTLSVerify: false,
},
{
assumeRole: 'SomeRole',
name: 'cluster2',
url: 'http://localhost:8081',
authProvider: 'aws',
skipTLSVerify: true,
},
{
assumeRole: 'SomeRole',
name: 'cluster2',
externalId: 'SomeExternalId',
url: 'http://localhost:8081',
authProvider: 'aws',
skipTLSVerify: true,
},
],
});
const sut = ConfigClusterLocator.fromConfig(config);
const sut = ConfigClusterLocator.fromConfig(config, authStrategy);
const result = await sut.getClusters();
expect(result).toStrictEqual([
expect(result).toMatchObject([
{
assumeRole: undefined,
name: 'cluster1',
serviceAccountToken: 'token',
externalId: undefined,
url: 'http://localhost:8080',
authProvider: 'aws',
skipTLSVerify: false,
skipMetricsLookup: false,
caData: undefined,
caFile: undefined,
authMetadata: expect.objectContaining({
[ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: 'SomeRole',
}),
},
]);
});
it('copies "externalId" config value into metadata', async () => {
const config: Config = new ConfigReader({
clusters: [
{
name: 'cluster1',
url: 'http://localhost:8080',
authProvider: 'aws',
externalId: 'SomeExternalId',
},
],
});
const sut = ConfigClusterLocator.fromConfig(config, authStrategy);
const result = await sut.getClusters();
expect(result).toMatchObject([
{
assumeRole: 'SomeRole',
name: 'cluster2',
externalId: undefined,
serviceAccountToken: undefined,
url: 'http://localhost:8081',
authProvider: 'aws',
skipTLSVerify: true,
skipMetricsLookup: false,
caData: undefined,
caFile: undefined,
},
{
assumeRole: 'SomeRole',
name: 'cluster2',
externalId: 'SomeExternalId',
url: 'http://localhost:8081',
serviceAccountToken: undefined,
authProvider: 'aws',
skipTLSVerify: true,
skipMetricsLookup: false,
caData: undefined,
caFile: undefined,
authMetadata: expect.objectContaining({
[ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: 'SomeExternalId',
}),
},
]);
});
@@ -200,16 +194,17 @@ describe('ConfigClusterLocator', () => {
],
});
const sut = ConfigClusterLocator.fromConfig(config);
const sut = ConfigClusterLocator.fromConfig(config, authStrategy);
const result = await sut.getClusters();
expect(result).toStrictEqual([
expect(result).toStrictEqual<ClusterDetails[]>([
{
name: 'cluster1',
serviceAccountToken: undefined,
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount',
},
skipMetricsLookup: false,
skipTLSVerify: false,
caData: undefined,
@@ -237,16 +232,17 @@ describe('ConfigClusterLocator', () => {
],
});
const sut = ConfigClusterLocator.fromConfig(config);
const sut = ConfigClusterLocator.fromConfig(config, authStrategy);
const result = await sut.getClusters();
expect(result).toStrictEqual([
expect(result).toStrictEqual<ClusterDetails[]>([
{
name: 'cluster1',
serviceAccountToken: undefined,
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount',
},
skipMetricsLookup: false,
skipTLSVerify: false,
caData: undefined,
@@ -257,21 +253,6 @@ describe('ConfigClusterLocator', () => {
]);
});
it('supports aks authProvider', async () => {
const cluster = {
name: 'aks-cluster',
url: 'https://aks.test',
authProvider: 'aks',
};
const sut = ConfigClusterLocator.fromConfig(
new ConfigReader({ clusters: [cluster] }),
);
const result = await sut.getClusters();
expect(result).toMatchObject([cluster]);
});
it('has cluster level defined customResources returns clusterDetails with those CRDs', async () => {
const config: Config = new ConfigReader({
clusters: [
@@ -290,16 +271,17 @@ describe('ConfigClusterLocator', () => {
],
});
const sut = ConfigClusterLocator.fromConfig(config);
const sut = ConfigClusterLocator.fromConfig(config, authStrategy);
const result = await sut.getClusters();
expect(result).toStrictEqual([
expect(result).toStrictEqual<ClusterDetails[]>([
{
name: 'cluster1',
serviceAccountToken: undefined,
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount',
},
skipMetricsLookup: false,
skipTLSVerify: false,
caData: undefined,
@@ -314,4 +296,21 @@ describe('ConfigClusterLocator', () => {
},
]);
});
it('wraps validation errors from auth strategy', () => {
const config: Config = new ConfigReader({
clusters: [
{
name: 'cluster1',
url: 'http://localhost:8080',
authProvider: 'authProvider',
},
],
});
authStrategy.validateCluster.mockReturnValue([new Error('mock error')]);
expect(() => ConfigClusterLocator.fromConfig(config, authStrategy)).toThrow(
`Invalid cluster 'cluster1': mock error`,
);
});
});
@@ -15,7 +15,14 @@
*/
import { Config } from '@backstage/config';
import {
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE,
ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID,
ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER,
} from '@backstage/plugin-kubernetes-common';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
import { AuthenticationStrategy } from '../auth';
export class ConfigClusterLocator implements KubernetesClustersSupplier {
private readonly clusterDetails: ClusterDetails[];
@@ -24,21 +31,24 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier {
this.clusterDetails = clusterDetails;
}
static fromConfig(config: Config): ConfigClusterLocator {
// TODO: Add validation that authProvider is required and serviceAccountToken
// is required if authProvider is serviceAccount
static fromConfig(
config: Config,
authStrategy: AuthenticationStrategy,
): ConfigClusterLocator {
return new ConfigClusterLocator(
config.getConfigArray('clusters').map(c => {
const authProvider = c.getString('authProvider');
const clusterDetails: ClusterDetails = {
name: c.getString('name'),
url: c.getString('url'),
serviceAccountToken: c.getOptionalString('serviceAccountToken'),
skipTLSVerify: c.getOptionalBoolean('skipTLSVerify') ?? false,
skipMetricsLookup: c.getOptionalBoolean('skipMetricsLookup') ?? false,
caData: c.getOptionalString('caData'),
caFile: c.getOptionalString('caFile'),
authProvider: authProvider,
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: authProvider,
...ConfigClusterLocator.parseAuthMetadata(c),
},
};
const customResources = c.getOptionalConfigArray('customResources');
@@ -64,43 +74,48 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier {
clusterDetails.dashboardParameters = c.get('dashboardParameters');
}
switch (authProvider) {
case 'google': {
return clusterDetails;
}
case 'aws': {
const assumeRole = c.getOptionalString('assumeRole');
const externalId = c.getOptionalString('externalId');
return { assumeRole, externalId, ...clusterDetails };
}
case 'azure': {
return clusterDetails;
}
case 'oidc': {
const oidcTokenProvider = c.getString('oidcTokenProvider');
return { oidcTokenProvider, ...clusterDetails };
}
case 'serviceAccount': {
return clusterDetails;
}
case 'googleServiceAccount': {
return clusterDetails;
}
case 'aks': {
return clusterDetails;
}
default: {
throw new Error(
`authProvider "${authProvider}" has no config associated with it`,
);
}
const validationErrors = authStrategy.validateCluster(
clusterDetails.authMetadata,
);
if (validationErrors.length !== 0) {
throw new Error(
`Invalid cluster '${clusterDetails.name}': ${validationErrors
.map(e => e.message)
.join(', ')}`,
);
}
return clusterDetails;
}),
);
}
private static parseAuthMetadata(
clusterConfig: Config,
): Record<string, string> | undefined {
const serviceAccountToken = clusterConfig.getOptionalString(
'serviceAccountToken',
);
const assumeRole = clusterConfig.getOptionalString('assumeRole');
const externalId = clusterConfig.getOptionalString('externalId');
const oidcTokenProvider =
clusterConfig.getOptionalString('oidcTokenProvider');
return serviceAccountToken || assumeRole || externalId || oidcTokenProvider
? {
...(serviceAccountToken && { serviceAccountToken }),
...(assumeRole && {
[ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: assumeRole,
}),
...(externalId && {
[ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: externalId,
}),
...(oidcTokenProvider && {
[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: oidcTokenProvider,
}),
}
: undefined;
}
async getClusters(): Promise<ClusterDetails[]> {
return this.clusterDetails;
}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { ANNOTATION_KUBERNETES_AUTH_PROVIDER } from '@backstage/plugin-kubernetes-common';
import '@backstage/backend-common';
import { ConfigReader, Config } from '@backstage/config';
import { GkeClusterLocator } from './GkeClusterLocator';
@@ -104,9 +105,9 @@ describe('GkeClusterLocator', () => {
expect(result).toStrictEqual([
{
authProvider: 'google',
name: 'some-cluster',
url: 'https://1.2.3.4',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
skipTLSVerify: false,
skipMetricsLookup: true,
},
@@ -141,9 +142,9 @@ describe('GkeClusterLocator', () => {
expect(result).toStrictEqual([
{
authProvider: 'google',
name: 'some-cluster',
url: 'https://1.2.3.4',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
skipTLSVerify: false,
skipMetricsLookup: false,
},
@@ -183,16 +184,16 @@ describe('GkeClusterLocator', () => {
expect(result).toStrictEqual([
{
authProvider: 'google',
name: 'some-cluster',
url: 'https://1.2.3.4',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
skipTLSVerify: false,
skipMetricsLookup: false,
},
{
authProvider: 'google',
name: 'some-other-cluster',
url: 'https://6.7.8.9',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
skipTLSVerify: false,
skipMetricsLookup: false,
},
@@ -238,16 +239,16 @@ describe('GkeClusterLocator', () => {
expect(result).toStrictEqual([
{
authProvider: 'google',
name: 'some-cluster',
url: 'https://1.2.3.4',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
skipTLSVerify: false,
skipMetricsLookup: false,
},
{
authProvider: 'google',
name: 'some-other-cluster',
url: 'https://6.7.8.9',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
skipTLSVerify: false,
skipMetricsLookup: false,
},
@@ -299,9 +300,9 @@ describe('GkeClusterLocator', () => {
expect(result).toStrictEqual([
{
authProvider: 'google',
name: 'some-cluster',
url: 'https://1.2.3.4',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
skipTLSVerify: false,
skipMetricsLookup: false,
},
@@ -363,9 +364,9 @@ describe('GkeClusterLocator', () => {
expect(result).toStrictEqual([
{
authProvider: 'google',
name: 'some-cluster',
url: 'https://1.2.3.4',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
skipTLSVerify: false,
skipMetricsLookup: true,
dashboardApp: 'gke',
@@ -14,16 +14,13 @@
* limitations under the License.
*/
import { ANNOTATION_KUBERNETES_AUTH_PROVIDER } from '@backstage/plugin-kubernetes-common';
import { Config } from '@backstage/config';
import { ForwardedError } from '@backstage/errors';
import * as container from '@google-cloud/container';
import { Duration } from 'luxon';
import { runPeriodically } from '../service/runPeriodically';
import {
ClusterDetails,
GKEClusterDetails,
KubernetesClustersSupplier,
} from '../types/types';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
interface MatchResourceLabelEntry {
key: string;
@@ -43,7 +40,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier {
constructor(
private readonly options: GkeClusterLocatorOptions,
private readonly client: container.v1.ClusterManagerClient,
private clusterDetails: GKEClusterDetails[] | undefined = undefined,
private clusterDetails: ClusterDetails[] | undefined = undefined,
private hasClusterDetails: boolean = false,
) {}
@@ -124,7 +121,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier {
// TODO filter out clusters which don't have name or endpoint
name: r.name ?? 'unknown',
url: `https://${r.endpoint ?? ''}`,
authProvider: 'google',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
skipTLSVerify,
skipMetricsLookup,
...(exposeDashboard
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { ANNOTATION_KUBERNETES_AUTH_PROVIDER } from '@backstage/plugin-kubernetes-common';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
export class LocalKubectlProxyClusterLocator
@@ -26,7 +27,9 @@ export class LocalKubectlProxyClusterLocator
{
name: 'local',
url: 'http:/localhost:8001',
authProvider: 'localKubectlProxy',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'localKubectlProxy',
},
skipMetricsLookup: true,
},
];
@@ -15,8 +15,11 @@
*/
import { Config, ConfigReader } from '@backstage/config';
import { getCombinedClusterSupplier } from './index';
import { CatalogApi } from '@backstage/catalog-client';
import { ANNOTATION_KUBERNETES_AUTH_PROVIDER } from '@backstage/plugin-kubernetes-common';
import { getCombinedClusterSupplier } from './index';
import { ClusterDetails } from '../types/types';
import { AuthenticationStrategy, DispatchStrategy } from '../auth';
describe('getCombinedClusterSupplier', () => {
let catalogApi: CatalogApi;
@@ -47,16 +50,26 @@ describe('getCombinedClusterSupplier', () => {
},
'ctx',
);
const mockStrategy: jest.Mocked<AuthenticationStrategy> = {
getCredential: jest.fn(),
validateCluster: jest.fn().mockReturnValue([]),
};
const clusterSupplier = getCombinedClusterSupplier(config, catalogApi);
const clusterSupplier = getCombinedClusterSupplier(
config,
catalogApi,
mockStrategy,
);
const result = await clusterSupplier.getClusters();
expect(result).toStrictEqual([
expect(result).toStrictEqual<ClusterDetails[]>([
{
name: 'cluster1',
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount',
serviceAccountToken: 'token',
},
skipMetricsLookup: false,
skipTLSVerify: false,
caData: undefined,
@@ -64,9 +77,8 @@ describe('getCombinedClusterSupplier', () => {
},
{
name: 'cluster2',
serviceAccountToken: undefined,
url: 'http://localhost:8081',
authProvider: 'google',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
skipMetricsLookup: false,
skipTLSVerify: false,
caData: undefined,
@@ -77,35 +89,17 @@ describe('getCombinedClusterSupplier', () => {
it('throws an error when using an unsupported cluster locator', async () => {
const config: Config = new ConfigReader(
{
kubernetes: {
clusterLocatorMethods: [
{
type: 'config',
clusters: [
{
name: 'cluster1',
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
},
{
name: 'cluster2',
url: 'http://localhost:8081',
authProvider: 'google',
},
],
},
{
type: 'magic',
},
],
},
},
{ kubernetes: { clusterLocatorMethods: [{ type: 'magic' }] } },
'ctx',
);
expect(() => getCombinedClusterSupplier(config, catalogApi)).toThrow(
expect(() =>
getCombinedClusterSupplier(
config,
catalogApi,
new DispatchStrategy({ authStrategyMap: {} }),
),
).toThrow(
new Error('Unsupported kubernetes.clusterLocatorMethods: "magic"'),
);
});
@@ -17,6 +17,7 @@
import { Config } from '@backstage/config';
import { Duration } from 'luxon';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
import { AuthenticationStrategy } from '../auth/types';
import { ConfigClusterLocator } from './ConfigClusterLocator';
import { GkeClusterLocator } from './GkeClusterLocator';
import { CatalogClusterLocator } from './CatalogClusterLocator';
@@ -42,6 +43,7 @@ class CombinedClustersSupplier implements KubernetesClustersSupplier {
export const getCombinedClusterSupplier = (
rootConfig: Config,
catalogClient: CatalogApi,
authStrategy: AuthenticationStrategy,
refreshInterval: Duration | undefined = undefined,
): KubernetesClustersSupplier => {
const clusterSuppliers = rootConfig
@@ -54,7 +56,10 @@ export const getCombinedClusterSupplier = (
case 'localKubectlProxy':
return new LocalKubectlProxyClusterLocator();
case 'config':
return ConfigClusterLocator.fromConfig(clusterLocatorMethod);
return ConfigClusterLocator.fromConfig(
clusterLocatorMethod,
authStrategy,
);
case 'gke':
return GkeClusterLocator.fromConfig(
clusterLocatorMethod,
+1 -1
View File
@@ -20,6 +20,6 @@
* @packageDocumentation
*/
export * from './kubernetes-auth-translator';
export * from './auth';
export * from './service';
export * from './types';
@@ -1,72 +0,0 @@
/*
* 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 { DispatchingKubernetesAuthTranslator } from './DispatchingKubernetesAuthTranslator';
import { ClusterDetails } from '../types';
import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
import { KubernetesAuthTranslator } from './types';
describe('decorateClusterDetailsWithAuth', () => {
let authTranslator: DispatchingKubernetesAuthTranslator;
let mockTranslator: jest.Mocked<KubernetesAuthTranslator>;
const authObject: KubernetesRequestAuth = {};
beforeEach(() => {
mockTranslator = { decorateClusterDetailsWithAuth: jest.fn() };
authTranslator = new DispatchingKubernetesAuthTranslator({
authTranslatorMap: { google: mockTranslator },
});
});
it('can decorate cluster details if the auth provider is in the translator map', async () => {
const expectedClusterDetails: ClusterDetails = {
url: 'notanything.com',
name: 'randomName',
authProvider: 'google',
serviceAccountToken: 'added by mock translator',
};
mockTranslator.decorateClusterDetailsWithAuth.mockResolvedValue(
expectedClusterDetails,
);
const returnedValue = await authTranslator.decorateClusterDetailsWithAuth(
{ name: 'googleCluster', url: 'anything.com', authProvider: 'google' },
authObject,
);
expect(mockTranslator.decorateClusterDetailsWithAuth).toHaveBeenCalledWith(
{ name: 'googleCluster', url: 'anything.com', authProvider: 'google' },
authObject,
);
expect(returnedValue).toBe(expectedClusterDetails);
});
it('throws an error when asked for an auth translator for an unsupported auth type', () => {
expect(() =>
authTranslator.decorateClusterDetailsWithAuth(
{
name: 'test-cluster',
url: 'anything.com',
authProvider: 'linode',
},
authObject,
),
).toThrow(
'authProvider "linode" has no KubernetesAuthTranslator associated with it',
);
});
});
@@ -1,56 +0,0 @@
/*
* 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 { ClusterDetails } from '../types';
import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
/**
*
* @public
*/
export type DispatchingKubernetesAuthTranslatorOptions = {
authTranslatorMap: {
[key: string]: KubernetesAuthTranslator;
};
};
/**
* used to direct a KubernetesAuthProvider to its corresponding KubernetesAuthTranslator
* @public
*/
export class DispatchingKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
private readonly translatorMap: { [key: string]: KubernetesAuthTranslator };
constructor(options: DispatchingKubernetesAuthTranslatorOptions) {
this.translatorMap = options.authTranslatorMap;
}
public decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
auth: KubernetesRequestAuth,
) {
if (this.translatorMap[clusterDetails.authProvider]) {
return this.translatorMap[
clusterDetails.authProvider
].decorateClusterDetailsWithAuth(clusterDetails, auth);
}
throw new Error(
`authProvider "${clusterDetails.authProvider}" has no KubernetesAuthTranslator associated with it`,
);
}
}
@@ -1,58 +0,0 @@
/*
* 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 { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator';
import { ClusterDetails } from '../types/types';
describe('OidcKubernetesAuthTranslator tests', () => {
const at = new OidcKubernetesAuthTranslator();
const baseClusterDetails: ClusterDetails = {
name: 'test',
authProvider: 'oidc',
url: '',
};
it('returns cluster details with auth token', async () => {
const details = await at.decorateClusterDetailsWithAuth(
{
oidcTokenProvider: 'okta',
...baseClusterDetails,
},
{
oidc: { okta: 'fakeToken' },
},
);
expect(details.serviceAccountToken).toBe('fakeToken');
});
it('returns error when oidcTokenProvider is not configured', async () => {
await expect(
at.decorateClusterDetailsWithAuth(baseClusterDetails, {}),
).rejects.toThrow(
'oidc authProvider requires a configured oidcTokenProvider',
);
});
it('returns error when token is not included in request body', async () => {
await expect(
at.decorateClusterDetailsWithAuth(
{ oidcTokenProvider: 'okta', ...baseClusterDetails },
{},
),
).rejects.toThrow('Auth token not found under oidc.okta in request body');
});
});
@@ -1,54 +0,0 @@
/*
* 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 { ClusterDetails } from '../types/types';
import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
/**
*
* @public
*/
export class OidcKubernetesAuthTranslator implements KubernetesAuthTranslator {
async decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
authConfig: KubernetesRequestAuth,
): Promise<ClusterDetails> {
const clusterDetailsWithAuthToken: ClusterDetails = Object.assign(
{},
clusterDetails,
);
const { oidcTokenProvider } = clusterDetails;
if (!oidcTokenProvider || oidcTokenProvider === '') {
throw new Error(
`oidc authProvider requires a configured oidcTokenProvider`,
);
}
const authToken: string | undefined = authConfig.oidc?.[oidcTokenProvider];
if (authToken) {
clusterDetailsWithAuthToken.serviceAccountToken = authToken;
} else {
throw new Error(
`Auth token not found under oidc.${oidcTokenProvider} in request body`,
);
}
return clusterDetailsWithAuthToken;
}
}
@@ -40,8 +40,7 @@ describe('MultiTenantConfigClusterLocator', () => {
{
name: 'cluster1',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
serviceAccountToken: '12345',
authMetadata: {},
},
];
},
@@ -56,9 +55,8 @@ describe('MultiTenantConfigClusterLocator', () => {
clusters: [
{
name: 'cluster1',
serviceAccountToken: '12345',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
authMetadata: {},
},
],
});
@@ -70,14 +68,13 @@ describe('MultiTenantConfigClusterLocator', () => {
return [
{
name: 'cluster1',
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
authMetadata: {},
},
{
name: 'cluster2',
url: 'http://localhost:8081',
authProvider: 'google',
authMetadata: {},
},
];
},
@@ -92,14 +89,13 @@ describe('MultiTenantConfigClusterLocator', () => {
clusters: [
{
name: 'cluster1',
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
authMetadata: {},
},
{
name: 'cluster2',
url: 'http://localhost:8081',
authProvider: 'google',
authMetadata: {},
},
],
});
@@ -17,7 +17,12 @@
import { getVoidLogger } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { Config, ConfigReader } from '@backstage/config';
import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
import {
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER,
ObjectsByEntityResponse,
KubernetesRequestAuth,
} from '@backstage/plugin-kubernetes-common';
import express from 'express';
import request from 'supertest';
import {
@@ -28,6 +33,7 @@ import {
KubernetesServiceLocator,
ObjectFetchParams,
} from '../types/types';
import { KubernetesCredential } from '../auth/types';
import { KubernetesBuilder } from './KubernetesBuilder';
import { KubernetesFanOutHandler } from './KubernetesFanOutHandler';
import { CatalogApi } from '@backstage/catalog-client';
@@ -50,7 +56,8 @@ describe('KubernetesBuilder', () => {
let catalogApi: CatalogApi;
let permissions: jest.Mocked<PermissionEvaluator>;
beforeAll(async () => {
beforeEach(async () => {
jest.resetAllMocks();
const logger = getVoidLogger();
config = new ConfigReader({
kubernetes: {
@@ -62,14 +69,18 @@ describe('KubernetesBuilder', () => {
const clusters: ClusterDetails[] = [
{
name: 'some-cluster',
authProvider: 'serviceAccount',
url: 'https://localhost:1234',
serviceAccountToken: 'someToken',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount',
},
},
{
name: 'some-other-cluster',
url: 'https://localhost:1235',
authProvider: 'google',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'oidc',
[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google',
},
},
];
const clusterSupplier: KubernetesClustersSupplier = {
@@ -100,10 +111,6 @@ describe('KubernetesBuilder', () => {
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('get /clusters', () => {
it('happy path: lists clusters', async () => {
const response = await request(app).get('/clusters');
@@ -117,7 +124,8 @@ describe('KubernetesBuilder', () => {
},
{
name: 'some-other-cluster',
authProvider: 'google',
authProvider: 'oidc',
oidcTokenProvider: 'google',
},
],
});
@@ -190,16 +198,18 @@ describe('KubernetesBuilder', () => {
const logger = getVoidLogger();
const someCluster: ClusterDetails = {
name: 'some-cluster',
authProvider: 'serviceAccount',
url: 'https://localhost:1234',
serviceAccountToken: 'someToken',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount',
serviceAccountToken: 'placeholder-token',
},
};
const clusters: ClusterDetails[] = [
someCluster,
{
name: 'some-other-cluster',
url: 'https://localhost:1235',
authProvider: 'google',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
},
];
const clusterSupplier: KubernetesClustersSupplier = {
@@ -241,6 +251,7 @@ describe('KubernetesBuilder', () => {
const fetcher: KubernetesFetcher = {
fetchPodMetricsByNamespaces(
_clusterDetails: ClusterDetails,
_credential: KubernetesCredential,
_namespaces: Set<string>,
): Promise<FetchResponseWrapper> {
return Promise.resolve({ errors: [], responses: [] });
@@ -285,8 +296,77 @@ describe('KubernetesBuilder', () => {
expect(response.body).toEqual(result);
expect(response.status).toEqual(200);
});
it('reads auth data for custom strategy', async () => {
permissions.authorize.mockResolvedValue([
{ result: AuthorizeResult.ALLOW },
]);
const mockFetcher = {
fetchPodMetricsByNamespaces: jest
.fn()
.mockResolvedValue({ errors: [], responses: [] }),
fetchObjectsForService: jest.fn().mockResolvedValue({
errors: [],
responses: [
{ type: 'pods', resources: [{ metadata: { name: 'pod1' } }] },
],
}),
};
const { router } = await KubernetesBuilder.createBuilder({
logger: getVoidLogger(),
config,
catalogApi,
permissions,
})
.addAuthStrategy('custom', {
getCredential: jest
.fn<
Promise<KubernetesCredential>,
[ClusterDetails, KubernetesRequestAuth]
>()
.mockImplementation(async (_, requestAuth) => ({
type: 'bearer token',
token: requestAuth.custom as string,
})),
validateCluster: jest.fn().mockReturnValue([]),
})
.setClusterSupplier({
getClusters: jest
.fn<Promise<ClusterDetails[]>, []>()
.mockResolvedValue([
{
name: 'custom-cluster',
url: 'http://my.cluster.url',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'custom',
},
},
]),
})
.setFetcher(mockFetcher)
.build();
app = express().use(router);
await request(app)
.post('/services/test-service')
.send({
entity: {
metadata: {
name: 'thing',
},
},
auth: { custom: 'custom-token' },
});
expect(mockFetcher.fetchObjectsForService).toHaveBeenCalledWith(
expect.objectContaining({
credential: { type: 'bearer token', token: 'custom-token' },
}),
);
});
});
describe('post /proxy', () => {
describe('/proxy', () => {
const worker = setupServer();
setupRequestMockHandlers(worker);
@@ -387,6 +467,59 @@ metadata:
expect(response.status).toEqual(403);
});
it('permits custom client-side auth strategy', async () => {
worker.use(
rest.get('http://my.cluster.url/api/v1/namespaces', (req, res, ctx) => {
if (req.headers.get('Authorization') !== 'custom-token') {
return res(ctx.status(401));
}
return res(ctx.json({ items: [] }));
}),
);
permissions.authorize.mockResolvedValue([
{ result: AuthorizeResult.ALLOW },
]);
const { router } = await KubernetesBuilder.createBuilder({
logger: getVoidLogger(),
config,
catalogApi,
permissions,
})
.addAuthStrategy('custom', {
getCredential: jest
.fn<
Promise<KubernetesCredential>,
[ClusterDetails, KubernetesRequestAuth]
>()
.mockResolvedValue({ type: 'anonymous' }),
validateCluster: jest.fn().mockReturnValue([]),
})
.setClusterSupplier({
getClusters: jest
.fn<Promise<ClusterDetails[]>, []>()
.mockResolvedValue([
{
name: 'custom-cluster',
url: 'http://my.cluster.url',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'custom',
},
},
]),
})
.build();
app = express().use(router);
const proxyEndpointRequest = request(app)
.get('/proxy/api/v1/namespaces')
.set(HEADER_KUBERNETES_CLUSTER, 'custom-cluster')
.set(HEADER_KUBERNETES_AUTH, 'custom-token');
worker.use(rest.all(proxyEndpointRequest.url, req => req.passthrough()));
const response = await proxyEndpointRequest;
expect(response.body).toStrictEqual({ items: [] });
});
});
describe('get /.well-known/backstage/permissions/metadata', () => {
it('lists permissions supported by the kubernetes plugin', async () => {
@@ -15,7 +15,11 @@
*/
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { kubernetesPermissions } from '@backstage/plugin-kubernetes-common';
import {
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER,
kubernetesPermissions,
} from '@backstage/plugin-kubernetes-common';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
import express from 'express';
@@ -25,16 +29,17 @@ import { Logger } from 'winston';
import { getCombinedClusterSupplier } from '../cluster-locator';
import {
KubernetesAuthTranslator,
DispatchingKubernetesAuthTranslator,
GoogleKubernetesAuthTranslator,
NoopKubernetesAuthTranslator,
AwsIamKubernetesAuthTranslator,
GoogleServiceAccountAuthTranslator,
AzureIdentityKubernetesAuthTranslator,
OidcKubernetesAuthTranslator,
AksKubernetesAuthTranslator,
} from '../kubernetes-auth-translator';
AuthenticationStrategy,
AnonymousStrategy,
DispatchStrategy,
GoogleStrategy,
ServiceAccountStrategy,
AwsIamStrategy,
GoogleServiceAccountStrategy,
AzureIdentityStrategy,
OidcStrategy,
AksStrategy,
} from '../auth';
import { addResourceRoutesToRouter } from '../routes/resourcesRoutes';
import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator';
@@ -80,7 +85,7 @@ export type KubernetesBuilderReturn = Promise<{
proxy: KubernetesProxy;
objectsProvider: KubernetesObjectsProvider;
serviceLocator: KubernetesServiceLocator;
authTranslatorMap: { [key: string]: KubernetesAuthTranslator };
authStrategyMap: { [key: string]: AuthenticationStrategy };
}>;
/**
@@ -96,7 +101,7 @@ export class KubernetesBuilder {
private fetcher?: KubernetesFetcher;
private serviceLocator?: KubernetesServiceLocator;
private proxy?: KubernetesProxy;
private authTranslatorMap?: { [key: string]: KubernetesAuthTranslator };
private authStrategyMap?: { [key: string]: AuthenticationStrategy };
static createBuilder(env: KubernetesEnvironment) {
return new KubernetesBuilder(env);
@@ -128,7 +133,7 @@ export class KubernetesBuilder {
const clusterSupplier = this.getClusterSupplier();
const authTranslatorMap = this.getAuthTranslatorMap();
const authStrategyMap = this.getAuthStrategyMap();
const proxy = this.getProxy(logger, clusterSupplier);
@@ -159,7 +164,7 @@ export class KubernetesBuilder {
objectsProvider,
router,
serviceLocator,
authTranslatorMap,
authStrategyMap,
};
}
@@ -193,10 +198,15 @@ export class KubernetesBuilder {
return this;
}
public setAuthTranslatorMap(authTranslatorMap: {
[key: string]: KubernetesAuthTranslator;
public setAuthStrategyMap(authStrategyMap: {
[key: string]: AuthenticationStrategy;
}) {
this.authTranslatorMap = authTranslatorMap;
this.authStrategyMap = authStrategyMap;
}
public addAuthStrategy(key: string, strategy: AuthenticationStrategy) {
this.getAuthStrategyMap()[key] = strategy;
return this;
}
protected buildCustomResources() {
@@ -225,6 +235,7 @@ export class KubernetesBuilder {
this.clusterSupplier = getCombinedClusterSupplier(
config,
this.env.catalogApi,
new DispatchStrategy({ authStrategyMap: this.getAuthStrategyMap() }),
refreshInterval,
);
@@ -234,11 +245,11 @@ export class KubernetesBuilder {
protected buildObjectsProvider(
options: KubernetesObjectsProviderOptions,
): KubernetesObjectsProvider {
const authTranslatorMap = this.getAuthTranslatorMap();
const authStrategyMap = this.getAuthStrategyMap();
this.objectsProvider = new KubernetesFanOutHandler({
...options,
authTranslator: new DispatchingKubernetesAuthTranslator({
authTranslatorMap,
authStrategy: new DispatchStrategy({
authStrategyMap,
}),
});
@@ -290,14 +301,14 @@ export class KubernetesBuilder {
logger: Logger,
clusterSupplier: KubernetesClustersSupplier,
): KubernetesProxy {
const authTranslatorMap = this.getAuthTranslatorMap();
const authTranslator = new DispatchingKubernetesAuthTranslator({
authTranslatorMap,
const authStrategyMap = this.getAuthStrategyMap();
const authStrategy = new DispatchStrategy({
authStrategyMap,
});
this.proxy = new KubernetesProxy({
logger,
clusterSupplier,
authTranslator,
authStrategy,
});
return this.proxy;
}
@@ -339,12 +350,16 @@ export class KubernetesBuilder {
router.get('/clusters', async (_, res) => {
const clusterDetails = await this.fetchClusterDetails(clusterSupplier);
res.json({
items: clusterDetails.map(cd => ({
name: cd.name,
dashboardUrl: cd.dashboardUrl,
authProvider: cd.authProvider,
oidcTokenProvider: cd.oidcTokenProvider,
})),
items: clusterDetails.map(cd => {
const oidcTokenProvider =
cd.authMetadata[ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER];
return {
name: cd.name,
dashboardUrl: cd.dashboardUrl,
authProvider: cd.authMetadata[ANNOTATION_KUBERNETES_AUTH_PROVIDER],
...(oidcTokenProvider && { oidcTokenProvider }),
};
}),
});
});
@@ -353,18 +368,18 @@ export class KubernetesBuilder {
return router;
}
protected buildAuthTranslatorMap() {
this.authTranslatorMap = {
google: new GoogleKubernetesAuthTranslator(),
aks: new AksKubernetesAuthTranslator(),
aws: new AwsIamKubernetesAuthTranslator({ config: this.env.config }),
azure: new AzureIdentityKubernetesAuthTranslator(this.env.logger),
serviceAccount: new NoopKubernetesAuthTranslator(),
googleServiceAccount: new GoogleServiceAccountAuthTranslator(),
oidc: new OidcKubernetesAuthTranslator(),
localKubectlProxy: new NoopKubernetesAuthTranslator(),
protected buildAuthStrategyMap() {
this.authStrategyMap = {
aks: new AksStrategy(),
aws: new AwsIamStrategy({ config: this.env.config }),
azure: new AzureIdentityStrategy(this.env.logger),
google: new GoogleStrategy(),
googleServiceAccount: new GoogleServiceAccountStrategy(),
localKubectlProxy: new AnonymousStrategy(),
oidc: new OidcStrategy(),
serviceAccount: new ServiceAccountStrategy(),
};
return this.authTranslatorMap;
return this.authStrategyMap;
}
protected async fetchClusterDetails(
@@ -447,7 +462,7 @@ export class KubernetesBuilder {
return this.proxy ?? this.buildProxy(logger, clusterSupplier);
}
protected getAuthTranslatorMap() {
return this.authTranslatorMap ?? this.buildAuthTranslatorMap();
protected getAuthStrategyMap() {
return this.authStrategyMap ?? this.buildAuthStrategyMap();
}
}
@@ -20,19 +20,28 @@ import {
CustomResource,
ObjectFetchParams,
KubernetesServiceLocator,
ServiceLocatorRequestContext,
} from '../types/types';
import { KubernetesCredential } from '../auth/types';
import { KubernetesFanOutHandler } from './KubernetesFanOutHandler';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
import {
KubernetesRequestAuth,
ObjectsByEntityResponse,
} from '@backstage/plugin-kubernetes-common';
import { Config, ConfigReader } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
describe('KubernetesFanOutHandler', () => {
const fetchObjectsForService = jest.fn();
const fetchPodMetricsByNamespaces = jest.fn();
const getClustersByEntity = jest.fn();
const getClustersByEntity = jest.fn<
Promise<{ clusters: ClusterDetails[] }>,
[Entity]
>();
let config: Config;
let sut: KubernetesFanOutHandler;
@@ -71,7 +80,8 @@ describe('KubernetesFanOutHandler', () => {
const cluster1 = {
name: 'test-cluster',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
customResources: [
{
group: 'some-other-crd.example.com',
@@ -83,7 +93,8 @@ describe('KubernetesFanOutHandler', () => {
const cluster2 = {
name: 'cluster-two',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
customResources: [
{
group: 'crd-two.example.com',
@@ -177,10 +188,14 @@ describe('KubernetesFanOutHandler', () => {
getClustersByEntity,
},
customResources: customResources,
authTranslator: {
decorateClusterDetailsWithAuth: async (clusterDetails, _) => {
return clusterDetails;
},
authStrategy: {
getCredential: jest
.fn<
Promise<KubernetesCredential>,
[ClusterDetails, KubernetesRequestAuth]
>()
.mockResolvedValue({ type: 'anonymous' }),
validateCluster: jest.fn().mockReturnValue([]),
},
config,
});
@@ -308,7 +323,11 @@ describe('KubernetesFanOutHandler', () => {
);
fetchPodMetricsByNamespaces.mockImplementation(
(_clusterDetails: ClusterDetails, namespaces: Set<string>) =>
(
_clusterDetails: ClusterDetails,
_: KubernetesCredential,
namespaces: Set<string>,
) =>
Promise.resolve({
errors: [],
responses: Array.from(namespaces).map(() => {
@@ -343,7 +362,8 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'test-cluster',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
},
],
}),
@@ -361,6 +381,7 @@ describe('KubernetesFanOutHandler', () => {
expect(fetchPodMetricsByNamespaces).toHaveBeenCalledTimes(1);
expect(fetchPodMetricsByNamespaces).toHaveBeenCalledWith(
expect.anything(),
{ type: 'anonymous' },
new Set(['ns-test-component-test-cluster']),
expect.anything(),
);
@@ -457,7 +478,8 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'test-cluster',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
},
cluster2,
],
@@ -511,7 +533,8 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'profile-cluster-1',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
customResourceProfile: 'build',
customResources: [
{
@@ -559,7 +582,8 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'profile-cluster-1',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
},
],
}),
@@ -599,7 +623,8 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'test-cluster',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
},
],
}),
@@ -648,6 +673,7 @@ describe('KubernetesFanOutHandler', () => {
expect(fetchPodMetricsByNamespaces).toHaveBeenCalledTimes(1);
expect(fetchPodMetricsByNamespaces).toHaveBeenCalledWith(
expect.anything(),
{ type: 'anonymous' },
new Set(['ns-a', 'ns-b']),
expect.anything(),
);
@@ -696,7 +722,8 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'test-cluster',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
},
],
}),
@@ -735,12 +762,14 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'test-cluster',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
dashboardUrl: 'https://k8s.foo.coom',
},
{
name: 'other-cluster',
authProvider: 'google',
url: '',
authMetadata: {},
},
],
}),
@@ -786,15 +815,18 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'test-cluster',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
},
{
name: 'other-cluster',
authProvider: 'google',
url: '',
authMetadata: {},
},
{
name: 'empty-cluster',
authProvider: 'google',
url: '',
authMetadata: {},
},
],
}),
@@ -839,19 +871,23 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'test-cluster',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
},
{
name: 'other-cluster',
authProvider: 'google',
url: '',
authMetadata: {},
},
{
name: 'empty-cluster',
authProvider: 'google',
url: '',
authMetadata: {},
},
{
name: 'error-cluster',
authProvider: 'google',
url: '',
authMetadata: {},
},
],
}),
@@ -917,12 +953,14 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'test-cluster',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
dashboardUrl: 'https://k8s.foo.coom',
},
{
name: 'other-cluster',
authProvider: 'google',
url: '',
authMetadata: {},
},
],
}),
@@ -932,7 +970,11 @@ describe('KubernetesFanOutHandler', () => {
// and an error for the second call.
fetchPodMetricsByNamespaces
.mockImplementationOnce(
(_clusterDetails: ClusterDetails, namespaces: Set<string>) =>
(
_clusterDetails: ClusterDetails,
_: KubernetesCredential,
namespaces: Set<string>,
) =>
Promise.resolve({
errors: [],
responses: Array.from(namespaces).map(() => {
@@ -1015,7 +1057,8 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'test-cluster',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
skipMetricsLookup: true,
},
],
@@ -1054,24 +1097,27 @@ describe('KubernetesFanOutHandler', () => {
);
const fleet: jest.Mocked<KubernetesServiceLocator> = {
getClustersByEntity: jest.fn().mockResolvedValue({
clusters: [
{
name: 'works',
url: 'https://works',
authProvider: 'serviceAccount',
serviceAccountToken: 'token',
skipMetricsLookup: true,
},
{
name: 'fails',
url: 'https://fails',
authProvider: 'serviceAccount',
serviceAccountToken: 'token',
skipMetricsLookup: true,
},
],
}),
getClustersByEntity: jest
.fn<
Promise<{ clusters: ClusterDetails[] }>,
[Entity, ServiceLocatorRequestContext]
>()
.mockResolvedValue({
clusters: [
{
name: 'works',
url: 'https://works',
authMetadata: {},
skipMetricsLookup: true,
},
{
name: 'fails',
url: 'https://fails',
authMetadata: {},
skipMetricsLookup: true,
},
],
}),
};
const logger = getVoidLogger();
const kubernetesFanOutHandler = new KubernetesFanOutHandler({
@@ -1093,10 +1139,14 @@ describe('KubernetesFanOutHandler', () => {
objectType: 'services',
},
],
authTranslator: {
decorateClusterDetailsWithAuth: async (clusterDetails, _) => {
return clusterDetails;
},
authStrategy: {
getCredential: jest
.fn<
Promise<KubernetesCredential>,
[ClusterDetails, KubernetesRequestAuth]
>()
.mockResolvedValue({ type: 'bearer token', token: 'token' }),
validateCluster: jest.fn().mockReturnValue([]),
},
config,
});
@@ -1192,7 +1242,8 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'test-cluster',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
},
cluster2,
],
@@ -1265,7 +1316,8 @@ describe('KubernetesFanOutHandler', () => {
clusters: [
{
name: 'profile-cluster-1',
authProvider: 'serviceAccount',
url: '',
authMetadata: {},
customResourceProfile: 'build',
},
],
@@ -27,9 +27,8 @@ import {
CustomResource,
CustomResourcesByEntity,
KubernetesObjectsByEntity,
ServiceLocatorRequestContext,
} from '../types/types';
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types';
import { AuthenticationStrategy, KubernetesCredential } from '../auth/types';
import {
ClientContainerStatus,
ClientCurrentResourceUsage,
@@ -48,14 +47,6 @@ import {
PodStatus,
} from '@kubernetes/client-node';
const isRejected = (
input: PromiseSettledResult<unknown>,
): input is PromiseRejectedResult => input.status === 'rejected';
const isFulfilled = <T>(
input: PromiseSettledResult<T>,
): input is PromiseFulfilledResult<T> => input.status === 'fulfilled';
/**
*
* @public
@@ -137,7 +128,7 @@ export const DEFAULT_OBJECTS: ObjectToFetch[] = [
export interface KubernetesFanOutHandlerOptions
extends KubernetesObjectsProviderOptions {
authTranslator: KubernetesAuthTranslator;
authStrategy: AuthenticationStrategy;
}
export interface KubernetesRequestBody extends ObjectsByEntityRequest {}
@@ -196,7 +187,7 @@ export class KubernetesFanOutHandler {
private readonly serviceLocator: KubernetesServiceLocator;
private readonly customResources: CustomResource[];
private readonly objectTypesToFetch: Set<ObjectToFetch>;
private readonly authTranslator: KubernetesAuthTranslator;
private readonly authStrategy: AuthenticationStrategy;
constructor({
logger,
@@ -204,14 +195,14 @@ export class KubernetesFanOutHandler {
serviceLocator,
customResources,
objectTypesToFetch = DEFAULT_OBJECTS,
authTranslator,
authStrategy,
}: KubernetesFanOutHandlerOptions) {
this.logger = logger;
this.fetcher = fetcher;
this.serviceLocator = serviceLocator;
this.customResources = customResources;
this.objectTypesToFetch = new Set(objectTypesToFetch);
this.authTranslator = authTranslator;
this.authStrategy = authStrategy;
}
async getCustomResourcesByEntity({
@@ -245,14 +236,13 @@ export class KubernetesFanOutHandler {
entity.metadata?.annotations?.['backstage.io/kubernetes-id'] ||
entity.metadata?.name;
const clusterDetailsDecoratedForAuth: ClusterDetails[] =
await this.decorateClusterDetailsWithAuth(entity, auth, {
objectTypesToFetch: objectTypesToFetch,
customResources: customResources ?? [],
});
const { clusters } = await this.serviceLocator.getClustersByEntity(entity, {
objectTypesToFetch: objectTypesToFetch,
customResources: customResources ?? [],
});
this.logger.info(
`entity.metadata.name=${entityName} clusterDetails=[${clusterDetailsDecoratedForAuth
`entity.metadata.name=${entityName} clusterDetails=[${clusters
.map(c => c.name)
.join(', ')}]`,
);
@@ -266,16 +256,21 @@ export class KubernetesFanOutHandler {
entity.metadata?.annotations?.['backstage.io/kubernetes-namespace'];
return Promise.all(
clusterDetailsDecoratedForAuth.map(clusterDetailsItem => {
clusters.map(async clusterDetails => {
const credential = await this.authStrategy.getCredential(
clusterDetails,
auth,
);
return this.fetcher
.fetchObjectsForService({
serviceId: entityName,
clusterDetails: clusterDetailsItem,
objectTypesToFetch: objectTypesToFetch,
clusterDetails,
credential,
objectTypesToFetch,
labelSelector,
customResources: (
customResources ||
clusterDetailsItem.customResources ||
clusterDetails.customResources ||
this.customResources
).map(c => ({
...c,
@@ -284,7 +279,12 @@ export class KubernetesFanOutHandler {
namespace,
})
.then(result =>
this.getMetricsForPods(clusterDetailsItem, labelSelector, result),
this.getMetricsForPods(
clusterDetails,
credential,
labelSelector,
result,
),
)
.catch(
(e): Promise<responseWithMetrics> =>
@@ -300,33 +300,11 @@ export class KubernetesFanOutHandler {
])
: Promise.reject(e),
)
.then(r => this.toClusterObjects(clusterDetailsItem, r));
.then(r => this.toClusterObjects(clusterDetails, r));
}),
).then(this.toObjectsByEntityResponse);
}
private async decorateClusterDetailsWithAuth(
entity: Entity,
auth: KubernetesRequestAuth,
requestContext: ServiceLocatorRequestContext,
) {
const clusterDetails: ClusterDetails[] = (
await this.serviceLocator.getClustersByEntity(entity, requestContext)
).clusters;
// Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them
const promiseResults = await Promise.allSettled(
clusterDetails.map(cd => {
return this.authTranslator.decorateClusterDetailsWithAuth(cd, auth);
}),
);
promiseResults.filter(isRejected).map(item => {
this.logger.info(`Failed to decorate cluster details: ${item.reason}`);
});
return promiseResults.filter(isFulfilled).map(item => item.value);
}
toObjectsByEntityResponse(
clusterObjects: ClusterObjects[],
): ObjectsByEntityResponse {
@@ -367,6 +345,7 @@ export class KubernetesFanOutHandler {
async getMetricsForPods(
clusterDetails: ClusterDetails,
credential: KubernetesCredential,
labelSelector: string,
result: FetchResponseWrapper,
): Promise<responseWithMetrics> {
@@ -387,6 +366,7 @@ export class KubernetesFanOutHandler {
const podMetrics = await this.fetcher.fetchPodMetricsByNamespaces(
clusterDetails,
credential,
namespaces,
labelSelector,
);
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { ANNOTATION_KUBERNETES_AUTH_PROVIDER } from '@backstage/plugin-kubernetes-common';
import { getVoidLogger } from '@backstage/backend-common';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
import { ObjectToFetch } from '../types/types';
@@ -138,9 +139,9 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
},
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
@@ -199,9 +200,9 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'http://localhost:9999/k8s/clusters/1234',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
},
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
@@ -253,8 +254,11 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'http://localhost:9999/k8s/clusters/1234',
authProvider: 'localKubectlProxy',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'localKubectlProxy',
},
},
credential: { type: 'anonymous' },
objectTypesToFetch: new Set([
{
group: '',
@@ -309,9 +313,9 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
},
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
@@ -383,9 +387,9 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
},
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [
@@ -475,9 +479,9 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
},
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
@@ -563,9 +567,9 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'http://badurl.does.not.exist',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
},
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
@@ -600,9 +604,9 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
},
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: 'service-label=value',
customResources: [],
@@ -667,10 +671,10 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
caData: 'MOCKCA',
},
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: new Set<ObjectToFetch>([
{
group: '',
@@ -704,9 +708,9 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
},
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: new Set<ObjectToFetch>([
{
group: '',
@@ -747,10 +751,10 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
caFile: '/path/to/ca.crt',
},
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: new Set<ObjectToFetch>([
{
group: '',
@@ -785,10 +789,10 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
skipTLSVerify: true,
},
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: new Set<ObjectToFetch>([
{
group: '',
@@ -836,9 +840,9 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
},
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
namespace: 'some-namespace',
@@ -874,14 +878,15 @@ describe('KubernetesFetcher', () => {
});
});
describe('Backstage not running on k8s', () => {
it('fails if cluster details has no token', () => {
it('fails if no credential is provided', () => {
const result = sut.fetchObjectsForService({
serviceId: 'some-service',
clusterDetails: {
name: 'unauthenticated-cluster',
url: 'http://ignored',
authProvider: 'serviceAccount',
authMetadata: {},
},
credential: { type: 'anonymous' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
@@ -904,8 +909,6 @@ describe('KubernetesFetcher', () => {
process.env.KUBERNETES_SERVICE_PORT = '443';
mockFs({
'/var/run/secrets/kubernetes.io/serviceaccount/ca.crt': '',
'/var/run/secrets/kubernetes.io/serviceaccount/token':
'allowed-token',
});
worker.use(
rest.get('https://10.10.10.10/api/v1/pods', (req, res, ctx) =>
@@ -923,8 +926,11 @@ describe('KubernetesFetcher', () => {
clusterDetails: {
name: 'overridden-to-in-cluster',
url: 'http://ignored',
authProvider: 'serviceAccount',
authMetadata: {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'serviceAccount',
},
},
credential: { type: 'bearer token', token: 'allowed-token' },
objectTypesToFetch: new Set<ObjectToFetch>([
{
group: '',
@@ -1019,9 +1025,9 @@ describe('KubernetesFetcher', () => {
{
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
},
{ type: 'bearer token', token: 'token' },
new Set(['ns-a']),
);
expect(result).toMatchObject({
@@ -1106,9 +1112,9 @@ describe('KubernetesFetcher', () => {
{
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
authMetadata: {},
},
{ type: 'bearer token', token: 'token' },
new Set(['ns-a', 'ns-b']),
);
@@ -20,7 +20,6 @@ import {
CoreV1Api,
KubeConfig,
Metrics,
User,
bufferFromFileOrString,
topPods,
} from '@kubernetes/client-node';
@@ -32,7 +31,9 @@ import {
KubernetesFetcher,
ObjectFetchParams,
} from '../types/types';
import { KubernetesCredential } from '../auth/types';
import {
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
FetchResponse,
KubernetesFetchError,
KubernetesErrorTypes,
@@ -95,6 +96,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
.map(({ objectType, group, apiVersion, plural }) =>
this.fetchResource(
params.clusterDetails,
params.credential,
group,
apiVersion,
plural,
@@ -125,6 +127,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
fetchPodMetricsByNamespaces(
clusterDetails: ClusterDetails,
credential: KubernetesCredential,
namespaces: Set<string>,
labelSelector?: string,
): Promise<FetchResponseWrapper> {
@@ -132,13 +135,22 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
const [podMetrics, podList] = await Promise.all([
this.fetchResource(
clusterDetails,
credential,
'metrics.k8s.io',
'v1beta1',
'pods',
ns,
labelSelector,
),
this.fetchResource(clusterDetails, '', 'v1', 'pods', ns, labelSelector),
this.fetchResource(
clusterDetails,
credential,
'',
'v1',
'pods',
ns,
labelSelector,
),
]);
if (podMetrics.ok && podList.ok) {
return topPods(
@@ -183,6 +195,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
private fetchResource(
clusterDetails: ClusterDetails,
credential: KubernetesCredential,
group: string,
apiVersion: string,
plural: string,
@@ -200,13 +213,19 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
let url: URL;
let requestInit: RequestInit;
const authProvider =
clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AUTH_PROVIDER];
if (
clusterDetails.serviceAccountToken ||
clusterDetails.authProvider === 'localKubectlProxy'
authProvider === 'serviceAccount' &&
!clusterDetails.authMetadata.serviceAccountToken &&
fs.pathExistsSync(Config.SERVICEACCOUNT_CA_PATH)
) {
[url, requestInit] = this.fetchArgsFromClusterDetails(clusterDetails);
} else if (fs.pathExistsSync(Config.SERVICEACCOUNT_TOKEN_PATH)) {
[url, requestInit] = this.fetchArgsInCluster();
[url, requestInit] = this.fetchArgsInCluster(credential);
} else if (
credential.type === 'bearer token' ||
authProvider === 'localKubectlProxy'
) {
[url, requestInit] = this.fetchArgs(clusterDetails, credential);
} else {
return Promise.reject(
new Error(
@@ -228,15 +247,18 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
return fetch(url, requestInit);
}
private fetchArgsFromClusterDetails(
private fetchArgs(
clusterDetails: ClusterDetails,
credential: KubernetesCredential,
): [URL, RequestInit] {
const requestInit: RequestInit = {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${clusterDetails.serviceAccountToken}`,
...(credential.type === 'bearer token' && {
Authorization: `Bearer ${credential.token}`,
}),
},
};
@@ -253,24 +275,25 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
}
return [url, requestInit];
}
private fetchArgsInCluster(): [URL, RequestInit] {
const kc = new KubeConfig();
kc.loadFromCluster();
// loadFromCluster is guaranteed to populate the cluster/user/context
const cluster = kc.getCurrentCluster() as Cluster;
const user = kc.getCurrentUser() as User;
const token = fs.readFileSync(user.authProvider.config.tokenFile);
private fetchArgsInCluster(
credential: KubernetesCredential,
): [URL, RequestInit] {
const requestInit: RequestInit = {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(credential.type === 'bearer token' && {
Authorization: `Bearer ${credential.token}`,
}),
},
};
const kc = new KubeConfig();
kc.loadFromCluster();
// loadFromCluster is guaranteed to populate the cluster/user/context
const cluster = kc.getCurrentCluster() as Cluster;
const url = new URL(cluster.server);
if (url.protocol === 'https:') {
requestInit.agent = new https.Agent({
@@ -23,6 +23,7 @@ import {
AuthorizeResult,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';
import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
import { getMockReq, getMockRes } from '@jest-mock/express';
import express from 'express';
import Router from 'express-promise-router';
@@ -34,9 +35,10 @@ import { AddressInfo, WebSocket, WebSocketServer } from 'ws';
import { LocalKubectlProxyClusterLocator } from '../cluster-locator/LocalKubectlProxyLocator';
import {
KubernetesAuthTranslator,
NoopKubernetesAuthTranslator,
} from '../kubernetes-auth-translator';
AuthenticationStrategy,
AnonymousStrategy,
KubernetesCredential,
} from '../auth';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
import {
APPLICATION_JSON,
@@ -50,11 +52,12 @@ import type { Request } from 'express';
describe('KubernetesProxy', () => {
let proxy: KubernetesProxy;
let authStrategy: jest.Mocked<AuthenticationStrategy>;
const worker = setupServer();
const logger = getVoidLogger();
const clusterSupplier: jest.Mocked<KubernetesClustersSupplier> = {
getClusters: jest.fn(),
getClusters: jest.fn<Promise<ClusterDetails[]>, []>(),
};
const permissionApi: jest.Mocked<PermissionEvaluator> = {
@@ -62,10 +65,6 @@ describe('KubernetesProxy', () => {
authorizeConditional: jest.fn(),
};
const authTranslator: jest.Mocked<KubernetesAuthTranslator> = {
decorateClusterDetailsWithAuth: jest.fn(),
};
setupRequestMockHandlers(worker);
const buildMockRequest = (clusterName: any, path: string): Request => {
@@ -126,7 +125,16 @@ describe('KubernetesProxy', () => {
beforeEach(() => {
jest.resetAllMocks();
proxy = new KubernetesProxy({ logger, clusterSupplier, authTranslator });
authStrategy = {
getCredential: jest
.fn<
Promise<KubernetesCredential>,
[ClusterDetails, KubernetesRequestAuth]
>()
.mockResolvedValue({ type: 'anonymous' }),
validateCluster: jest.fn(),
};
proxy = new KubernetesProxy({ logger, clusterSupplier, authStrategy });
permissionApi.authorize.mockResolvedValue([
{ result: AuthorizeResult.ALLOW },
]);
@@ -148,15 +156,14 @@ describe('KubernetesProxy', () => {
{
name: 'local',
url: 'http:/localhost:8001',
authProvider: 'localKubectlProxy',
authMetadata: {},
skipMetricsLookup: true,
} as ClusterDetails,
},
{
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'tokenA',
authProvider: 'googleServiceAccount',
} as ClusterDetails,
authMetadata: {},
},
]);
const req = buildMockRequest(undefined, 'api');
@@ -172,9 +179,8 @@ describe('KubernetesProxy', () => {
{
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'tokenA',
authProvider: 'googleServiceAccount',
} as ClusterDetails,
authMetadata: {},
},
]);
const req = buildMockRequest('test', 'api');
@@ -201,17 +207,9 @@ describe('KubernetesProxy', () => {
{
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: '',
authProvider: 'serviceAccount',
authMetadata: {},
},
] as ClusterDetails[]);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: '',
authProvider: 'serviceAccount',
} as ClusterDetails);
]);
worker.use(
rest.get('https://localhost:9999/api', (_: any, res: any, ctx: any) =>
@@ -247,17 +245,9 @@ describe('KubernetesProxy', () => {
{
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: '',
authProvider: 'serviceAccount',
authMetadata: {},
},
] as ClusterDetails[]);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: '',
authProvider: 'serviceAccount',
} as ClusterDetails);
]);
worker.use(
rest.get('https://localhost:9999/api', (_: any, res: any, ctx: any) =>
@@ -293,12 +283,9 @@ describe('KubernetesProxy', () => {
{
name: 'cluster1',
url: 'http://localhost:9999',
authProvider: '',
authMetadata: {},
},
]);
authTranslator.decorateClusterDetailsWithAuth.mockImplementation(
async x => x,
);
const requestPromise = setupProxyPromise({
proxyPath: '/mountpath',
@@ -312,7 +299,7 @@ describe('KubernetesProxy', () => {
expect(response.status).toEqual(200);
});
it('should default to using a authTranslator provided serviceAccountToken as authorization headers to kubeapi when backstage-kubernetes-auth field is not provided', async () => {
it('should default to using a strategy-provided bearer token as authorization headers to kubeapi when backstage-kubernetes-auth field is not provided', async () => {
worker.use(
rest.get(
'https://localhost:9999/api/v1/namespaces',
@@ -323,7 +310,7 @@ describe('KubernetesProxy', () => {
if (
req.headers.get('Authorization') !==
'Bearer translator-provided-token'
'Bearer strategy-provided-token'
) {
return res(ctx.status(403));
}
@@ -344,17 +331,14 @@ describe('KubernetesProxy', () => {
{
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: '',
authProvider: 'serviceAccount',
authMetadata: {},
},
] as ClusterDetails[]);
]);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'translator-provided-token',
authProvider: 'serviceAccount',
} as ClusterDetails);
authStrategy.getCredential.mockResolvedValue({
type: 'bearer token',
token: 'strategy-provided-token',
});
const requestPromise = setupProxyPromise({
proxyPath: '/mountpath',
@@ -368,7 +352,7 @@ describe('KubernetesProxy', () => {
expect(response.status).toEqual(200);
});
it('should add a authTranslator provided serviceAccountToken as authorization headers to kubeapi if one isnt provided in request and one isnt set up in cluster details', async () => {
it('should add an authStrategy-provided serviceAccountToken as authorization headers to kubeapi if one isnt provided in request and one isnt set up in cluster details', async () => {
worker.use(
rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => {
if (!req.headers.get('Authorization')) {
@@ -394,16 +378,14 @@ describe('KubernetesProxy', () => {
{
name: 'cluster1',
url: 'https://localhost:9999',
authProvider: 'googleServiceAccount',
authMetadata: {},
},
] as ClusterDetails[]);
]);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'my-token',
authProvider: 'googleServiceAccount',
} as ClusterDetails);
authStrategy.getCredential.mockResolvedValue({
type: 'bearer token',
token: 'my-token',
});
const requestPromise = setupProxyPromise({
proxyPath: '/mountpath',
@@ -448,16 +430,14 @@ describe('KubernetesProxy', () => {
{
name: 'cluster1',
url: 'https://localhost:9999',
authProvider: 'googleServiceAccount',
authMetadata: {},
},
] as ClusterDetails[]);
]);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'tokenA',
authProvider: 'googleServiceAccount',
} as ClusterDetails);
authStrategy.getCredential.mockResolvedValue({
type: 'bearer token',
token: 'tokenA',
});
const requestPromise = setupProxyPromise({
proxyPath: '/mountpath',
@@ -479,7 +459,7 @@ describe('KubernetesProxy', () => {
});
});
it('should not invoke authTranslator if Backstage-Kubernetes-Authorization field is provided', async () => {
it('should not invoke authStrategy if Backstage-Kubernetes-Authorization field is provided', async () => {
worker.use(
rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => {
if (!req.headers.get('Authorization')) {
@@ -505,9 +485,9 @@ describe('KubernetesProxy', () => {
{
name: 'cluster1',
url: 'https://localhost:9999',
authProvider: 'googleServiceAccount',
authMetadata: {},
},
] as ClusterDetails[]);
]);
const requestPromise = setupProxyPromise({
proxyPath: '/mountpath',
@@ -521,9 +501,7 @@ describe('KubernetesProxy', () => {
const response = await requestPromise;
expect(authTranslator.decorateClusterDetailsWithAuth).toHaveBeenCalledTimes(
0,
);
expect(authStrategy.getCredential).toHaveBeenCalledTimes(0);
expect(response.status).toEqual(200);
expect(response.body).toStrictEqual({
kind: 'NamespaceList',
@@ -536,7 +514,7 @@ describe('KubernetesProxy', () => {
proxy = new KubernetesProxy({
logger: getVoidLogger(),
clusterSupplier: new LocalKubectlProxyClusterLocator(),
authTranslator: new NoopKubernetesAuthTranslator(),
authStrategy: new AnonymousStrategy(),
});
worker.use(
@@ -573,7 +551,7 @@ describe('KubernetesProxy', () => {
});
});
it('returns a 500 error if authTranslator errors out and Backstage-Kubernetes-Authorization field is not provided', async () => {
it('returns a 500 error if authStrategy errors out and Backstage-Kubernetes-Authorization field is not provided', async () => {
worker.use(
rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => {
if (!req.headers.get('Authorization')) {
@@ -599,14 +577,11 @@ describe('KubernetesProxy', () => {
{
name: 'cluster1',
url: 'https://localhost:9999',
authProvider: 'google',
serviceAccountToken: 'client-side-token',
authMetadata: {},
},
] as ClusterDetails[]);
]);
authTranslator.decorateClusterDetailsWithAuth.mockRejectedValue(
Error('some internal error'),
);
authStrategy.getCredential.mockRejectedValue(Error('some internal error'));
const requestPromise = setupProxyPromise({
proxyPath: '/mountpath',
@@ -643,14 +618,10 @@ describe('KubernetesProxy', () => {
{
name: 'cluster1',
url: 'http://localhost:9999/subpath',
authProvider: '',
authMetadata: {},
},
]);
authTranslator.decorateClusterDetailsWithAuth.mockImplementation(
async x => x,
);
const requestPromise = setupProxyPromise({
proxyPath: '/mountpath',
requestPath: '/api/v1/namespaces',
@@ -696,19 +667,11 @@ describe('KubernetesProxy', () => {
{
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: '',
authProvider: 'serviceAccount',
authMetadata: {},
caFile: resolvePath(__dirname, '__fixtures__/mock-ca.crt'),
},
] as ClusterDetails[]);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: '',
authProvider: 'serviceAccount',
} as ClusterDetails);
worker.use(
rest.get('https://localhost:9999/api', (_: any, res: any, ctx: any) =>
res(ctx.status(299), ctx.json(apiResponse)),
@@ -747,7 +710,7 @@ describe('KubernetesProxy', () => {
event: 'connection' | 'open' | 'close' | 'error' | 'message',
) => new Promise(resolve => ws.once(event, x => resolve(x?.toString())));
beforeAll(async () => {
beforeEach(async () => {
await new Promise(resolve => {
expressServer = express()
.use(
@@ -778,7 +741,7 @@ describe('KubernetesProxy', () => {
wsEchoServer.on('error', console.error);
});
afterAll(() => {
afterEach(() => {
wsEchoServer.close();
expressServer.close();
});
@@ -788,17 +751,9 @@ describe('KubernetesProxy', () => {
{
name: 'local',
url: `http://localhost:${wsPort}`,
serviceAccountToken: '',
authProvider: 'serviceAccount',
authMetadata: {},
},
] as ClusterDetails[]);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'local',
url: `http://localhost:${wsPort}`,
serviceAccountToken: '',
authProvider: 'serviceAccount',
} as ClusterDetails);
]);
const wsProxyAddress = `ws://127.0.0.1:${proxyPort}${proxyPath}${wsPath}`;
const wsAddress = `ws://localhost:${wsPort}${wsPath}`;
@@ -31,7 +31,7 @@ import { bufferFromFileOrString } from '@kubernetes/client-node';
import type { Request, RequestHandler } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { Logger } from 'winston';
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator';
import { AuthenticationStrategy } from '../auth';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
export const APPLICATION_JSON: string = 'application/json';
@@ -68,7 +68,7 @@ export type KubernetesProxyCreateRequestHandlerOptions = {
export type KubernetesProxyOptions = {
logger: Logger;
clusterSupplier: KubernetesClustersSupplier;
authTranslator: KubernetesAuthTranslator;
authStrategy: AuthenticationStrategy;
};
/**
@@ -80,12 +80,12 @@ export class KubernetesProxy {
private readonly middlewareForClusterName = new Map<string, RequestHandler>();
private readonly logger: Logger;
private readonly clusterSupplier: KubernetesClustersSupplier;
private readonly authTranslator: KubernetesAuthTranslator;
private readonly authStrategy: AuthenticationStrategy;
constructor(options: KubernetesProxyOptions) {
this.logger = options.logger;
this.clusterSupplier = options.clusterSupplier;
this.authTranslator = options.authTranslator;
this.authStrategy = options.authStrategy;
}
public createRequestHandler(
@@ -112,13 +112,11 @@ export class KubernetesProxy {
if (authHeader) {
req.headers.authorization = authHeader;
} else {
const { serviceAccountToken } = await this.getClusterForRequest(
req,
).then(cd =>
this.authTranslator.decorateClusterDetailsWithAuth(cd, {}),
);
if (serviceAccountToken) {
req.headers.authorization = `Bearer ${serviceAccountToken}`;
const credential = await this.getClusterForRequest(req).then(cd => {
return this.authStrategy.getCredential(cd, {});
});
if (credential.type === 'bearer token') {
req.headers.authorization = `Bearer ${credential.token}`;
}
}
+11 -38
View File
@@ -26,6 +26,7 @@ import type {
ObjectsByEntityResponse,
} from '@backstage/plugin-kubernetes-common';
import { Config } from '@backstage/config';
import { KubernetesCredential } from '../auth/types';
/**
*
@@ -33,11 +34,8 @@ import { Config } from '@backstage/config';
*/
export interface ObjectFetchParams {
serviceId: string;
clusterDetails:
| AWSClusterDetails
| GKEClusterDetails
| ServiceAccountClusterDetails
| ClusterDetails;
clusterDetails: ClusterDetails;
credential: KubernetesCredential;
objectTypesToFetch: Set<ObjectToFetch>;
labelSelector: string;
customResources: CustomResource[];
@@ -55,6 +53,7 @@ export interface KubernetesFetcher {
): Promise<FetchResponseWrapper>;
fetchPodMetricsByNamespaces(
clusterDetails: ClusterDetails,
credential: KubernetesCredential,
namespaces: Set<string>,
labelSelector?: string,
): Promise<FetchResponseWrapper>;
@@ -148,6 +147,12 @@ export interface KubernetesServiceLocator {
*/
export type ServiceLocatorMethod = 'multiTenant' | 'http'; // TODO implement http
/**
* Provider-specific authentication configuration
* @public
*/
export type AuthMetadata = Record<string, string>;
/**
*
* @public
@@ -158,12 +163,7 @@ export interface ClusterDetails {
*/
name: string;
url: string;
authProvider: string;
serviceAccountToken?: string | undefined;
/**
* oidc provider used to get id tokens to authenticate against kubernetes
*/
oidcTokenProvider?: string | undefined;
authMetadata: AuthMetadata;
skipTLSVerify?: boolean;
/**
* Whether to skip the lookup to the metrics server to retrieve pod resource usage.
@@ -211,33 +211,6 @@ export interface ClusterDetails {
customResources?: CustomResourceMatcher[];
}
/**
*
* @public
*/
export interface GKEClusterDetails extends ClusterDetails {}
/**
*
* @public
*/
export interface AzureClusterDetails extends ClusterDetails {}
/**
*
* @public
*/
export interface ServiceAccountClusterDetails extends ClusterDetails {}
/**
*
* @public
*/
export interface AWSClusterDetails extends ClusterDetails {
assumeRole?: string;
externalId?: string;
}
/**
*
* @public
+1 -10
View File
@@ -233,16 +233,7 @@ export const kubernetesPermissions: BasicPermission[];
export const kubernetesProxyPermission: BasicPermission;
// @public (undocumented)
export interface KubernetesRequestAuth {
// (undocumented)
aks?: string;
// (undocumented)
google?: string;
// (undocumented)
oidc?: {
[key: string]: string;
};
}
export type KubernetesRequestAuth = JsonObject;
// @public (undocumented)
export interface KubernetesRequestBody {
+1 -7
View File
@@ -33,13 +33,7 @@ import {
import { Entity } from '@backstage/catalog-model';
/** @public */
export interface KubernetesRequestAuth {
google?: string;
aks?: string;
oidc?: {
[key: string]: string;
};
}
export type KubernetesRequestAuth = JsonObject;
/** @public */
export interface CustomResourceMatcher {
+1
View File
@@ -42,6 +42,7 @@
"@backstage/plugin-catalog-react": "workspace:^",
"@backstage/plugin-kubernetes-common": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/types": "workspace:^",
"@kubernetes-models/apimachinery": "^1.1.0",
"@kubernetes-models/base": "^4.0.1",
"@kubernetes/client-node": "0.18.1",
@@ -74,7 +74,7 @@ describe('KubernetesAuthProviders tests', () => {
requestBody,
);
expect(details.auth?.oidc?.okta).toBe('oktaToken');
expect(details).toMatchObject({ auth: { oidc: { okta: 'oktaToken' } } });
});
it('returns error for unknown authProvider', async () => {
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { KubernetesAuthProvider } from './types';
import { JsonObject } from '@backstage/types';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
import { OpenIdConnectApi } from '@backstage/core-plugin-api';
import { KubernetesAuthProvider } from './types';
export class OidcKubernetesAuthProvider implements KubernetesAuthProvider {
providerName: string;
@@ -31,9 +32,9 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider {
requestBody: KubernetesRequestBody,
): Promise<KubernetesRequestBody> {
const authToken: string = (await this.getCredentials()).token;
const auth = { ...requestBody.auth };
const auth = { ...(requestBody.auth as JsonObject) };
if (auth.oidc) {
auth.oidc[this.providerName] = authToken;
(auth.oidc as JsonObject)[this.providerName] = authToken;
} else {
auth.oidc = { [this.providerName]: authToken };
}
+1
View File
@@ -7693,6 +7693,7 @@ __metadata:
"@backstage/plugin-kubernetes-common": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/types": "workspace:^"
"@kubernetes-models/apimachinery": ^1.1.0
"@kubernetes-models/base": ^4.0.1
"@kubernetes/client-node": 0.18.1