Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(toolkit): bootstrap action #33453

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/@aws-cdk/toolkit/lib/api/aws-cdk.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/* eslint-disable import/no-restricted-paths */

export { Bootstrapper, type BootstrapEnvironmentOptions } from '../../../../aws-cdk/lib/api';

// APIs
export { formatSdkLoggerContent, SdkProvider } from '../../../../aws-cdk/lib/api/aws-auth';
export { Mode } from '../../../../aws-cdk/lib/api/plugin';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where are we using Mode?

export { Context, PROJECT_CONTEXT } from '../../../../aws-cdk/lib/api/context';
export { Deployments, type SuccessfulDeployStackResult } from '../../../../aws-cdk/lib/api/deployments';
export { Settings } from '../../../../aws-cdk/lib/api/settings';
Expand All @@ -17,10 +20,11 @@ export { HotswapMode } from '../../../../aws-cdk/lib/api/hotswap/common';
export { StackActivityProgress } from '../../../../aws-cdk/lib/api/util/cloudformation/stack-activity-monitor';
export { RWLock, type ILock } from '../../../../aws-cdk/lib/api/util/rwlock';
export { formatTime } from '../../../../aws-cdk/lib/api/util/string-manipulation';
export { rootDir } from '../../../../aws-cdk/lib/util/directories';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where are we using rootDir?


// @todo Not yet API probably should be
export { formatErrorMessage } from '../../../../aws-cdk/lib/util/error';
export { obscureTemplate, serializeStructure } from '../../../../aws-cdk/lib/serialize';
export { loadStructuredFile, obscureTemplate, serializeStructure } from '../../../../aws-cdk/lib/serialize';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where are we using loadStructuredFile?

export { loadTree, some } from '../../../../aws-cdk/lib/tree';
export { splitBySize } from '../../../../aws-cdk/lib/util';
export { validateSnsTopicArn } from '../../../../aws-cdk/lib/util/validate-notification-arn';
Expand All @@ -31,6 +35,7 @@ export type { AssetBuildNode, AssetPublishNode, StackNode } from '../../../../aw
export { CloudWatchLogEventMonitor } from '../../../../aws-cdk/lib/api/logs/logs-monitor';
export { findCloudWatchLogGroups } from '../../../../aws-cdk/lib/api/logs/find-cloudwatch-logs';
export { HotswapPropertyOverrides, EcsHotswapProperties } from '../../../../aws-cdk/lib/api/hotswap/common';
export { type StringWithoutPlaceholders } from '../../../../aws-cdk/lib/api/util/placeholders';

