Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/actions/bootstrap/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,17 @@ export interface BootstrapParameters {
readonly customPermissionsBoundary?: string;
}

export interface EnvironmentBootstrapResult {
environment: cxapi.Environment;
status: 'success' | 'no-op';
duration: number;
}

export interface BootstrapResult {
environments: EnvironmentBootstrapResult[];
duration: number;
}

/**
* Parameters of the bootstrapping template with flexible configuration options
*/
Expand Down
21 changes: 18 additions & 3 deletions packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as chokidar from 'chokidar';
import * as fs from 'fs-extra';
import type { ToolkitServices } from './private';
import { assemblyFromSource } from './private';
import type { BootstrapEnvironments, BootstrapOptions } from '../actions/bootstrap';
import type { BootstrapEnvironments, BootstrapOptions, BootstrapResult, EnvironmentBootstrapResult } from '../actions/bootstrap';
import { BootstrapSource } from '../actions/bootstrap';
import { AssetBuildTime, type DeployOptions } from '../actions/deploy';
import { type ExtendedDeployOptions, buildParameterMap, createHotswapPropertyOverrides, removePublishedAssets } from '../actions/deploy/private';
Expand Down Expand Up @@ -147,7 +147,10 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
/**
* Bootstrap Action
*/
public async bootstrap(environments: BootstrapEnvironments, options: BootstrapOptions): Promise<void> {
public async bootstrap(environments: BootstrapEnvironments, options: BootstrapOptions): Promise<BootstrapResult> {
const startTime = Date.now();
const results: EnvironmentBootstrapResult[] = [];

const ioHelper = asIoHelper(this.ioHost, 'bootstrap');
const bootstrapEnvironments = await environments.getEnvironments();
const source = options.source ?? BootstrapSource.default();
Expand Down Expand Up @@ -177,17 +180,29 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
usePreviousParameters: parameters?.keepExistingParameters,
},
);

const message = bootstrapResult.noOp
? ` ✅ ${environment.name} (no changes)`
: ` ✅ ${environment.name}`;

await ioHelper.notify(IO.CDK_TOOLKIT_I9900.msg(chalk.green('\n' + message), { environment }));
await bootstrapSpan.end();
const envTime = await bootstrapSpan.end();
Copy link
Contributor

@mrgrain mrgrain Mar 20, 2025

Choose a reason for hiding this comment

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

you now have bootstrapSpan.end(); twice, causing the message to be printed twice. your reporting block needs to move to where the original end message was.

const result: EnvironmentBootstrapResult = {
environment,
status: bootstrapResult.noOp ? 'no-op' : 'success',
duration: envTime.asMs,
};
results.push(result);
} catch (e: any) {
await ioHelper.notify(IO.CDK_TOOLKIT_E9900.msg(`\n ❌ ${chalk.bold(environment.name)} failed: ${formatErrorMessage(e)}`, { error: e }));
throw e;
}
})));

return {
environments: results,
duration: Date.now() - startTime,
};
}

