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

chore(toolkit): requireApproval option for deploy #32977

Merged
merged 11 commits into from
Jan 17, 2025
Merged
1 change: 1 addition & 0 deletions aws-cdk.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"name": "aws-custom-resource-sdk-adapter",
"rootPath": "packages/@aws-cdk/aws-custom-resource-sdk-adapter"
},
{ "name": "toolkit", "rootPath": "packages/@aws-cdk/toolkit" },
{ "name": "user-input-gen", "rootPath": "tools/@aws-cdk/user-input-gen" }
]
},
Expand Down
3 changes: 3 additions & 0 deletions packages/@aws-cdk/toolkit/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ build-info.json
lib/**/*.wasm
lib/**/*.yaml

# Include test resources
!test/_fixtures/**/*.js

# Include config files
!.eslintrc.js
!jest.config.js
10 changes: 6 additions & 4 deletions packages/@aws-cdk/toolkit/lib/actions/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,10 @@ export class StackParameters {
export interface BaseDeployOptions {
/**
* Criteria for selecting stacks to deploy
*
* @default - all stacks
*/
readonly stacks: StackSelector;
readonly stacks?: StackSelector;

/**
* @deprecated set on toolkit
Expand Down Expand Up @@ -148,9 +150,9 @@ export interface BaseDeployOptions {
* A 'hotswap' deployment will attempt to short-circuit CloudFormation
* and update the affected resources like Lambda functions directly.
*
* @default - `HotswapMode.FALL_BACK` for regular deployments, `HotswapMode.HOTSWAP_ONLY` for 'watch' deployments
* @default - no hotswap
mrgrain marked this conversation as resolved.
Show resolved Hide resolved
*/
readonly hotswap: HotswapMode;
readonly hotswap?: HotswapMode;

/**
* Rollback failed deployments
Expand Down Expand Up @@ -182,7 +184,7 @@ export interface DeployOptions extends BaseDeployOptions {
/**
* What kind of security changes require approval
*
* @default RequireApproval.Broadening
* @default RequireApproval.NEVER
kaizencc marked this conversation as resolved.
Show resolved Hide resolved
*/
readonly requireApproval?: RequireApproval;

Expand Down
2 changes: 1 addition & 1 deletion packages/@aws-cdk/toolkit/lib/actions/synth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export interface SynthOptions {
/**
* Select the stacks
*/
readonly stacks: StackSelector;
readonly stacks?: StackSelector;

/**
* After synthesis, validate stacks with the "validateOnSynth" attribute set (can also be controlled with CDK_VALIDATION)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './context-aware-source';
export * from './exec';
export * from './prepare-source';
export * from './source-builder';
export * from './stack-selectors';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { StackSelectionStrategy, StackSelector } from '../stack-selector';

export const ALL_STACKS: StackSelector = {
strategy: StackSelectionStrategy.ALL_STACKS,
};
60 changes: 38 additions & 22 deletions packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ import { patternsArrayForWatch, WatchOptions } from '../actions/watch';
import { 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 } from '../api/aws-cdk';
import { CachedCloudAssemblySource, IdentityCloudAssemblySource, StackAssembly, ICloudAssemblySource, StackSelectionStrategy } from '../api/cloud-assembly';
import { CloudAssemblySourceBuilder } from '../api/cloud-assembly/private/source-builder';
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, data, error, highlight, info, success, warn, ActionAwareIoHost, debug } from '../api/io/private';
import { asSdkLogger, withAction, Timer, confirm, error, highlight, info, success, warn, ActionAwareIoHost, debug } from '../api/io/private';

/**
* The current action being performed by the CLI. 'none' represents the absence of an action.
Expand All @@ -38,7 +38,7 @@ export interface ToolkitOptions {
/**
* The IoHost implementation, handling the inline interactions between the Toolkit and an integration.
*/
// ioHost: IIoHost;
ioHost?: IIoHost;

/**
* Configuration options for the SDK.
Expand Down Expand Up @@ -78,10 +78,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
public constructor(private readonly props: ToolkitOptions = {}) {
super();

// @todo open ioHost up
this.ioHost = CliIoHost.getIoHost();
// this.ioHost = options.ioHost;

this.ioHost = props.ioHost ?? CliIoHost.getIoHost();
this.toolkitStackName = props.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME;
}

Expand Down Expand Up @@ -121,26 +118,38 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
/**
* Synth Action
*/
public async synth(cx: ICloudAssemblySource, options: SynthOptions): Promise<ICloudAssemblySource> {
public async synth(cx: ICloudAssemblySource, options: SynthOptions = {}): Promise<ICloudAssemblySource> {
const ioHost = withAction(this.ioHost, 'synth');
const assembly = await this.assemblyFromSource(cx);
const stacks = assembly.selectStacksV2(options.stacks);
const stacks = assembly.selectStacksV2(options.stacks ?? ALL_STACKS);
const autoValidateStacks = options.validateStacks ? [assembly.selectStacksForValidation()] : [];
await this.validateStacksMetadata(stacks.concat(...autoValidateStacks), ioHost);

// if we have a single stack, print it to STDOUT
const message = `Successfully synthesized to ${chalk.blue(path.resolve(stacks.assembly.directory))}`;
const assemblyData = {
assemblyDirectory: stacks.assembly.directory,
stacksCount: stacks.stackCount,
stackIds: stacks.hierarchicalIds,
};

if (stacks.stackCount === 1) {
const template = stacks.firstStack?.template;
const firstStack = stacks.firstStack!;
const template = firstStack.template;
const obscuredTemplate = obscureTemplate(template);
await ioHost.notify(info('', 'CDK_TOOLKIT_I0001', {
raw: template,
json: serializeStructure(obscuredTemplate, true),
yaml: serializeStructure(obscuredTemplate, false),
},
));
await ioHost.notify(info(message, 'CDK_TOOLKIT_I0001', {
...assemblyData,
stack: {
stackName: firstStack.stackName,
hierarchicalId: firstStack.hierarchicalId,
template,
stringifiedJson: serializeStructure(obscuredTemplate, true),
stringifiedYaml: serializeStructure(obscuredTemplate, false),
},
}));
} else {
// not outputting template to stdout, let's explain things to the user a little bit...
await ioHost.notify(success(`Successfully synthesized to ${chalk.blue(path.resolve(stacks.assembly.directory))}`));
await ioHost.notify(success(message, 'CDK_TOOLKIT_I0002', assemblyData));
await ioHost.notify(info(`Supply a stack id (${stacks.stackArtifacts.map((s) => chalk.green(s.hierarchicalId)).join(', ')}) to display its template.`));
}

Expand Down Expand Up @@ -174,11 +183,11 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
/**
* Deploys the selected stacks into an AWS account
*/
public async deploy(cx: ICloudAssemblySource, options: DeployOptions): Promise<void> {
public async deploy(cx: ICloudAssemblySource, options: DeployOptions = {}): Promise<void> {
const ioHost = withAction(this.ioHost, 'deploy');
const timer = Timer.start();
const assembly = await this.assemblyFromSource(cx);
const stackCollection = assembly.selectStacksV2(options.stacks);
const stackCollection = assembly.selectStacksV2(options.stacks ?? ALL_STACKS);
await this.validateStacksMetadata(stackCollection, ioHost);

const synthTime = timer.end();
Expand Down Expand Up @@ -414,9 +423,16 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
await ioHost.notify(info(`${chalk.cyan(stack.id)}.${chalk.cyan(name)} = ${chalk.underline(chalk.cyan(value))}`));
}

await ioHost.notify(info('Stack ARN:'));

await ioHost.notify(data(deployResult.stackArn));
const obscuredTemplate = obscureTemplate(stack.template);
await ioHost.notify(info(`Stack ARN:${deployResult.stackArn}`, 'CDK_TOOLKIT_I0002', {
stack: {
stackName: stack.stackName,
hierarchicalId: stack.hierarchicalId,
template: stack.template,
stringifiedJson: serializeStructure(obscuredTemplate, true),
stringifiedYaml: serializeStructure(obscuredTemplate, false),
},
Copy link
Contributor Author

Choose a reason for hiding this comment

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

i am not quite sure exactly what information we want to send via this channel but i mostly copied what was done on synth

}));
} catch (e: any) {
// It has to be exactly this string because an integration test tests for
// "bold(stackname) failed: ResourceNotReady: <error>"
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/toolkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@types/node": "^18.18.14",
"aws-cdk": "0.0.0",
"aws-cdk-lib": "0.0.0",
"aws-sdk-client-mock": "^4.0.1",
"esbuild": "^0.24.0",
"jest": "^29.7.0",
"typescript": "~5.6.3"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as cdk from 'aws-cdk-lib/core';

const app = new cdk.App();
new cdk.Stack(app, 'AppStack1');
new cdk.Stack(app, 'AppStack2');

app.synth();
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"app": "node app.js"
}
7 changes: 7 additions & 0 deletions packages/@aws-cdk/toolkit/test/_helpers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as path from 'path';

export * from './test-io-host';

export function fixture(name: string, app = 'app.js'): string {
return path.normalize(path.join(__dirname, '..', '_fixtures', name, app));
}
13 changes: 13 additions & 0 deletions packages/@aws-cdk/toolkit/test/_helpers/test-io-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { IIoHost, IoMessage, IoRequest } from '../../lib';

/**
* A test implementation of IIoHost that does nothing but can by spied on.
*/
export class TestIoHost implements IIoHost {
public async notify<T>(_msg: IoMessage<T>): Promise<void> {
// do nothing
}
public async requestResponse<T, U>(msg: IoRequest<T, U>): Promise<U> {
return msg.defaultResponse;
}
}
122 changes: 122 additions & 0 deletions packages/@aws-cdk/toolkit/test/actions/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as core from 'aws-cdk-lib/core';
import { Toolkit } from '../../lib/toolkit';
import { fixture, TestIoHost } from '../_helpers';
import { CloudFormationClient, DescribeChangeSetCommand, DescribeStacksCommand, StackStatus } from '@aws-sdk/client-cloudformation';
import { mockClient } from 'aws-sdk-client-mock';
import { StackSelectionStrategy } from '../../lib';

const ioHost = new TestIoHost();
const notifySpy = jest.spyOn(ioHost, 'notify');
const requestResponseSpy = jest.spyOn(ioHost, 'requestResponse');
const cdk = new Toolkit({ ioHost });
const mockCloudFormationClient = mockClient(CloudFormationClient);

const cxFromBuilder = async () => {
return cdk.fromAssemblyBuilder(async () => {
const app = new core.App();
new core.Stack(app, 'Stack1');
new core.Stack(app, 'Stack2');

// @todo fix api
return app.synth() as any;
});
};

const cxFromApp = async (name: string) => {
return cdk.fromCdkApp(`node ${fixture(name)}`);
};

beforeEach(() => {
requestResponseSpy.mockClear();
notifySpy.mockClear();
mockCloudFormationClient.reset();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

all this mockCloudFormation stuff is copied over. there's a whole bunch more to mock as we add more unit tests...

mockCloudFormationClient.onAnyCommand().resolves({});
mockCloudFormationClient.on(DescribeChangeSetCommand).resolves({
Status: StackStatus.CREATE_COMPLETE,
Changes: [],
});
mockCloudFormationClient
.on(DescribeStacksCommand)
// First call, no stacks exis
.resolvesOnce({
Stacks: [],
})
// Second call, stack has been created
.resolves({
Stacks: [
{
StackStatus: StackStatus.CREATE_COMPLETE,
StackStatusReason: 'It is magic',
EnableTerminationProtection: false,
StackName: 'MagicalStack',
CreationTime: new Date(),
},
],
});
});

describe('deploy', () => {
test('deploy from builder', async () => {
// WHEN
const cx = await cxFromBuilder();
await cdk.deploy(cx);

// THEN
expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({
action: 'deploy',
level: 'info',
message: expect.stringContaining('Deployment time:'),
}));
});

test('deploy from app', async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

again this one doesn't work yet

Copy link
Contributor

Choose a reason for hiding this comment

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

We probably don't need to test all the different assembly types on all commands. Once it has synth'd it should be.

// WHEN
await cdk.deploy(await cxFromApp('two-empty-stacks'));

// THEN
expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({
action: 'deploy',
level: 'info',
message: expect.stringContaining('Deployment time:'),
}));
});

test('deploy no resources results in warning', async () => {
// WHEN
const cx = await cxFromBuilder();
await cdk.deploy(cx, {
stacks: {
strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE,
patterns: ['Stack1'],
},
});

// THEN
expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({
action: 'deploy',
level: 'info',
message: expect.stringContaining('Stack ARN:'),
data: expect.objectContaining({
stack: expect.objectContaining({
hierarchicalId: 'Stack1',
stackName: 'Stack1',
stringifiedJson: expect.not.stringContaining('CheckBootstrapVersion'),
}),
}),
}));

expect(notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({
action: 'deploy',
level: 'info',
message: expect.stringContaining('Stack ARN:'),
data: expect.objectContaining({
stack: expect.objectContaining({
hierarchicalId: 'Stack2',
stackName: 'Stack2',
stringifiedJson: expect.not.stringContaining('CheckBootstrapVersion'),
}),
}),
}));
});
});
Loading
Loading