-
Notifications
You must be signed in to change notification settings - Fork 4.1k
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
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'; | ||
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'; | ||
|
@@ -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'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. where are we using |
||
|
||
// @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'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. where are we using |
||
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'; | ||
|
@@ -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'; | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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. | ||||||
*/ | ||||||
|
@@ -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). | ||||||
*/ | ||||||
|
||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
public async bootstrap(cx: ICloudAssemblySource, options: BootstrapEnvironmentOptions = {}): Promise<void> { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||
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, | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we need to typecast this to |
||||||
message: `\n ❌ ${chalk.bold(environment.name)} failed: ${formatErrorMessage(e)}`, | ||||||
}); | ||||||
throw e; | ||||||
} | ||||||
}))); | ||||||
} | ||||||
|
||||||
/** | ||||||
* Synth Action | ||||||
*/ | ||||||
|
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'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
}); | ||
}); |
There was a problem hiding this comment.
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
?