Merge pull request #19903 from jamieklassen/clusterdetails-relax-auth-type
Configurable Kubernetes Authentication Strategies
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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';
|
||||
|
||||
+9
-6
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+13
-6
@@ -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 [];
|
||||
}
|
||||
}
|
||||
+9
-7
@@ -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 [];
|
||||
}
|
||||
}
|
||||
+36
-26
@@ -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');
|
||||
});
|
||||
+25
-21
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
-39
@@ -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();
|
||||
});
|
||||
});
|
||||
+11
-20
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
-17
@@ -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 [];
|
||||
}
|
||||
}
|
||||
+13
-19
@@ -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 [];
|
||||
}
|
||||
}
|
||||
+9
-8
@@ -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';
|
||||
+16
-8
@@ -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,
|
||||
|
||||
@@ -20,6 +20,6 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './kubernetes-auth-translator';
|
||||
export * from './auth';
|
||||
export * from './service';
|
||||
export * from './types';
|
||||
|
||||
-72
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
-56
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
-58
@@ -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');
|
||||
});
|
||||
});
|
||||
-54
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user