// @todo Cloud Assembly and Executable - this is a messy API right now
export { CloudAssembly, sanitizePatterns, type StackDetails, StackCollection, ExtendedStackSelection } from '../../../../aws-cdk/lib/api/cxapp/cloud-assembly';
Expand Down
3 changes: 3 additions & 0 deletions packages/@aws-cdk/toolkit/lib/api/io/private/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export const CODES = {
CDK_TOOLKIT_E7900: 'Stack deletion failed',

// 9: Bootstrap
CDK_TOOLKIT_I9000: 'Provides bootstrap times',

CDK_TOOLKIT_E9900: 'Bootstrap failed',

// Assembly codes
CDK_ASSEMBLY_I0042: 'Writing updated context',
Expand Down
5 changes: 3 additions & 2 deletions packages/@aws-cdk/toolkit/lib/api/io/private/timer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class Timer {
* Ends the current timer as a specified timing and notifies the IoHost.
* @returns the elapsed time
*/
public async endAs(ioHost: ActionAwareIoHost, type: 'synth' | 'deploy' | 'rollback' | 'destroy') {
public async endAs(ioHost: ActionAwareIoHost, type: 'synth' | 'deploy' | 'rollback' | 'destroy' | 'bootstrap') {
const duration = this.end();
const { code, text } = timerMessageProps(type);

Expand All @@ -49,7 +49,7 @@ export class Timer {
}
}

function timerMessageProps(type: 'synth' | 'deploy' | 'rollback'| 'destroy'): {
function timerMessageProps(type: 'synth' | 'deploy' | 'rollback'| 'destroy' | 'bootstrap'): {
code: VALID_CODE;
text: string;
} {
Expand All @@ -58,5 +58,6 @@ function timerMessageProps(type: 'synth' | 'deploy' | 'rollback'| 'destroy'): {
case 'deploy': return { code: 'CDK_TOOLKIT_I5000', text: 'Deployment' };
case 'rollback': return { code: 'CDK_TOOLKIT_I6000', text: 'Rollback' };
case 'destroy': return { code: 'CDK_TOOLKIT_I7000', text: 'Destroy' };
case 'bootstrap': return { code: 'CDK_TOOLKIT_I9000', text: 'Bootstrap' };
}
}
56 changes: 55 additions & 1 deletion packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ import { type RollbackOptions } from '../actions/rollback';
import { type SynthOptions } from '../actions/synth';
import { patternsArrayForWatch, WatchOptions } from '../actions/watch';
import { type SdkOptions } from '../api/aws-auth';
import { DEFAULT_TOOLKIT_STACK_NAME, SdkProvider, SuccessfulDeployStackResult, StackCollection, Deployments, HotswapMode, StackActivityProgress, ResourceMigrator, obscureTemplate, serializeStructure, tagsForStack, CliIoHost, validateSnsTopicArn, Concurrency, WorkGraphBuilder, AssetBuildNode, AssetPublishNode, StackNode, formatErrorMessage, CloudWatchLogEventMonitor, findCloudWatchLogGroups, formatTime, StackDetails } from '../api/aws-cdk';
import { DEFAULT_TOOLKIT_STACK_NAME, Bootstrapper, BootstrapEnvironmentOptions, SdkProvider, SuccessfulDeployStackResult, StackCollection, Deployments, HotswapMode, StackActivityProgress, ResourceMigrator, obscureTemplate, serializeStructure, tagsForStack, CliIoHost, validateSnsTopicArn, Concurrency, WorkGraphBuilder, AssetBuildNode, AssetPublishNode, StackNode, formatErrorMessage, CloudWatchLogEventMonitor, findCloudWatchLogGroups, formatTime, StackDetails } from '../api/aws-cdk';
import { CachedCloudAssemblySource, IdentityCloudAssemblySource, StackAssembly, ICloudAssemblySource, StackSelectionStrategy } from '../api/cloud-assembly';
import { ALL_STACKS, CloudAssemblySourceBuilder } from '../api/cloud-assembly/private';
import { ToolkitError } from '../api/errors';
import { IIoHost, IoMessageCode, IoMessageLevel } from '../api/io';
import { asSdkLogger, withAction, Timer, confirm, error, info, success, warn, ActionAwareIoHost, debug, result, withoutEmojis, withoutColor, withTrimmedWhitespace } from '../api/io/private';

// Must use a require() otherwise esbuild complains about calling a namespace
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pLimit: typeof import('p-limit') = require('p-limit');

/**
* The current action being performed by the CLI. 'none' represents the absence of an action.
*/
Expand Down Expand Up @@ -153,6 +157,56 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
};
}

/**
* Bootstrap Action
*
* Bootstraps the CDK Toolkit stack in the environments used by the specified stack(s).
*/

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
*/
*/

public async bootstrap(cx: ICloudAssemblySource, options: BootstrapEnvironmentOptions = {}): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should copy over and expose only what makes sense for BootstrapEnvironmentOptions. Take a look at what we've brought in thats slightly different than what the CDK CLI offers. It lives in lib/actions/destroy/index.ts and all the other actions we currently have

const ioHost = withAction(this.ioHost, 'bootstrap');
const assembly = await this.assemblyFromSource(cx);
const stackCollection = assembly.selectStacksV2({
patterns: ['**'],
strategy: StackSelectionStrategy.ALL_STACKS,
});

const bootstrapper = new Bootstrapper(options.source);

const environments = stackCollection.stackArtifacts.map(stack => stack.environment);
const sdkProvider = await this.sdkProvider('bootstrap');
const limit = pLimit(20);

// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
await Promise.all(environments.map((environment: cxapi.Environment) => limit(async () => {
await ioHost.notify(info(`${chalk.bold(environment.name)}: bootstrapping...`));
const bootstrapTimer = Timer.start();

try {
const bootstrapResult = await bootstrapper.bootstrapEnvironment(environment, sdkProvider, {
toolkitStackName: this.toolkitStackName,
roleArn: options.roleArn,
terminationProtection: options.terminationProtection,
parameters: options.parameters,
});
const message = bootstrapResult.noOp
? ` ✅ ${environment.name} (no changes)`
: ` ✅ ${environment.name}`;

await ioHost.notify(success('\n' + message));
await bootstrapTimer.endAs(ioHost, 'bootstrap');
} catch (e) {
await ioHost.notify({
time: new Date(),
level: 'error',
code: 'CDK_TOOLKIT_E9900' as IoMessageCode,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to typecast this to IoMessageCode?

message: `\n ❌ ${chalk.bold(environment.name)} failed: ${formatErrorMessage(e)}`,
});
throw e;
}
})));
}

/**
* Synth Action
*/
Expand Down
205 changes: 205 additions & 0 deletions packages/@aws-cdk/toolkit/test/actions/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Mock the Bootstrapper before imports
jest.mock('../../../../aws-cdk/lib/api', () => ({
...jest.requireActual('../../../../aws-cdk/lib/api'),
Bootstrapper: jest.fn().mockImplementation(() => ({
bootstrapEnvironment: jest.fn().mockResolvedValue({
noOp: false,
outputs: {},
stackArn: 'arn:aws:cloudformation:us-east-1:123456789012:stack/CDKToolkit/abcd1234',
}),
})),
}));

import * as os from 'node:os';
import * as path from 'node:path';
import * as cxapi from '@aws-cdk/cx-api';
import * as fs from 'fs-extra';
import { BootstrapEnvironmentOptions, SdkProvider, Bootstrapper, StringWithoutPlaceholders } from '../../lib/api/aws-cdk';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the imports here, if needed, should be imported into https://github.com/aws/aws-cdk/blob/main/packages/@aws-cdk/toolkit/test/util/aws-cdk.ts and used from there. that is because you are currently importing things you want to use in tests but it will get bundled into the released toolkit as well.

import { Toolkit } from '../../lib/toolkit';
import { TestIoHost } from '../_helpers';
import { TestCloudAssemblySource } from '../_helpers/test-cloud-assembly-source';

const ioHost = new TestIoHost();

// Create a mock SDK provider
const mockSdkProvider = {
withAwsCliCompatibleDefaults: jest.fn().mockResolvedValue({
sdk: {},
defaultAccount: jest.fn(),
defaultRegion: jest.fn(),
resolveEnvironment: jest.fn(env => ({
...env,
account: env.account || '123456789012',
region: env.region || 'us-east-1',
name: `aws://${env.account || '123456789012'}/${env.region || 'us-east-1'}`,
})),
forEnvironment: jest.fn().mockResolvedValue({
sdk: {
cloudFormation: () => ({
describeStacks: () => ({ promise: () => Promise.resolve({ Stacks: [] }) }),
}),
},
resolvedEnvironment: {
account: '123456789012',
region: 'us-east-1',
name: 'aws://123456789012/us-east-1',
},
}),
}),
};

// Mock the SdkProvider before creating the toolkit instance
jest.spyOn(SdkProvider, 'withAwsCliCompatibleDefaults').mockImplementation(mockSdkProvider.withAwsCliCompatibleDefaults);

const toolkit = new Toolkit({ ioHost });

describe('bootstrap', () => {
let tempOutDir: string;

beforeEach(() => {
ioHost.notifySpy.mockClear();
ioHost.requestSpy.mockClear();
mockSdkProvider.withAwsCliCompatibleDefaults.mockClear();
(Bootstrapper as jest.Mock).mockClear();

// Create a temporary directory for cloud assembly output
tempOutDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'cdk.out'));
});

afterEach(() => {
// Clean up temporary directory
fs.removeSync(tempOutDir);
});

test('bootstraps single environment', async () => {
// GIVEN
const mockEnvironment: cxapi.Environment = {
account: '123456789012',
region: 'us-east-1',
name: 'aws://123456789012/us-east-1',
};

const cx = new TestCloudAssemblySource({
stacks: [{
stackName: 'mock-stack',
env: `aws://${mockEnvironment.account}/${mockEnvironment.region}`,
template: { Resources: {} },
}],
schemaVersion: '30.0.0',
});

// WHEN
await toolkit.bootstrap(cx);

// THEN
expect(ioHost.notifySpy).toHaveBeenCalledTimes(3);

// First message is the bootstrapping notification
expect(ioHost.notifySpy).toHaveBeenNthCalledWith(1, expect.objectContaining({
action: 'bootstrap',
code: 'CDK_TOOLKIT_I0000',
level: 'info',
message: expect.stringContaining('bootstrapping...'),
time: expect.any(Date),
}));

// Second message is the success notification
expect(ioHost.notifySpy).toHaveBeenNthCalledWith(2, expect.objectContaining({
action: 'bootstrap',
code: 'CDK_TOOLKIT_I0000',
level: 'info',
message: expect.stringMatching(/✅\s+aws:\/\/123456789012\/us-east-1/),
time: expect.any(Date),
}));

// Third message is the bootstrap time notification
expect(ioHost.notifySpy).toHaveBeenNthCalledWith(3, expect.objectContaining({
action: 'bootstrap',
code: 'CDK_TOOLKIT_I9000',
level: 'info',
message: expect.stringMatching(/✨\s+Bootstrap time:/),
time: expect.any(Date),
data: expect.objectContaining({
duration: expect.any(Number),
}),
}));
expect(Bootstrapper).toHaveBeenCalled();
});

test('handles bootstrap options', async () => {
// GIVEN
const mockEnvironment: cxapi.Environment = {
account: '123456789012',
region: 'us-east-1',
name: 'aws://123456789012/us-east-1',
};

const cx = new TestCloudAssemblySource({
stacks: [{
stackName: 'mock-stack',
env: `aws://${mockEnvironment.account}/${mockEnvironment.region}`,
template: { Resources: {} },
}],
schemaVersion: '30.0.0',
});

const options: BootstrapEnvironmentOptions = {
roleArn: 'arn:aws:iam::123456789012:role/AdminRole' as StringWithoutPlaceholders,
terminationProtection: true,
parameters: {
bucketName: 'my-bucket',
kmsKeyId: 'my-key',
},
};

// WHEN
await toolkit.bootstrap(cx, options);

// THEN
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
level: 'info',
message: expect.stringContaining('bootstrapping...'),
}));
expect(Bootstrapper).toHaveBeenCalled();
const bootstrapper = ((Bootstrapper as jest.Mock).mock.results[0]?.value) as { bootstrapEnvironment: jest.Mock };
expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining(options),
);
});

test('handles bootstrap failure', async () => {
// GIVEN
const mockEnvironment: cxapi.Environment = {
account: '123456789012',
region: 'us-east-1',
name: 'aws://123456789012/us-east-1',
};

const cx = new TestCloudAssemblySource({
stacks: [{
stackName: 'mock-stack',
env: `aws://${mockEnvironment.account}/${mockEnvironment.region}`,
template: { Resources: {} },
}],
schemaVersion: '30.0.0',
});

// Mock a failure in the bootstrapper
(Bootstrapper as jest.Mock).mockImplementationOnce(() => ({
bootstrapEnvironment: jest.fn().mockRejectedValue(new Error('Bootstrap failed')),
}));

// WHEN
await expect(toolkit.bootstrap(cx)).rejects.toThrow('Bootstrap failed');

// THEN
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
level: 'error',
code: 'CDK_TOOLKIT_E9900',
message: expect.stringContaining('failed: Bootstrap failed'),
}));
expect(Bootstrapper).toHaveBeenCalled();
});
});