packages/@aws-cdk-testing/cli-integ/lib/aws.ts (239 lines of code) (raw):
import {
CloudFormationClient,
DeleteStackCommand,
DescribeStacksCommand,
UpdateTerminationProtectionCommand,
type Stack,
} from '@aws-sdk/client-cloudformation';
import { DeleteRepositoryCommand, ECRClient } from '@aws-sdk/client-ecr';
import { ECRPUBLICClient } from '@aws-sdk/client-ecr-public';
import { ECSClient } from '@aws-sdk/client-ecs';
import { IAMClient } from '@aws-sdk/client-iam';
import { LambdaClient } from '@aws-sdk/client-lambda';
import {
S3Client,
DeleteObjectsCommand,
ListObjectVersionsCommand,
type ObjectIdentifier,
DeleteBucketCommand,
} from '@aws-sdk/client-s3';
import { SNSClient } from '@aws-sdk/client-sns';
import { SSOClient } from '@aws-sdk/client-sso';
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers';
import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@smithy/types';
import { ConfiguredRetryStrategy } from '@smithy/util-retry';
interface ClientConfig {
readonly credentials: AwsCredentialIdentityProvider | AwsCredentialIdentity;
readonly region: string;
readonly retryStrategy: ConfiguredRetryStrategy;
}
export class AwsClients {
public static async forIdentity(region: string, identity: AwsCredentialIdentity, output: NodeJS.WritableStream) {
return new AwsClients(region, output, identity);
}
public static async forRegion(region: string, output: NodeJS.WritableStream) {
return new AwsClients(region, output);
}
private readonly config: ClientConfig;
public readonly cloudFormation: CloudFormationClient;
public readonly s3: S3Client;
public readonly ecr: ECRClient;
public readonly ecrPublic: ECRPUBLICClient;
public readonly ecs: ECSClient;
public readonly sso: SSOClient;
public readonly sns: SNSClient;
public readonly iam: IAMClient;
public readonly lambda: LambdaClient;
public readonly sts: STSClient;
constructor(
public readonly region: string,
private readonly output: NodeJS.WritableStream,
public readonly identity?: AwsCredentialIdentity) {
this.config = {
credentials: this.identity ?? chainableCredentials(this.region),
region: this.region,
retryStrategy: new ConfiguredRetryStrategy(9, (attempt: number) => attempt ** 500),
};
this.cloudFormation = new CloudFormationClient(this.config);
this.s3 = new S3Client(this.config);
this.ecr = new ECRClient(this.config);
this.ecrPublic = new ECRPUBLICClient({ ...this.config, region: 'us-east-1' /* public gallery is only available in us-east-1 */ });
this.ecs = new ECSClient(this.config);
this.sso = new SSOClient(this.config);
this.sns = new SNSClient(this.config);
this.iam = new IAMClient(this.config);
this.lambda = new LambdaClient(this.config);
this.sts = new STSClient(this.config);
}
public async account(): Promise<string> {
// Reduce # of retries, we use this as a circuit breaker for detecting no-config
const stsClient = new STSClient({
credentials: this.config.credentials,
region: this.config.region,
maxAttempts: 2,
});
return (await stsClient.send(new GetCallerIdentityCommand({}))).Account!;
}
/**
* If the clients already has an established identity (via atmosphere for example),
* return an environment variable map activating it.
*
* Otherwise, returns undefined.
*/
public identityEnv(): Record<string, string> | undefined {
return this.identity ? {
AWS_ACCESS_KEY_ID: this.identity.accessKeyId,
AWS_SECRET_ACCESS_KEY: this.identity.secretAccessKey,
AWS_SESSION_TOKEN: this.identity.sessionToken!,
// unset any previously used profile because the SDK will prefer
// this over static env credentials. this is relevant for tests running on CodeBuild
// because we use a profile as our main credentials source.
AWS_PROFILE: '',
} : undefined;
}
/**
* Resolve the current identity or identity provider to credentials
*/
public async credentials() {
const x = this.config.credentials;
if (isAwsCredentialIdentity(x)) {
return x;
}
return x();
}
public async deleteStacks(...stackNames: string[]) {
if (stackNames.length === 0) {
return;
}
// We purposely do all stacks serially, because they've been ordered
// to do the bootstrap stack last.
for (const stackName of stackNames) {
await this.cloudFormation.send(
new UpdateTerminationProtectionCommand({
EnableTerminationProtection: false,
StackName: stackName,
}),
);
await this.cloudFormation.send(
new DeleteStackCommand({
StackName: stackName,
}),
);
await retry(this.output, `Deleting ${stackName}`, retry.forSeconds(600), async () => {
const status = await this.stackStatus(stackName);
if (status !== undefined && status.endsWith('_FAILED')) {
throw retry.abort(new Error(`'${stackName}' is in state '${status}'`));
}
if (status !== undefined) {
throw new Error(`Delete of '${stackName}' not complete yet, status: '${status}'`);
}
});
}
}
public async stackStatus(stackName: string): Promise<string | undefined> {
try {
return (
await this.cloudFormation.send(
new DescribeStacksCommand({
StackName: stackName,
}),
)
).Stacks?.[0].StackStatus;
} catch (e: any) {
if (isStackMissingError(e)) {
return undefined;
}
throw e;
}
}
public async emptyBucket(bucketName: string, options?: { bypassGovernance?: boolean }) {
const objects = await this.s3.send(
new ListObjectVersionsCommand({
Bucket: bucketName,
}),
);
const deletes = [...(objects.Versions || []), ...(objects.DeleteMarkers || [])].reduce((acc, obj) => {
if (typeof obj.VersionId !== 'undefined' && typeof obj.Key !== 'undefined') {
acc.push({ Key: obj.Key, VersionId: obj.VersionId });
} else if (typeof obj.Key !== 'undefined') {
acc.push({ Key: obj.Key });
}
return acc;
}, [] as ObjectIdentifier[]);
if (deletes.length === 0) {
return Promise.resolve();
}
return this.s3.send(
new DeleteObjectsCommand({
Bucket: bucketName,
Delete: {
Objects: deletes,
Quiet: false,
},
BypassGovernanceRetention: options?.bypassGovernance ? true : undefined,
}),
);
}
public async deleteImageRepository(repositoryName: string) {
await this.ecr.send(
new DeleteRepositoryCommand({
repositoryName: repositoryName,
force: true,
}),
);
}
public async deleteBucket(bucketName: string) {
try {
await this.emptyBucket(bucketName);
await this.s3.send(
new DeleteBucketCommand({
Bucket: bucketName,
}),
);
} catch (e: any) {
if (isBucketMissingError(e)) {
return;
}
throw e;
}
}
}
export function isStackMissingError(e: Error) {
return e.message.indexOf('does not exist') > -1;
}
export function isBucketMissingError(e: Error) {
return e.message.indexOf('does not exist') > -1;
}
/**
* Retry an async operation until a deadline is hit.
*
* Use `retry.forSeconds()` to construct a deadline relative to right now.
*
* Exceptions will cause the operation to retry. Use `retry.abort` to annotate an exception
* to stop the retry and end in a failure.
*/
export async function retry<A>(
output: NodeJS.WritableStream,
operation: string,
deadline: Date,
block: () => Promise<A>,
): Promise<A> {
let i = 0;
output.write(`💈 ${operation}\n`);
while (true) {
try {
i++;
const ret = await block();
output.write(`💈 ${operation}: succeeded after ${i} attempts\n`);
return ret;
} catch (e: any) {
if (e.abort || Date.now() > deadline.getTime()) {
throw new Error(`${operation}: did not succeed after ${i} attempts: ${e}`);
}
output.write(`⏳ ${operation} (${e.message})\n`);
await sleep(5000);
}
}
}
/**
* Make a deadline for the `retry` function relative to the current time.
*/
retry.forSeconds = (seconds: number): Date => {
return new Date(Date.now() + seconds * 1000);
};
/**
* Annotate an error to stop the retrying
*/
retry.abort = (e: Error): Error => {
(e as any).abort = true;
return e;
};
export function outputFromStack(key: string, stack: Stack): string | undefined {
return (stack.Outputs ?? []).find((o) => o.OutputKey === key)?.OutputValue;
}
export async function sleep(ms: number) {
return new Promise((ok) => setTimeout(ok, ms));
}
function chainableCredentials(region: string): AwsCredentialIdentityProvider {
if ((process.env.CODEBUILD_BUILD_ARN || process.env.GITHUB_RUN_ID) && process.env.AWS_PROFILE) {
// in codebuild we must assume the role that the cdk uses
// otherwise credentials will just be picked up by the normal sdk
// heuristics and expire after an hour.
return fromIni({
clientConfig: { region },
});
}
// Otherwise just get what's default
return fromNodeProviderChain({ clientConfig: { region } });
}
function isAwsCredentialIdentity(x: any): x is AwsCredentialIdentity {
return Boolean(x && typeof x === 'object' && x.accessKeyId);
}