Merge pull request #17084 from RoadieHQ/port-aws-sdk

port k8s plugin from aws v2 to v3 client
This commit is contained in:
Fredrik Adelöw
2023-04-24 13:34:46 +02:00
committed by GitHub
9 changed files with 186 additions and 215 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-kubernetes-backend': minor
---
Update `aws-sdk` client from v2 to v3.
**BREAKING**: The `AwsIamKubernetesAuthTranslator` class no longer exposes the following methods `awsGetCredentials`, `getBearerToken`, `getCredentials` and `validCredentials`. There is no replacement for these methods.
+3 -16
View File
@@ -5,7 +5,6 @@
```ts
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { Credentials } from 'aws-sdk';
import type { CustomResourceMatcher } from '@backstage/plugin-kubernetes-common';
import { Duration } from 'luxon';
import { Entity } from '@backstage/catalog-model';
@@ -34,25 +33,11 @@ export interface AWSClusterDetails extends ClusterDetails {
export class AwsIamKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
// (undocumented)
awsGetCredentials: () => Promise<Credentials>;
constructor(opts: { config: Config });
// (undocumented)
decorateClusterDetailsWithAuth(
clusterDetails: AWSClusterDetails,
): Promise<AWSClusterDetails>;
// (undocumented)
getBearerToken(
clusterName: string,
assumeRole?: string,
externalId?: string,
): Promise<string>;
// (undocumented)
getCredentials(
assumeRole?: string,
externalId?: string,
): Promise<SigningCreds>;
// (undocumented)
validCredentials(creds: SigningCreds): boolean;
}
// @public (undocumented)
@@ -338,6 +323,8 @@ export interface KubernetesObjectsProvider {
// @public (undocumented)
export interface KubernetesObjectsProviderOptions {
// (undocumented)
config: Config;
// (undocumented)
customResources: CustomResource[];
// (undocumented)
+4 -2
View File
@@ -49,6 +49,9 @@
"clean": "backstage-cli package clean"
},
"dependencies": {
"@aws-crypto/sha256-js": "^3.0.0",
"@aws-sdk/credential-providers": "^3.208.0",
"@aws-sdk/signature-v4": "^3.310.0",
"@azure/identity": "^2.0.4",
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
@@ -56,6 +59,7 @@
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/integration-aws-node": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/plugin-kubernetes-common": "workspace:^",
@@ -66,7 +70,6 @@
"@kubernetes/client-node": "0.18.1",
"@types/express": "^4.17.6",
"@types/luxon": "^3.0.0",
"aws-sdk": "^2.840.0",
"aws4": "^1.11.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -88,7 +91,6 @@
"@backstage/cli": "workspace:^",
"@types/aws4": "^1.5.1",
"@types/http-proxy-middleware": "^0.19.3",
"aws-sdk-mock": "^5.2.1",
"mock-fs": "^5.2.0",
"msw": "^1.0.0",
"supertest": "^6.1.3"
@@ -13,148 +13,127 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import AWS from 'aws-sdk';
import AWSMock from 'aws-sdk-mock';
import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator';
import { ConfigReader } from '@backstage/config';
const awsError: AWS.AWSError = {
code: '123',
message: 'no way',
name: 'nope',
time: new Date(),
let presign = jest.fn(async () => ({
hostname: 'https://example.com',
query: {},
path: '/asdf',
}));
const credsManager = {
getCredentialProvider: async () => ({
sdkCredentialProvider: {
AccessKeyId: 'asdf',
},
}),
};
jest.mock('@backstage/integration-aws-node', () => ({
DefaultAwsCredentialsManager: {
fromConfig: () => credsManager,
},
}));
const config = new ConfigReader({});
jest.mock('@aws-sdk/signature-v4', () => ({
SignatureV4: jest.fn().mockImplementation(() => ({
presign,
})),
}));
const fromTemporaryCredentials = jest.fn();
jest.mock('@aws-sdk/credential-providers', () => ({
fromTemporaryCredentials: (opts: any) => {
return fromTemporaryCredentials(opts);
},
}));
describe('AwsIamKubernetesAuthTranslator tests', () => {
let role: any = undefined;
const credentials: any = {
accessKeyId: 'bloop',
secretAccessKey: 'omg-so-secret',
sessionToken: 'token',
};
beforeEach(() => {});
it('returns a signed url for AWS credentials without assume role', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator({ config });
let assumeResponse: any = {
Credentials: {
AccessKeyId: credentials.accessKeyId,
SecretAccessKey: credentials.secretAccessKey,
SessionToken: credentials.sessionToken,
},
};
let mockedCredentials: any = undefined;
AWS.config.credentials = new AWS.Credentials(credentials);
AWSMock.setSDKInstance(AWS);
beforeEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
jest.resetAllMocks();
});
function executeTranslation() {
AWSMock.mock('STS', 'assumeRole', (_params: any, callback: Function) => {
callback(null, assumeResponse);
});
const authTranslator = new AwsIamKubernetesAuthTranslator();
if (mockedCredentials) {
jest
.spyOn(authTranslator, 'awsGetCredentials')
.mockImplementation(async () => mockedCredentials);
}
const response = authTranslator.decorateClusterDetailsWithAuth({
assumeRole: role,
const authPromise = authTranslator.decorateClusterDetailsWithAuth({
name: 'test-cluster',
url: '',
authProvider: 'aws',
});
mockedCredentials = undefined;
return response;
}
it('returns a signed url for AWS credentials', async () => {
// These credentials are not real.
// Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html
AWS.config.credentials = new AWS.Credentials(
'AKIAIOSFODNN7EXAMPLE',
'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
expect((await authPromise).serviceAccountToken).toEqual(
'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
);
});
const response = await executeTranslation();
expect(response.serviceAccountToken).toBeDefined();
it('returns a signed url for AWS credentials with assume role', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator({ config });
const authPromise = authTranslator.decorateClusterDetailsWithAuth({
assumeRole: 'SomeRole',
name: 'test-cluster',
url: '',
authProvider: 'aws',
});
expect((await authPromise).serviceAccountToken).toEqual(
'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
);
expect(fromTemporaryCredentials).toHaveBeenCalledWith({
clientConfig: {
region: 'us-east-1',
},
masterCredentials: {
AccessKeyId: 'asdf',
},
params: {
ExternalId: undefined,
RoleArn: 'SomeRole',
},
});
});
it('returns a signed url for AWS credentials and passes the external id', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator({ config });
const authPromise = authTranslator.decorateClusterDetailsWithAuth({
assumeRole: 'SomeRole',
externalId: 'external-id',
name: 'test-cluster',
url: '',
authProvider: 'aws',
});
expect((await authPromise).serviceAccountToken).toEqual(
'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
);
expect(fromTemporaryCredentials).toHaveBeenCalledWith({
clientConfig: {
region: 'us-east-1',
},
masterCredentials: {
AccessKeyId: 'asdf',
},
params: {
ExternalId: 'external-id',
RoleArn: 'SomeRole',
},
});
});
describe('When the credentials is failing', () => {
beforeEach(() => {
jest.spyOn(AWS.config, 'getCredentials').mockImplementation(cb => {
cb(awsError, null);
presign = jest.fn(async () => {
throw new Error('no way');
});
});
it('throws the right error', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator();
const authTranslator = new AwsIamKubernetesAuthTranslator({ config });
await expect(
authTranslator.decorateClusterDetailsWithAuth({
assumeRole: role,
name: 'test-cluster',
url: '',
authProvider: 'aws',
}),
).rejects.toEqual(awsError);
});
});
describe('When the role is assumed', () => {
// These credentials are not real.
// Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html
AWS.config.credentials = new AWS.Credentials(
'AKIAIOSFODNN7EXAMPLE',
'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
);
role = 'SomeRole';
describe('When the role is valid', () => {
it('returns a signed url for AWS credentials', async () => {
const response = await executeTranslation();
expect(response.serviceAccountToken).toBeDefined();
});
});
describe('When the role is invalid', () => {
it('returns the original AWS credentials', async () => {
assumeResponse = undefined;
await expect(executeTranslation()).rejects.toThrow(
/Unable to assume role:/,
);
});
});
});
describe('When no AWS creds are available', () => {
it('throws unable to get AWS credentials', async () => {
mockedCredentials = new Error();
await expect(executeTranslation()).rejects.toThrow(
'No AWS credentials found.',
);
});
});
describe('When invalid AWS creds are available', () => {
it('throws credentials are invalid to get AWS credentials', async () => {
const undefinedSecret: any = undefined;
AWS.config.credentials = new AWS.Credentials(
'AKIAIOSFODNN7EXAMPLE',
undefinedSecret,
);
await expect(executeTranslation()).rejects.toThrow(
'Invalid AWS credentials found.',
);
).rejects.toThrow('no way');
});
});
});
@@ -13,10 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import AWS, { Credentials } from 'aws-sdk';
import { sign } from 'aws4';
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';
import {
AwsCredentialsManager,
DefaultAwsCredentialsManager,
} from '@backstage/integration-aws-node';
import { Config } from '@backstage/config';
/**
*
@@ -28,6 +34,8 @@ export type SigningCreds = {
sessionToken: string | undefined;
};
const defaultRegion = 'us-east-1';
/**
*
* @public
@@ -35,90 +43,70 @@ export type SigningCreds = {
export class AwsIamKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
validCredentials(creds: SigningCreds): boolean {
return (creds?.accessKeyId && creds?.secretAccessKey) as unknown as boolean;
private readonly credsManager: AwsCredentialsManager;
constructor(opts: { config: Config }) {
this.credsManager = DefaultAwsCredentialsManager.fromConfig(opts.config);
}
awsGetCredentials = async (): Promise<Credentials> => {
return new Promise((resolve, reject) => {
AWS.config.getCredentials(err => {
if (err) {
return reject(err);
}
return resolve(AWS.config.credentials as Credentials);
});
});
};
async getCredentials(
assumeRole?: string,
externalId?: string,
): Promise<SigningCreds> {
const awsCreds = await this.awsGetCredentials();
if (!(awsCreds instanceof Credentials))
throw new Error('No AWS credentials found.');
let creds: SigningCreds = {
accessKeyId: awsCreds.accessKeyId,
secretAccessKey: awsCreds.secretAccessKey,
sessionToken: awsCreds.sessionToken,
};
if (!this.validCredentials(creds))
throw new Error('Invalid AWS credentials found.');
if (!assumeRole) return creds;
try {
const params: AWS.STS.Types.AssumeRoleRequest = {
RoleArn: assumeRole,
RoleSessionName: 'backstage-login',
};
if (externalId) params.ExternalId = externalId;
const assumedRole = await new AWS.STS().assumeRole(params).promise();
if (!assumedRole.Credentials) {
throw new Error(`No credentials returned for role ${assumeRole}`);
}
creds = {
accessKeyId: assumedRole.Credentials.AccessKeyId,
secretAccessKey: assumedRole.Credentials.SecretAccessKey,
sessionToken: assumedRole.Credentials.SessionToken,
};
} catch (e) {
console.warn(`There was an error assuming the role: ${e}`);
throw new Error(`Unable to assume role: ${e}`);
}
return creds;
}
async getBearerToken(
private async getBearerToken(
clusterName: string,
assumeRole?: string,
externalId?: string,
): Promise<string> {
const credentials = await this.getCredentials(assumeRole, externalId);
const region = process.env.AWS_REGION ?? defaultRegion;
const request = {
host: `sts.amazonaws.com`,
path: `/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Expires=60`,
headers: {
'x-k8s-aws-id': clusterName,
let credentials = (await this.credsManager.getCredentialProvider())
.sdkCredentialProvider;
if (assumeRole) {
credentials = fromTemporaryCredentials({
masterCredentials: credentials,
clientConfig: {
region,
},
params: {
RoleArn: assumeRole,
ExternalId: externalId,
},
});
}
const signer = new SignatureV4({
credentials,
region,
service: 'sts',
sha256: Sha256,
});
const request = await signer.presign(
{
headers: {
host: `sts.${region}.amazonaws.com`,
'x-k8s-aws-id': clusterName,
},
hostname: `sts.${region}.amazonaws.com`,
method: 'GET',
path: '/',
protocol: 'https:',
query: {
Action: 'GetCallerIdentity',
Version: '2011-06-15',
},
},
signQuery: true,
};
{ expiresIn: 0 },
);
const signed = sign(request, credentials);
const url = `https://${signed.host}${signed.path}`;
const base64Url = Buffer.from(url, 'binary').toString('base64');
const urlSafeBase64Url = base64Url
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const query = Object.keys(request?.query ?? {})
.map(
q =>
`${encodeURIComponent(q)}=${encodeURIComponent(
request.query?.[q] as string,
)}`,
)
.join('&');
return `k8s-aws-v1.${urlSafeBase64Url}`;
const url = `https://${request.hostname}${request.path}?${query}`;
return `k8s-aws-v1.${Buffer.from(url).toString('base64url')}`;
}
async decorateClusterDetailsWithAuth(
@@ -136,6 +136,7 @@ export class KubernetesBuilder {
const objectsProvider = this.getObjectsProvider({
logger,
fetcher,
config,
serviceLocator,
customResources,
objectTypesToFetch: this.getObjectTypesToFetch(),
@@ -354,7 +355,7 @@ export class KubernetesBuilder {
protected buildAuthTranslatorMap() {
this.authTranslatorMap = {
google: new GoogleKubernetesAuthTranslator(),
aws: new AwsIamKubernetesAuthTranslator(),
aws: new AwsIamKubernetesAuthTranslator({ config: this.env.config }),
azure: new AzureIdentityKubernetesAuthTranslator(this.env.logger),
serviceAccount: new NoopKubernetesAuthTranslator(),
googleServiceAccount: new GoogleServiceAccountAuthTranslator(),
@@ -28,6 +28,7 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
import { ConfigReader } from '@backstage/config';
const fetchObjectsForService = jest.fn();
const fetchPodMetricsByNamespaces = jest.fn();
@@ -158,6 +159,7 @@ function mockFetchAndGetKubernetesFanOutHandler(
function getKubernetesFanOutHandler(customResources: CustomResource[]) {
return new KubernetesFanOutHandler({
logger: getVoidLogger(),
config: new ConfigReader({}),
fetcher: {
fetchObjectsForService,
fetchPodMetricsByNamespaces,
@@ -868,6 +870,7 @@ describe('getKubernetesObjectsByEntity', () => {
const sut = new KubernetesFanOutHandler({
logger,
fetcher: new KubernetesClientBasedFetcher({ logger }),
config: new ConfigReader({}),
serviceLocator: fleet,
customResources: [],
objectTypesToFetch: [
@@ -25,6 +25,7 @@ import type {
KubernetesRequestBody,
ObjectsByEntityResponse,
} from '@backstage/plugin-kubernetes-common';
import { Config } from '@backstage/config';
/**
*
@@ -242,6 +243,7 @@ export interface AWSClusterDetails extends ClusterDetails {
*/
export interface KubernetesObjectsProviderOptions {
logger: Logger;
config: Config;
fetcher: KubernetesFetcher;
serviceLocator: KubernetesServiceLocator;
customResources: CustomResource[];
+5 -3
View File
@@ -1426,7 +1426,7 @@ __metadata:
languageName: node
linkType: hard
"@aws-sdk/signature-v4@npm:3.310.0":
"@aws-sdk/signature-v4@npm:3.310.0, @aws-sdk/signature-v4@npm:^3.310.0":
version: 3.310.0
resolution: "@aws-sdk/signature-v4@npm:3.310.0"
dependencies:
@@ -7069,6 +7069,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "@backstage/plugin-kubernetes-backend@workspace:plugins/kubernetes-backend"
dependencies:
"@aws-crypto/sha256-js": ^3.0.0
"@aws-sdk/credential-providers": ^3.208.0
"@aws-sdk/signature-v4": ^3.310.0
"@azure/identity": ^2.0.4
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
@@ -7078,6 +7081,7 @@ __metadata:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/integration-aws-node": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"
"@backstage/plugin-kubernetes-common": "workspace:^"
@@ -7090,8 +7094,6 @@ __metadata:
"@types/express": ^4.17.6
"@types/http-proxy-middleware": ^0.19.3
"@types/luxon": ^3.0.0
aws-sdk: ^2.840.0
aws-sdk-mock: ^5.2.1
aws4: ^1.11.0
compression: ^1.7.4
cors: ^2.8.5