/**
Expand Down
135 changes: 102 additions & 33 deletions packages/@aws-cdk/toolkit-lib/test/actions/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as path from 'node:path';
import { EnvironmentUtils } from '@aws-cdk/cx-api';
import type { Stack } from '@aws-sdk/client-cloudformation';
import {
CreateChangeSetCommand,
Expand All @@ -8,6 +9,7 @@ import {
ExecuteChangeSetCommand,
} from '@aws-sdk/client-cloudformation';
import { bold } from 'chalk';

import type { BootstrapOptions } from '../../lib/actions/bootstrap';
import { BootstrapEnvironments, BootstrapSource, BootstrapStackParameters } from '../../lib/actions/bootstrap';
import { SdkProvider } from '../../lib/api/aws-cdk';
Expand Down Expand Up @@ -92,6 +94,11 @@ async function runBootstrap(options?: {
});
}

function expectValidBootstrapResult(result: any) {
expect(result).toHaveProperty('environments');
expect(Array.isArray(result.environments)).toBe(true);
}

function expectSuccessfulBootstrap() {
expect(mockCloudFormationClient.calls().length).toBeGreaterThan(0);
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
Expand Down Expand Up @@ -132,9 +139,12 @@ describe('bootstrap', () => {
setupMockCloudFormationClient(mockStack2);

// WHEN
await runBootstrap({ environments: ['aws://123456789012/us-east-1', 'aws://234567890123/eu-west-1'] });
const result = await runBootstrap({ environments: ['aws://123456789012/us-east-1', 'aws://234567890123/eu-west-1'] });

// THEN
expectValidBootstrapResult(result);
expect(result.environments.length).toBe(2);

expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining(`${bold('aws://123456789012/us-east-1')}: bootstrapping...`),
}));
Expand Down Expand Up @@ -168,35 +178,20 @@ describe('bootstrap', () => {

test('handles errors in user-specified environments', async () => {
// GIVEN
const mockStack = createMockStack([
{ OutputKey: 'BucketName', OutputValue: 'BUCKET_NAME' },
{ OutputKey: 'BucketDomainName', OutputValue: 'BUCKET_ENDPOINT' },
{ OutputKey: 'BootstrapVersion', OutputValue: '1' },
]);
setupMockCloudFormationClient(mockStack);

// Mock an access denied error
const accessDeniedError = new Error('Access Denied');
accessDeniedError.name = 'AccessDeniedException';
const error = new Error('Access Denied');
error.name = 'AccessDeniedException';
mockCloudFormationClient
.on(CreateChangeSetCommand)
.rejects(accessDeniedError);
.rejects(error);

// WHEN/THEN
await expect(runBootstrap({ environments: ['aws://123456789012/us-east-1'] }))
.rejects.toThrow('Access Denied');

// Get all error notifications
const errorCalls = ioHost.notifySpy.mock.calls
.filter(call => call[0].level === 'error')
.map(call => call[0]);

// Verify error notifications
expect(errorCalls).toContainEqual(expect.objectContaining({
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
level: 'error',
message: expect.stringContaining('❌'),
}));
expect(errorCalls).toContainEqual(expect.objectContaining({
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
level: 'error',
message: expect.stringContaining(`${bold('aws://123456789012/us-east-1')} failed: Access Denied`),
}));
Expand Down Expand Up @@ -492,37 +487,111 @@ describe('bootstrap', () => {
});

describe('error handling', () => {
test('handles generic bootstrap errors', async () => {
test('returns correct BootstrapResult for successful bootstraps', async () => {
// GIVEN
mockCloudFormationClient.onAnyCommand().rejects(new Error('Bootstrap failed'));
const mockStack = createMockStack([
{ OutputKey: 'BucketName', OutputValue: 'BUCKET_NAME' },
{ OutputKey: 'BucketDomainName', OutputValue: 'BUCKET_ENDPOINT' },
{ OutputKey: 'BootstrapVersion', OutputValue: '1' },
]);
setupMockCloudFormationClient(mockStack);

// WHEN
await expect(runBootstrap()).rejects.toThrow('Bootstrap failed');
const result = await runBootstrap({ environments: ['aws://123456789012/us-east-1'] });

// THEN
expectValidBootstrapResult(result);
expect(result.environments.length).toBe(1);
expect(result.environments[0].status).toBe('success');
expect(result.environments[0].environment).toStrictEqual(EnvironmentUtils.make('123456789012', 'us-east-1'));
expect(result.environments[0].duration).toBeGreaterThan(0);
});

test('returns correct BootstrapResult for no-op scenarios', async () => {
// GIVEN
const mockExistingStack = {
StackId: 'mock-stack-id',
StackName: 'CDKToolkit',
StackStatus: 'CREATE_COMPLETE',
CreationTime: new Date(),
LastUpdatedTime: new Date(),
Outputs: [
{ OutputKey: 'BucketName', OutputValue: 'BUCKET_NAME' },
{ OutputKey: 'BucketDomainName', OutputValue: 'BUCKET_ENDPOINT' },
{ OutputKey: 'BootstrapVersion', OutputValue: '1' },
],
} as Stack;

mockCloudFormationClient
.on(DescribeStacksCommand)
.resolves({ Stacks: [mockExistingStack] })
.on(CreateChangeSetCommand)
.resolves({ Id: 'CHANGESET_ID' })
.on(DescribeChangeSetCommand)
.resolves({
Status: 'FAILED',
StatusReason: 'No updates are to be performed.',
Changes: [],
});

// WHEN
const result = await runBootstrap({ environments: ['aws://123456789012/us-east-1'] });

// THEN
expectValidBootstrapResult(result);
expect(result.environments.length).toBe(1);
expect(result.environments[0].status).toBe('no-op');
expect(result.environments[0].environment).toStrictEqual(EnvironmentUtils.make('123456789012', 'us-east-1'));
expect(result.environments[0].duration).toBeGreaterThan(0);
});

test('returns correct BootstrapResult for failure', async () => {
// GIVEN
const error = new Error('Access Denied');
error.name = 'AccessDeniedException';
mockCloudFormationClient
.on(DescribeStacksCommand)
.rejects(error);

// WHEN/THEN
await expect(runBootstrap({ environments: ['aws://123456789012/us-east-1'] }))
.rejects.toThrow('Access Denied');
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
level: 'error',
message: expect.stringContaining('❌'),
}));
});

test('handles permission errors', async () => {
test('handles generic bootstrap errors', async () => {
// GIVEN
const permissionError = new Error('Access Denied');
permissionError.name = 'AccessDeniedException';
mockCloudFormationClient.onAnyCommand().rejects(permissionError);

// WHEN
await expect(runBootstrap()).rejects.toThrow('Access Denied');
const error = new Error('Bootstrap failed');
mockCloudFormationClient
.on(DescribeStacksCommand)
.rejects(error);

// THEN
// WHEN/THEN
await expect(runBootstrap({ environments: ['aws://123456789012/us-east-1'] }))
.rejects.toThrow('Bootstrap failed');
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
level: 'error',
message: expect.stringContaining('❌'),
}));
});

test('handles permission errors', async () => {
// GIVEN
const error = new Error('Access Denied');
error.name = 'AccessDeniedException';
mockCloudFormationClient
.on(DescribeStacksCommand)
.rejects(error);

// WHEN/THEN
await expect(runBootstrap({ environments: ['aws://123456789012/us-east-1'] }))
.rejects.toThrow('Access Denied');
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
level: 'error',
message: expect.stringContaining('Access Denied'),
message: expect.stringContaining(''),
}));
});
});
Expand Down