Skip to content

Commit b23b2da

Browse files
iankhougithub-actionsmrgrain
authored
feat(toolkit): add typed return to bootstrap action (#249)
Fixes #192 ## Description The `bootstrap` action in the Toolkit Library currently does not have a returned value. Although there is logged output, the `Toolkit` instance does not get any information about the result of the bootstrap action. ## Solution Make `Toolkit.bootstrap()` Return an instance of type `Promise<BootstrapResult>`, which also uses new type `EnvironmentBootstrapResult` with metrics for each environment. ## Testing Added unit tests. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Signed-off-by: github-actions <[email protected]> Co-authored-by: github-actions <[email protected]> Co-authored-by: Momo Kornher <[email protected]>
1 parent 192d0ee commit b23b2da

File tree

3 files changed

+131
-36
lines changed

3 files changed

+131
-36
lines changed

packages/@aws-cdk/toolkit-lib/lib/actions/bootstrap/index.ts

+11
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,17 @@ export interface BootstrapParameters {
169169
readonly customPermissionsBoundary?: string;
170170
}
171171

172+
export interface EnvironmentBootstrapResult {
173+
environment: cxapi.Environment;
174+
status: 'success' | 'no-op';
175+
duration: number;
176+
}
177+
178+
export interface BootstrapResult {
179+
environments: EnvironmentBootstrapResult[];
180+
duration: number;
181+
}
182+
172183
/**
173184
* Parameters of the bootstrapping template with flexible configuration options
174185
*/

packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as chokidar from 'chokidar';
55
import * as fs from 'fs-extra';
66
import type { ToolkitServices } from './private';
77
import { assemblyFromSource } from './private';
8-
import type { BootstrapEnvironments, BootstrapOptions } from '../actions/bootstrap';
8+
import type { BootstrapEnvironments, BootstrapOptions, BootstrapResult, EnvironmentBootstrapResult } from '../actions/bootstrap';
99
import { BootstrapSource } from '../actions/bootstrap';
1010
import { AssetBuildTime, type DeployOptions } from '../actions/deploy';
1111
import { type ExtendedDeployOptions, buildParameterMap, createHotswapPropertyOverrides, removePublishedAssets } from '../actions/deploy/private';
@@ -147,7 +147,10 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
147147
/**
148148
* Bootstrap Action
149149
*/
150-
public async bootstrap(environments: BootstrapEnvironments, options: BootstrapOptions): Promise<void> {
150+
public async bootstrap(environments: BootstrapEnvironments, options: BootstrapOptions): Promise<BootstrapResult> {
151+
const startTime = Date.now();
152+
const results: EnvironmentBootstrapResult[] = [];
153+
151154
const ioHelper = asIoHelper(this.ioHost, 'bootstrap');
152155
const bootstrapEnvironments = await environments.getEnvironments();
153156
const source = options.source ?? BootstrapSource.default();
@@ -177,17 +180,29 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
177180
usePreviousParameters: parameters?.keepExistingParameters,
178181
},
179182
);
183+
180184
const message = bootstrapResult.noOp
181185
? ` ✅ ${environment.name} (no changes)`
182186
: ` ✅ ${environment.name}`;
183187

184188
await ioHelper.notify(IO.CDK_TOOLKIT_I9900.msg(chalk.green('\n' + message), { environment }));
185-
await bootstrapSpan.end();
189+
const envTime = await bootstrapSpan.end();
190+
const result: EnvironmentBootstrapResult = {
191+
environment,
192+
status: bootstrapResult.noOp ? 'no-op' : 'success',
193+
duration: envTime.asMs,
194+
};
195+
results.push(result);
186196
} catch (e: any) {
187197
await ioHelper.notify(IO.CDK_TOOLKIT_E9900.msg(`\n ❌ ${chalk.bold(environment.name)} failed: ${formatErrorMessage(e)}`, { error: e }));
188198
throw e;
189199
}
190200
})));
201+
202+
return {
203+
environments: results,
204+
duration: Date.now() - startTime,
205+
};
191206
}
192207

