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

fix(cli): build assets before deploying any stacks #21513

Merged
merged 17 commits into from
Aug 19, 2022
Merged
Show file tree
Hide file tree
Changes from 7 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
54 changes: 50 additions & 4 deletions packages/aws-cdk/lib/api/cloudformation-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ export interface DeployStackOptions {
* @default - Use the stored template
*/
readonly overrideTemplate?: any;

/**
* Disable asset publishing
*
* @default false
*/
readonly disableAssetPublishing?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

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

Thinking out loud here, would this be easier to understand if it were this?

Suggested change
/**
* Disable asset publishing
*
* @default false
*/
readonly disableAssetPublishing?: boolean;
/**
* Whether to publish Assets as part of a Stack deployment
*
* @default true
*/
readonly publishAssets?: boolean;

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes please! Negative arguments break my brains.

}

export interface DestroyStackOptions {
Expand Down Expand Up @@ -338,9 +345,14 @@ export class CloudFormationDeployments {

const toolkitInfo = await ToolkitInfo.lookup(resolvedEnvironment, stackSdk, options.toolkitStackName);

// Publish any assets before doing the actual deploy (do not publish any assets on import operation)
if (options.resourcesToImport === undefined) {
await this.publishStackAssets(options.stack, toolkitInfo);
const disableAssetPublishing = options.disableAssetPublishing ?? false;
const isImporting = options.resourcesToImport !== undefined;
// Determine whether we should publish assets. We don't publish if assets
// are prepublished or during an import operation.
const shouldPublish = !disableAssetPublishing && !isImporting;

if (shouldPublish) {
await this._publishStackAssets(options.stack, toolkitInfo);
}
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 is to maintain deployStack()'s backwards compatibility, but I wonder whether publishing assets via deployStack() is even necessary. Given that cdk import is the only other place deployStack() is used and that we're not publishing assets there, I can't think of any good reasons to keep the old behaviour. But, I wanted to discuss this first before making that change.

Copy link
Contributor

Choose a reason for hiding this comment

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

CloudFormationDeployments is not exposed in v2 (though it was in v1). See #18211

I'd want someone else to confirm this, but from what I can tell it's up for changes.


// Do a verification of the bootstrap stack version
Expand Down Expand Up @@ -451,10 +463,20 @@ export class CloudFormationDeployments {
};
}

/**
* Publish a stack's assets.
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
* Publish a stack's assets.
* Publish a stack's assets

*/
public async publishStackAssets(options: PublishStackAssetsOptions) {
const { stackSdk, resolvedEnvironment } = await this.prepareSdkFor(options.stack, options.roleArn);
const toolkitInfo = await ToolkitInfo.lookup(resolvedEnvironment, stackSdk, options.toolkitStackName);

await this._publishStackAssets(options.stack, toolkitInfo);
}

/**
* Publish all asset manifests that are referenced by the given stack
*/
private async publishStackAssets(stack: cxapi.CloudFormationStackArtifact, toolkitInfo: ToolkitInfo) {
private async _publishStackAssets(stack: cxapi.CloudFormationStackArtifact, toolkitInfo: ToolkitInfo) {
const stackEnv = await this.sdkProvider.resolveEnvironment(stack.environment);
const assetArtifacts = stack.dependencies.filter(cxapi.AssetManifestArtifact.isAssetManifestArtifact);

Expand Down Expand Up @@ -488,3 +510,27 @@ export class CloudFormationDeployments {
}
}
}

/**
* Options for publishing stack assets.
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
* Options for publishing stack assets.
* Options for publishing stack assets

*/
export interface PublishStackAssetsOptions {
/**
* Stack to publish assets for
*/
readonly stack: cxapi.CloudFormationStackArtifact;

/**
* Execution role for publishing assets
*
* @default - Current role
*/
readonly roleArn?: string;

/**
* Name of the toolkit stack, if not the default name
*
* @default 'CDKToolkit'
*/
readonly toolkitStackName?: string;
}
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
}
}

24 changes: 24 additions & 0 deletions packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { deployStacks } from './deploy';
import { printSecurityDiff, printStackDiff, RequireApproval } from './diff';
import { ResourceImporter } from './import';
import { data, debug, error, highlight, print, success, warning } from './logging';
import { publishAllStackAssets } from './publish';
import { deserializeStructure, serializeStructure } from './serialize';
import { Configuration, PROJECT_CONFIG } from './settings';
import { numberFromBool, partition } from './util';
Expand Down Expand Up @@ -175,6 +176,28 @@ export class CdkToolkit {
warning('⚠️ The --concurrency flag only supports --progress "events". Switching to "events".');
}