193208
/**

packages/@aws-cdk/toolkit-lib/test/actions/bootstrap.test.ts

+102-33
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as path from 'node:path';
2+
import { EnvironmentUtils } from '@aws-cdk/cx-api';
23
import type { Stack } from '@aws-sdk/client-cloudformation';
34
import {
45
CreateChangeSetCommand,
@@ -8,6 +9,7 @@ import {
89
ExecuteChangeSetCommand,
910
} from '@aws-sdk/client-cloudformation';
1011
import { bold } from 'chalk';
12+
1113
import type { BootstrapOptions } from '../../lib/actions/bootstrap';
1214
import { BootstrapEnvironments, BootstrapSource, BootstrapStackParameters } from '../../lib/actions/bootstrap';
1315
import { SdkProvider } from '../../lib/api/aws-cdk';
@@ -92,6 +94,11 @@ async function runBootstrap(options?: {
9294
});
9395
}
9496

97+
function expectValidBootstrapResult(result: any) {
98+
expect(result).toHaveProperty('environments');
99+
expect(Array.isArray(result.environments)).toBe(true);
100+
}
101+
95102
function expectSuccessfulBootstrap() {
96103
expect(mockCloudFormationClient.calls().length).toBeGreaterThan(0);
97104
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
@@ -132,9 +139,12 @@ describe('bootstrap', () => {
132139
setupMockCloudFormationClient(mockStack2);
133140

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

137144
// THEN
145+
expectValidBootstrapResult(result);
146+
expect(result.environments.length).toBe(2);
147+
138148
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
139149
message: expect.stringContaining(`${bold('aws://123456789012/us-east-1')}: bootstrapping...`),
140150
}));
@@ -168,35 +178,20 @@ describe('bootstrap', () => {
168178

169179
test('handles errors in user-specified environments', async () => {
170180
// GIVEN
171-
const mockStack = createMockStack([
172-
{ OutputKey: 'BucketName', OutputValue: 'BUCKET_NAME' },
173-
{ OutputKey: 'BucketDomainName', OutputValue: 'BUCKET_ENDPOINT' },
174-
{ OutputKey: 'BootstrapVersion', OutputValue: '1' },
175-
]);
176-
setupMockCloudFormationClient(mockStack);
177-
178-
// Mock an access denied error
179-
const accessDeniedError = new Error('Access Denied');
180-
accessDeniedError.name = 'AccessDeniedException';
181+
const error = new Error('Access Denied');
182+
error.name = 'AccessDeniedException';
181183
mockCloudFormationClient
182184
.on(CreateChangeSetCommand)
183-
.rejects(accessDeniedError);
185+
.rejects(error);
184186

185187
// WHEN/THEN
186188
await expect(runBootstrap({ environments: ['aws://123456789012/us-east-1'] }))
187189
.rejects.toThrow('Access Denied');
188-
189-
// Get all error notifications
190-
const errorCalls = ioHost.notifySpy.mock.calls
191-
.filter(call => call[0].level === 'error')
192-
.map(call => call[0]);
193-
194-
// Verify error notifications
195-
expect(errorCalls).toContainEqual(expect.objectContaining({
190+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
196191
level: 'error',
197192
message: expect.stringContaining('❌'),
198193
}));
199-
expect(errorCalls).toContainEqual(expect.objectContaining({
194+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
200195
level: 'error',
201196
message: expect.stringContaining(`${bold('aws://123456789012/us-east-1')} failed: Access Denied`),
202197
}));
@@ -492,37 +487,111 @@ describe('bootstrap', () => {
492487
});
493488

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

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

502502
// THEN
503+
expectValidBootstrapResult(result);
504+
expect(result.environments.length).toBe(1);
505+
expect(result.environments[0].status).toBe('success');
506+
expect(result.environments[0].environment).toStrictEqual(EnvironmentUtils.make('123456789012', 'us-east-1'));
507+
expect(result.environments[0].duration).toBeGreaterThan(0);
508+
});
509+
510+
test('returns correct BootstrapResult for no-op scenarios', async () => {
511+
// GIVEN
512+
const mockExistingStack = {
513+
StackId: 'mock-stack-id',
514+
StackName: 'CDKToolkit',
515+
StackStatus: 'CREATE_COMPLETE',
516+
CreationTime: new Date(),
517+
LastUpdatedTime: new Date(),
518+
Outputs: [
519+
{ OutputKey: 'BucketName', OutputValue: 'BUCKET_NAME' },
520+
{ OutputKey: 'BucketDomainName', OutputValue: 'BUCKET_ENDPOINT' },
521+
{ OutputKey: 'BootstrapVersion', OutputValue: '1' },
522+
],
523+
} as Stack;
524+
525+
mockCloudFormationClient
526+
.on(DescribeStacksCommand)
527+
.resolves({ Stacks: [mockExistingStack] })
528+
.on(CreateChangeSetCommand)
529+
.resolves({ Id: 'CHANGESET_ID' })
530+
.on(DescribeChangeSetCommand)
531+
.resolves({
532+
Status: 'FAILED',
533+
StatusReason: 'No updates are to be performed.',
534+
Changes: [],
535+
});
536+
537+
// WHEN
538+
const result = await runBootstrap({ environments: ['aws://123456789012/us-east-1'] });
539+
540+
// THEN
541+
expectValidBootstrapResult(result);
542+
expect(result.environments.length).toBe(1);
543+
expect(result.environments[0].status).toBe('no-op');
544+
expect(result.environments[0].environment).toStrictEqual(EnvironmentUtils.make('123456789012', 'us-east-1'));
545+
expect(result.environments[0].duration).toBeGreaterThan(0);
546+
});
547+
548+
test('returns correct BootstrapResult for failure', async () => {
549+
// GIVEN
550+
const error = new Error('Access Denied');
551+
error.name = 'AccessDeniedException';
552+
mockCloudFormationClient
553+
.on(DescribeStacksCommand)
554+
.rejects(error);
555+
556+
// WHEN/THEN
557+
await expect(runBootstrap({ environments: ['aws://123456789012/us-east-1'] }))
558+
.rejects.toThrow('Access Denied');
503559
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
504560
level: 'error',
505561
message: expect.stringContaining('❌'),
506562
}));
507563
});
508564

509-
test('handles permission errors', async () => {
565+
test('handles generic bootstrap errors', async () => {
510566
// GIVEN
511-
const permissionError = new Error('Access Denied');
512-
permissionError.name = 'AccessDeniedException';
513-
mockCloudFormationClient.onAnyCommand().rejects(permissionError);
514-
515-
// WHEN
516-
await expect(runBootstrap()).rejects.toThrow('Access Denied');
567+
const error = new Error('Bootstrap failed');
568+
mockCloudFormationClient
569+
.on(DescribeStacksCommand)
570+
.rejects(error);
517571

518-
// THEN
572+
// WHEN/THEN
573+
await expect(runBootstrap({ environments: ['aws://123456789012/us-east-1'] }))
574+
.rejects.toThrow('Bootstrap failed');
519575
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
520576
level: 'error',
521577
message: expect.stringContaining('❌'),
522578
}));
579+
});
580+
581+
test('handles permission errors', async () => {
582+
// GIVEN
583+
const error = new Error('Access Denied');
584+
error.name = 'AccessDeniedException';
585+
mockCloudFormationClient
586+
.on(DescribeStacksCommand)
587+
.rejects(error);
588+
589+
// WHEN/THEN
590+
await expect(runBootstrap({ environments: ['aws://123456789012/us-east-1'] }))
591+
.rejects.toThrow('Access Denied');
523592
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
524593
level: 'error',
525-
message: expect.stringContaining('Access Denied'),
594+
message: expect.stringContaining(''),
526595
}));
527596
});
528597
});

0 commit comments

Comments
 (0)