const publishStackAssets = async (stack: cxapi.CloudFormationStackArtifact) => {
// Check whether the stack has an asset manifest before trying to build and publish.
if (!stack.dependencies.some(cxapi.AssetManifestArtifact.isAssetManifestArtifact)) {
return;
}

print('%s: building and publishing assets...\n', chalk.bold(stack.displayName));
await this.props.cloudFormation.publishStackAssets({
stack,
roleArn: options.roleArn,
toolkitStackName: options.toolkitStackName,
});
print('\n%s: assets published\n', chalk.bold(stack.displayName));
};

try {
await publishAllStackAssets(stacks, { concurrency, publishStackAssets });
} catch (e) {
error('\n ❌ Publishing assets failed: %s', e);
throw e;
}

const deployStack = async (stack: cxapi.CloudFormationStackArtifact) => {
if (stackCollection.stackCount !== 1) { highlight(stack.displayName); }
if (!stack.environment) {
Expand Down Expand Up @@ -250,6 +273,7 @@ export class CdkToolkit {
rollback: options.rollback,
hotswap: options.hotswap,
extraUserAgent: options.extraUserAgent,
disableAssetPublishing: true,
});

const message = result.noOp
Expand Down
28 changes: 28 additions & 0 deletions packages/aws-cdk/lib/publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as cxapi from '@aws-cdk/cx-api';
import PQueue from 'p-queue';

type Options = {
concurrency: number;
publishStackAssets: (stack: cxapi.CloudFormationStackArtifact) => Promise<void>;
};

export const publishAllStackAssets = async (stacks: cxapi.CloudFormationStackArtifact[], options: Options): Promise<void> => {
const { concurrency, publishStackAssets } = options;

const queue = new PQueue({ concurrency });
const publishingErrors: Error[] = [];

for (const stack of stacks) {
queue.add(async () => {
await publishStackAssets(stack);
}).catch((err) => {
publishingErrors.push(err);
});
}

await queue.onIdle();

if (publishingErrors.length) {
throw Error(`Publishing Assets Failed: ${publishingErrors.join(', ')}`);
}
};
137 changes: 136 additions & 1 deletion packages/aws-cdk/test/api/cloudformation-deployments.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
jest.mock('../../lib/api/deploy-stack');
jest.mock('../../lib/util/asset-publishing');

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
import * as cxapi from '@aws-cdk/cx-api';
import { CloudFormation } from 'aws-sdk';
import { CloudFormationDeployments } from '../../lib/api/cloudformation-deployments';
import { deployStack } from '../../lib/api/deploy-stack';
import { ToolkitInfo } from '../../lib/api/toolkit-info';
import { EcrRepositoryInfo, ToolkitInfo } from '../../lib/api/toolkit-info';
import { CloudFormationStack } from '../../lib/api/util/cloudformation';
import { publishAssets } from '../../lib/util/asset-publishing';
import { testStack } from '../util';
import { mockBootstrapStack, MockSdkProvider } from '../util/mock-sdk';
import { FakeCloudformationStack } from './fake-cloudformation-stack';
Expand Down Expand Up @@ -55,6 +62,32 @@ function mockSuccessfulBootstrapStackLookup(props?: Record<string, any>) {
mockToolkitInfoLookup.mockResolvedValue(ToolkitInfo.fromStack(fakeStack, sdkProvider.sdk));
}

test('deployStack publishing asset', async () => {
const stack = testStackWithAssetManifest();

// WHEN
await deployments.deployStack({
stack,
});

// THEN
expect(publishAssets).toHaveBeenCalled();
});

test('deployStack with asset publishing disabled', async () => {
// GIVEN
const stack = testStackWithAssetManifest();

// WHEN
await deployments.deployStack({
stack,
disableAssetPublishing: true,
});

// THEN
expect(publishAssets).not.toHaveBeenCalled();
});

test('passes through hotswap=true to deployStack()', async () => {
// WHEN
await deployments.deployStack({
Expand Down Expand Up @@ -843,6 +876,32 @@ test('readCurrentTemplateWithNestedStacks() succesfully ignores stacks without m
});
});

test('publishing assets', async () => {
// GIVEN
const stack = testStackWithAssetManifest();

// WHEN
await deployments.publishStackAssets({
stack,
});

// THEN
const expectedAssetManifest = expect.objectContaining({
directory: stack.assembly.directory,
manifest: expect.objectContaining({
files: expect.objectContaining({
fake: expect.anything(),
}),
}),
});
const expectedEnvironment = expect.objectContaining({
account: 'account',
name: 'aws://account/region',
region: 'region',
});
expect(publishAssets).toBeCalledWith(expectedAssetManifest, sdkProvider, expectedEnvironment);
});

function pushStackResourceSummaries(stackName: string, ...items: CloudFormation.StackResourceSummary[]) {
if (!currentCfnStackResources[stackName]) {
currentCfnStackResources[stackName] = [];
Expand All @@ -860,3 +919,79 @@ function stackSummaryOf(logicalId: string, resourceType: string, physicalResourc
LastUpdatedTimestamp: new Date(),
};
}

function testStackWithAssetManifest() {
const toolkitInfo = new class extends ToolkitInfo {
public found: boolean = true;
public bucketUrl: string = 's3://fake/here';
public bucketName: string = 'fake';
public version: number = 1234;
public get bootstrapStack(): CloudFormationStack {
throw new Error('This should never happen');
};

constructor() {
super(sdkProvider.sdk);
}

public validateVersion(): Promise<void> {
return Promise.resolve();
}

public prepareEcrRepository(): Promise<EcrRepositoryInfo> {
return Promise.resolve({
repositoryUri: 'fake',
});
}
};

ToolkitInfo.lookup = mockToolkitInfoLookup = jest.fn().mockResolvedValue(toolkitInfo);

const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk.out.'));
fs.writeFileSync(path.join(outDir, 'assets.json'), JSON.stringify({
version: '15.0.0',
files: {
fake: {
source: {
path: 'fake.json',
packaging: 'file',
},
destinations: {
'current_account-current_region': {
bucketName: 'fake-bucket',
objectKey: 'fake.json',
assumeRoleArn: 'arn:fake',
},
},
},
},
dockerImages: {},
}));
fs.writeFileSync(path.join(outDir, 'template.json'), JSON.stringify({
Resources: {
No: { Type: 'Resource' },
},
}));

const builder = new cxapi.CloudAssemblyBuilder(outDir);

builder.addArtifact('assets', {
type: cxschema.ArtifactType.ASSET_MANIFEST,
properties: {
file: 'assets.json',
},
environment: 'aws://account/region',
});

builder.addArtifact('stack', {
type: cxschema.ArtifactType.AWS_CLOUDFORMATION_STACK,
properties: {
templateFile: 'template.json',
},
environment: 'aws://account/region',
dependencies: ['assets'],
});

const assembly = builder.buildAssembly();
return assembly.getStackArtifact('stack');
}
39 changes: 39 additions & 0 deletions packages/aws-cdk/test/publish.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as cxapi from '@aws-cdk/cx-api';
import { publishAllStackAssets } from '../lib/publish';

type Stack = cxapi.CloudFormationStackArtifact;

describe('publishAllStackAssets', () => {
const A = { id: 'A' };
const B = { id: 'B' };
const C = { id: 'C' };
const concurrency = 3;
const toPublish = [A, B, C] as unknown as Stack[];

const sleep = async (duration: number) => new Promise<void>((resolve) => setTimeout(() => resolve(), duration));

test('publish', async () => {
// GIVEN
const publishStackAssets = jest.fn(() => sleep(1));

// WHEN/THEN
await expect(publishAllStackAssets(toPublish, { concurrency, publishStackAssets }))
.resolves
.toBeUndefined();

expect(publishStackAssets).toBeCalledTimes(3);
expect(publishStackAssets).toBeCalledWith(A);
expect(publishStackAssets).toBeCalledWith(B);
expect(publishStackAssets).toBeCalledWith(C);
});

test('errors', async () => {
// GIVEN
const publishStackAssets = async () => { throw new Error('Message'); };

// WHEN/THEN
await expect(publishAllStackAssets(toPublish, { concurrency, publishStackAssets }))
.rejects
.toThrow('Publishing Assets Failed: Error: Message, Error: Message, Error: Message');
});
});