diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/publish/cdk-publish-requires-unstable-flag.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/publish/cdk-publish-requires-unstable-flag.integtest.ts new file mode 100644 index 000000000..e9fa0be7c --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/publish/cdk-publish-requires-unstable-flag.integtest.ts @@ -0,0 +1,19 @@ +import { DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; +import { integTest, withDefaultFixture } from '../../../lib'; + +jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime + +integTest( + 'publish command', + withDefaultFixture(async (fixture) => { + const stackName = 'lambda'; + const fullStackName = fixture.fullStackName(stackName); + + const output = await fixture.cdk(['publish', fullStackName, '--unstable=publish']); + expect(output).toMatch('Assets published successfully'); + + // assert the stack wan not deployed + await expect(fixture.aws.cloudFormation.send(new DescribeStacksCommand({ StackName: fullStackName }))) + .rejects.toThrow(/does not exist/); + }), +); diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts index b82900894..f8918d9d8 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts @@ -4,6 +4,7 @@ export * from './destroy'; export * from './diff'; export * from './drift'; export * from './list'; +export * from './publish'; export * from './refactor'; export * from './rollback'; export * from './synth'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/publish/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/publish/index.ts new file mode 100644 index 000000000..31327c862 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/publish/index.ts @@ -0,0 +1,39 @@ +import type { IManifestEntry } from '@aws-cdk/cdk-assets-lib'; +import type { StackSelector } from '../../api/cloud-assembly'; + +export interface PublishOptions { + /** + * Select stacks to publish assets for + * + * @default - All stacks + */ + readonly stacks?: StackSelector; + + /** + * Always publish assets, even if they are already published + * + * @default false + */ + readonly force?: boolean; + + /** + * Maximum number of simultaneous asset publishing operations + * + * @default 1 + */ + readonly concurrency?: number; + + /** + * Role to pass to CloudFormation for asset operations + * + * @default - Current role + */ + readonly roleArn?: string; +} + +export interface PublishResult { + /** + * List of assets that were published + */ + readonly publishedAssets: IManifestEntry[]; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts index a5abd05b2..35b8c2aee 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts @@ -10,6 +10,7 @@ export type ToolkitAction = | 'deploy' | 'drift' | 'rollback' +| 'publish' | 'watch' | 'destroy' | 'doctor' diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts index f2428cc42..1b81263f2 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts @@ -10,6 +10,7 @@ export * from './stack-activity'; export * from './synth'; export * from './types'; export * from './progress'; +export * from './publish'; export * from './refactor'; export * from './watch'; export * from './stack-details'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/publish.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/publish.ts new file mode 100644 index 000000000..0efb3e3ad --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/publish.ts @@ -0,0 +1,8 @@ +import type { PublishResult } from '../actions'; + +export interface PublishResultPayload { + /** + * The publish result + */ + readonly result: PublishResult; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 16c0bc665..4c6f6e588 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -46,6 +46,7 @@ import type { DiffOptions } from '../actions/diff'; import { appendObject, prepareDiff } from '../actions/diff/private'; import type { DriftOptions, DriftResult } from '../actions/drift'; import { type ListOptions } from '../actions/list'; +import type { PublishOptions, PublishResult } from '../actions/publish'; import type { RefactorOptions } from '../actions/refactor'; import { type RollbackOptions } from '../actions/rollback'; import { type SynthOptions } from '../actions/synth'; @@ -488,6 +489,75 @@ export class Toolkit extends CloudAssemblySourceBuilder { return allDriftResults; } + /** + * Publish Action + * + * Publishes assets for the selected stacks without deploying + */ + public async publish(cx: ICloudAssemblySource, options: PublishOptions = {}): Promise { + const ioHelper = asIoHelper(this.ioHost, 'publish'); + const selectStacks = stacksOpt(options); + await using assembly = await synthAndMeasure(ioHelper, cx, selectStacks); + + const stackCollection = await assembly.selectStacksV2(selectStacks); + await this.validateStacksMetadata(stackCollection, ioHelper); + + if (stackCollection.stackCount === 0) { + await ioHelper.notify(IO.CDK_TOOLKIT_E5001.msg('No stacks selected')); + return { + publishedAssets: [], + }; + } + + const deployments = await this.deploymentsForAction('publish'); + + const stacks = stackCollection.stackArtifacts; + const stacksAndTheirAssetManifests = stacks.flatMap((stack) => [ + stack, + ...stack.dependencies.filter(x => cxapi.AssetManifestArtifact.isAssetManifestArtifact(x)), + ]); + + const workGraph = new WorkGraphBuilder( + ioHelper, + true, // prebuild all assets + ).build(stacksAndTheirAssetManifests); + + if (!options.force) { + await removePublishedAssetsFromWorkGraph(workGraph, deployments, options); + } + + const assetNodes = Object.values(workGraph.nodes) + .filter((n): n is AssetPublishNode => n.type === 'asset-publish'); + + if (assetNodes.length === 0) { + await ioHelper.defaults.info(chalk.green('\n✨ All assets are already published\n')); + return { + publishedAssets: [], + }; + } + + const graphConcurrency: Concurrency = { + 'stack': 1, + 'asset-build': 1, + 'asset-publish': options.concurrency ?? 1, + }; + + await workGraph.doParallel(graphConcurrency, { + deployStack: async () => { + // No-op: we're only publishing assets, not deploying + }, + buildAsset: this.createBuildAssetFunction(ioHelper, deployments, options.roleArn), + publishAsset: this.createPublishAssetFunction(ioHelper, deployments, options.roleArn, options.force), + }); + + await ioHelper.defaults.info(chalk.green('\n✨ Assets published successfully\n')); + const publishedAssets = assetNodes.map(n => n.asset); + + return { + publishedAssets, + }; + } + /** * List Action * @@ -554,36 +624,6 @@ export class Toolkit extends CloudAssemblySourceBuilder { const stackOutputs: { [key: string]: any } = {}; const outputsFile = options.outputsFile; - const buildAsset = async (assetNode: AssetBuildNode) => { - const buildAssetSpan = await ioHelper.span(SPAN.BUILD_ASSET).begin({ - asset: assetNode.asset, - }); - await deployments.buildSingleAsset( - assetNode.assetManifestArtifact, - assetNode.assetManifest, - assetNode.asset, - { - stack: assetNode.parentStack, - roleArn: options.roleArn, - stackName: assetNode.parentStack.stackName, - }, - ); - await buildAssetSpan.end(); - }; - - const publishAsset = async (assetNode: AssetPublishNode) => { - const publishAssetSpan = await ioHelper.span(SPAN.PUBLISH_ASSET).begin({ - asset: assetNode.asset, - }); - await deployments.publishSingleAsset(assetNode.assetManifest, assetNode.asset, { - stack: assetNode.parentStack, - roleArn: options.roleArn, - stackName: assetNode.parentStack.stackName, - forcePublish: options.forceAssetPublishing, - }); - await publishAssetSpan.end(); - }; - const deployStack = async (stackNode: StackNode) => { const stack = stackNode.stack; if (stackCollection.stackCount !== 1) { @@ -837,8 +877,8 @@ export class Toolkit extends CloudAssemblySourceBuilder { await workGraph.doParallel(graphConcurrency, { deployStack, - buildAsset, - publishAsset, + buildAsset: this.createBuildAssetFunction(ioHelper, deployments, options.roleArn), + publishAsset: this.createPublishAssetFunction(ioHelper, deployments, options.roleArn, options.forceAssetPublishing), }); return ret; @@ -1407,6 +1447,55 @@ export class Toolkit extends CloudAssemblySourceBuilder { throw new ToolkitError(`Unstable feature '${requestedFeature}' is not enabled. Please enable it under 'unstableFeatures'`); } } + + /** + * Create a buildAsset function for use in WorkGraph + */ + private createBuildAssetFunction( + ioHelper: IoHelper, + deployments: Deployments, + roleArn: string | undefined, + ) { + return async (assetNode: AssetBuildNode) => { + const buildAssetSpan = await ioHelper.span(SPAN.BUILD_ASSET).begin({ + asset: assetNode.asset, + }); + await deployments.buildSingleAsset( + assetNode.assetManifestArtifact, + assetNode.assetManifest, + assetNode.asset, + { + stack: assetNode.parentStack, + roleArn, + stackName: assetNode.parentStack.stackName, + }, + ); + await buildAssetSpan.end(); + }; + } + + /** + * Create a publishAsset function for use in WorkGraph + */ + private createPublishAssetFunction( + ioHelper: IoHelper, + deployments: Deployments, + roleArn: string | undefined, + forcePublish?: boolean, + ) { + return async (assetNode: AssetPublishNode) => { + const publishAssetSpan = await ioHelper.span(SPAN.PUBLISH_ASSET).begin({ + asset: assetNode.asset, + }); + await deployments.publishSingleAsset(assetNode.assetManifest, assetNode.asset, { + stack: assetNode.parentStack, + roleArn, + stackName: assetNode.parentStack.stackName, + forcePublish, + }); + await publishAssetSpan.end(); + }; + } } /** diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/publish.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/publish.test.ts new file mode 100644 index 000000000..3f69f2868 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/actions/publish.test.ts @@ -0,0 +1,100 @@ +import { StackSelectionStrategy } from '../../lib/api/cloud-assembly'; +import * as deployments from '../../lib/api/deployments'; +import { Toolkit } from '../../lib/toolkit'; +import { builderFixture, TestIoHost } from '../_helpers'; + +let ioHost: TestIoHost; +let toolkit: Toolkit; + +beforeEach(() => { + jest.restoreAllMocks(); + ioHost = new TestIoHost('info', true); + toolkit = new Toolkit({ ioHost }); + + // Mock deployments methods for asset operations + jest.spyOn(deployments.Deployments.prototype, 'resolveEnvironment').mockResolvedValue({ + account: '11111111', + region: 'us-east-1', + name: 'aws://11111111/us-east-1', + }); + jest.spyOn(deployments.Deployments.prototype, 'isSingleAssetPublished').mockResolvedValue(false); + jest.spyOn(deployments.Deployments.prototype, 'buildSingleAsset').mockImplementation(); + jest.spyOn(deployments.Deployments.prototype, 'publishSingleAsset').mockImplementation(); +}); + +describe('publish', () => { + test('publishes assets for a single stack', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-asset'); + const result = await toolkit.publish(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + }); + + // THEN + expect(result.publishedAssets.length).toBeGreaterThan(0); + ioHost.expectMessage({ containing: 'Assets published successfully', level: 'info' }); + }); + + test('publishes assets for multiple stacks', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-different-stacks'); + const result = await toolkit.publish(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + }); + + // THEN + expect(result.publishedAssets.length).toBeGreaterThan(0); + ioHost.expectMessage({ containing: 'Assets published successfully', level: 'info' }); + }); + + test('returns result when no stacks are selected', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-asset'); + const result = await toolkit.publish(cx, { + stacks: { patterns: ['NonExistentStack'], strategy: StackSelectionStrategy.PATTERN_MATCH }, + }); + + // THEN + expect(result.publishedAssets.length).toBe(0); + ioHost.expectMessage({ containing: 'No stacks selected', level: 'error' }); + }); + + test('can invoke publish action without options', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-asset'); + const result = await toolkit.publish(cx); + + // THEN + expect(result.publishedAssets.length).toBeGreaterThan(0); + ioHost.expectMessage({ containing: 'Assets published successfully', level: 'info' }); + }); + + test('respects force option', async () => { + // WHEN - publish with force + const cx = await builderFixture(toolkit, 'stack-with-asset'); + const result = await toolkit.publish(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + force: true, + }); + + // THEN + expect(result.publishedAssets.length).toBeGreaterThan(0); + ioHost.expectMessage({ containing: 'Assets published successfully', level: 'info' }); + }); + + test('skips already published assets when force is false', async () => { + // Mock that asset is already published + jest.spyOn(deployments.Deployments.prototype, 'isSingleAssetPublished').mockResolvedValue(true); + + // WHEN - publish without force + const cx = await builderFixture(toolkit, 'stack-with-asset'); + const result = await toolkit.publish(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + force: false, + }); + + // THEN + expect(result.publishedAssets.length).toBe(0); + ioHost.expectMessage({ containing: 'All assets are already published', level: 'info' }); + }); +}); diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 62e54fab9..94436a4c4 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -19,6 +19,7 @@ The AWS CDK Toolkit provides the `cdk` command-line interface that can be used t | [`cdk synth`](#cdk-synth) | Synthesize a CDK app to CloudFormation template(s) | | [`cdk diff`](#cdk-diff) | Diff stacks against current state | | [`cdk deploy`](#cdk-deploy) | Deploy a stack into an AWS account | +| [`cdk publish`](#cdk-publish) | Publish assets for stack(s) without deploying (unstable) | | [`cdk rollback`](#cdk-rollback) | Roll back a failed deployment | | [`cdk import`](#cdk-import) | Import existing AWS resources into a CDK stack | | [`cdk migrate`](#cdk-migrate) | Migrate AWS resources, CloudFormation stacks, and CloudFormation templates to CDK | @@ -611,6 +612,31 @@ For technical implementation details (function calls, file locations), see [docs ![Deploy flowchart](./images/deploy-flowchart.png) +### `cdk publish` + +Publishes assets for the specified stack(s) without performing a deployment. + +> [!CAUTION] +> `cdk publish` is under development and therefore must be opted in via the +> `--unstable` flag: `cdk publish --unstable=publish`. `--unstable` indicates that the scope and +> API of feature might still change. Otherwise the feature is generally production +> ready and fully supported. + +Publishes assets (such as Docker images and file assets) for the specified stack(s) to their respective destinations (ECR repositories, S3 buckets) without performing a deployment. + +This is useful in CI/CD pipelines where you want to separate the build/publish phase from the deployment phase, allowing you to publish assets once and deploy to multiple environments using the already-published assets. + +```console +$ # Publish assets for a single stack +$ cdk publish MyStack --unstable=publish + +$ # Publish assets for all stacks +$ cdk publish --all --unstable=publish + +$ # Force re-publish even if assets already exist +$ cdk publish MyStack --unstable=publish --force +``` + ### `cdk rollback` If a deployment performed using `cdk deploy --no-rollback` fails, your diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 26706be3d..4d303041b 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -19,6 +19,7 @@ import { CloudWatchLogEventMonitor, DEFAULT_TOOLKIT_STACK_NAME, DiffFormatter, + ExpandStackSelection, findCloudWatchLogGroups, GarbageCollector, removeNonImportResources, @@ -790,6 +791,19 @@ export class CdkToolkit { } } + public async publish(options: PublishOptions): Promise { + await this.toolkit.publish(this.props.cloudExecutable, { + stacks: { + patterns: options.selector.patterns, + strategy: options.selector.patterns.length > 0 ? StackSelectionStrategy.PATTERN_MATCH : StackSelectionStrategy.ALL_STACKS, + expand: options.exclusively ? ExpandStackSelection.NONE : ExpandStackSelection.UPSTREAM, + }, + force: options.force, + concurrency: options.concurrency, + roleArn: options.roleArn, + }); + } + public async watch(options: WatchOptions) { const rootDir = path.dirname(path.resolve(PROJECT_CONFIG)); const ioHelper = asIoHelper(this.ioHost, 'watch'); @@ -1856,6 +1870,41 @@ export interface RollbackOptions { readonly validateBootstrapStackVersion?: boolean; } +export interface PublishOptions { + /** + * Criteria for selecting stacks + */ + readonly selector: StackSelector; + + /** + * Only select the given stack + * + * @default false + */ + readonly exclusively?: boolean; + + /** + * Always publish assets, even if they are already published + * + * @default false + */ + readonly force?: boolean; + + /** + * Maximum number of simultaneous asset publishing operations + * + * @default 1 + */ + readonly concurrency?: number; + + /** + * Role to pass to CloudFormation for deployment + * + * @default - Current role + */ + readonly roleArn?: string; +} + export interface ImportOptions extends CfnDeployOptions { /** * Build a physical resource mapping and write it to the given file, without performing the actual import operation diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index 51eb6c987..d0515abb0 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -244,6 +244,19 @@ export async function makeConfig(): Promise { }, }, }, + 'publish': { + description: 'Publish assets for the given stack(s) without deploying', + arg: { + name: 'STACKS', + variadic: true, + }, + options: { + all: { type: 'boolean', desc: 'Publish assets for all available stacks', default: false }, + exclusively: { type: 'boolean', alias: 'e', desc: 'Only publish assets for requested stacks, don\'t include dependencies' }, + force: { type: 'boolean', alias: 'f', desc: 'Always publish assets, even if they are already published', default: false }, + concurrency: { type: 'number', desc: 'Maximum number of simultaneous asset publishing operations (dependency permitting) to execute.', default: 1, requiresArg: true }, + }, + }, 'import': { description: 'Import existing resource(s) into the given STACK', arg: { diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index d854d5460..8ea2e9cca 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -601,6 +601,37 @@ } } }, + "publish": { + "description": "Publish assets for the given stack(s) without deploying", + "arg": { + "name": "STACKS", + "variadic": true + }, + "options": { + "all": { + "type": "boolean", + "desc": "Publish assets for all available stacks", + "default": false + }, + "exclusively": { + "type": "boolean", + "alias": "e", + "desc": "Only publish assets for requested stacks, don't include dependencies" + }, + "force": { + "type": "boolean", + "alias": "f", + "desc": "Always publish assets, even if they are already published", + "default": false + }, + "concurrency": { + "type": "number", + "desc": "Maximum number of simultaneous asset publishing operations (dependency permitting) to execute.", + "default": 1, + "requiresArg": true + } + } + }, "import": { "description": "Import existing resource(s) into the given STACK", "arg": { diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 95c0d267c..83fadc64a 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -428,6 +428,19 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { requiresArg: true, }), ) + .command('publish [STACKS..]', 'Publish assets for the given stack(s) without deploying', (yargs: Argv) => + yargs + .option('all', { + default: false, + type: 'boolean', + desc: 'Publish assets for all available stacks', + }) + .option('exclusively', { + default: undefined, + type: 'boolean', + alias: 'e', + desc: "Only publish assets for requested stacks, don't include dependencies", + }) + .option('force', { + default: false, + type: 'boolean', + alias: 'f', + desc: 'Always publish assets, even if they are already published', + }) + .option('concurrency', { + default: 1, + type: 'number', + desc: 'Maximum number of simultaneous asset publishing operations (dependency permitting) to execute.', + requiresArg: true, + }), + ) .command('import [STACK]', 'Import existing resource(s) into the given STACK', (yargs: Argv) => yargs .option('execute', { diff --git a/packages/aws-cdk/lib/cli/user-configuration.ts b/packages/aws-cdk/lib/cli/user-configuration.ts index 733f56a83..207afe2b0 100644 --- a/packages/aws-cdk/lib/cli/user-configuration.ts +++ b/packages/aws-cdk/lib/cli/user-configuration.ts @@ -28,6 +28,7 @@ export enum Command { GC = 'gc', FLAGS = 'flags', ROLLBACK = 'rollback', + PUBLISH = 'publish', IMPORT = 'import', ACKNOWLEDGE = 'acknowledge', ACK = 'ack', @@ -49,6 +50,7 @@ const BUNDLING_COMMANDS = [ Command.SYNTHESIZE, Command.WATCH, Command.IMPORT, + Command.PUBLISH, ]; export type Arguments = { diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 1cf678f6a..628aabdaa 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -60,6 +60,11 @@ export interface UserInput { */ readonly rollback?: RollbackOptions; + /** + * Publish assets for the given stack(s) without deploying + */ + readonly publish?: PublishOptions; + /** * Import existing resource(s) into the given STACK */ @@ -987,6 +992,50 @@ export interface RollbackOptions { readonly STACKS?: Array; } +/** + * Publish assets for the given stack(s) without deploying + * + * @struct + */ +export interface PublishOptions { + /** + * Publish assets for all available stacks + * + * @default - false + */ + readonly all?: boolean; + + /** + * Only publish assets for requested stacks, don't include dependencies + * + * aliases: e + * + * @default - undefined + */ + readonly exclusively?: boolean; + + /** + * Always publish assets, even if they are already published + * + * aliases: f + * + * @default - false + */ + readonly force?: boolean; + + /** + * Maximum number of simultaneous asset publishing operations (dependency permitting) to execute. + * + * @default - 1 + */ + readonly concurrency?: number; + + /** + * Positional argument for publish + */ + readonly STACKS?: Array; +} + /** * Import existing resource(s) into the given STACK * diff --git a/packages/aws-cdk/test/cli/cli-arguments.test.ts b/packages/aws-cdk/test/cli/cli-arguments.test.ts index 839f2719a..0edb668a2 100644 --- a/packages/aws-cdk/test/cli/cli-arguments.test.ts +++ b/packages/aws-cdk/test/cli/cli-arguments.test.ts @@ -156,6 +156,7 @@ describe('config', () => { metadata: expect.anything(), migrate: expect.anything(), rollback: expect.anything(), + publish: expect.anything(), synth: expect.anything(), watch: expect.anything(), notices: expect.anything(), diff --git a/packages/aws-cdk/test/cli/cli.test.ts b/packages/aws-cdk/test/cli/cli.test.ts index 003392c34..0c247a001 100644 --- a/packages/aws-cdk/test/cli/cli.test.ts +++ b/packages/aws-cdk/test/cli/cli.test.ts @@ -66,6 +66,20 @@ jest.mock('../../lib/cli/parse-command-line-arguments', () => ({ _: ['deploy'], parameters: [], }; + } else if (args.includes('publish')) { + result = { ...result, _: ['publish'] }; + + // Handle publish-specific flags + if (args.includes('--exclusively')) { + result = { ...result, exclusively: true }; + } + if (args.includes('--force')) { + result = { ...result, force: true }; + } + const concurrencyIndex = args.findIndex((arg: string) => arg === '--concurrency'); + if (concurrencyIndex !== -1 && args[concurrencyIndex + 1]) { + result = { ...result, concurrency: parseInt(args[concurrencyIndex + 1], 10) }; + } } else if (args.includes('flags')) { result = { ...result, _: ['flags'] }; } @@ -518,6 +532,80 @@ describe('gc command tests', () => { }); }); +describe('publish command tests', () => { + let originalCliIoHostInstance: any; + + beforeEach(() => { + jest.clearAllMocks(); + originalCliIoHostInstance = CliIoHost.instance; + }); + + afterEach(() => { + CliIoHost.instance = originalCliIoHostInstance; + }); + + test('throw error when unstable flag is not set', async () => { + const publishSpy = jest.spyOn(cdkToolkitModule.CdkToolkit.prototype, 'publish').mockResolvedValue(undefined); + + (ioHost as any).defaults = { warn: jest.fn(), debug: jest.fn(), result: jest.fn() }; + (ioHost as any).asIoHelper = () => ioHelper; + (ioHost as any).logLevel = 'info'; + jest.spyOn(CliIoHost, 'instance').mockReturnValue(ioHost as any); + + const mockConfig = { + loadConfigFiles: jest.fn().mockResolvedValue(undefined), + settings: { + get: jest.fn().mockImplementation((key: string[]) => { + if (key[0] === 'unstable') return []; // No unstable flags set + return []; + }), + }, + context: { + get: jest.fn().mockReturnValue([]), + }, + }; + + Configuration.fromArgsAndFiles = jest.fn().mockResolvedValue(mockConfig); + + await expect(exec(['publish'])).rejects.toThrow(/Unstable feature use.*publish.*unstable/); + expect(publishSpy).not.toHaveBeenCalled(); + }); + + test('should pass options to publish method', async () => { + const publishSpy = jest.spyOn(cdkToolkitModule.CdkToolkit.prototype, 'publish').mockResolvedValue(undefined); + + (ioHost as any).defaults = { warn: jest.fn(), debug: jest.fn(), result: jest.fn(), info: jest.fn() }; + (ioHost as any).asIoHelper = () => ioHelper; + (ioHost as any).logLevel = 'info'; + jest.spyOn(CliIoHost, 'instance').mockReturnValue(ioHost as any); + + const mockConfig = { + loadConfigFiles: jest.fn().mockResolvedValue(undefined), + settings: { + get: jest.fn().mockImplementation((key: string[]) => { + if (key[0] === 'unstable') return ['publish']; + return undefined; + }), + }, + context: { + get: jest.fn().mockReturnValue([]), + }, + }; + + Configuration.fromArgsAndFiles = jest.fn().mockResolvedValue(mockConfig); + + await exec(['publish', '--unstable=publish', '--exclusively', '--force', '--concurrency', '4']); + + expect(publishSpy).toHaveBeenCalledWith( + expect.objectContaining({ + exclusively: true, + force: true, + concurrency: 4, + }), + ); + }); +}); + describe('--yes', () => { test('when --yes option is provided, CliIoHost is using autoRespond', async () => { // GIVEN diff --git a/packages/aws-cdk/test/commands/publish.test.ts b/packages/aws-cdk/test/commands/publish.test.ts new file mode 100644 index 000000000..b857a1c83 --- /dev/null +++ b/packages/aws-cdk/test/commands/publish.test.ts @@ -0,0 +1,91 @@ +import { Deployments } from '../../lib/api/deployments'; +import { CdkToolkit } from '../../lib/cli/cdk-toolkit'; +import { CliIoHost } from '../../lib/cli/io-host'; +import { instanceMockFrom, MockCloudExecutable } from '../_helpers'; + +describe('publish', () => { + let cloudExecutable: MockCloudExecutable; + let cloudFormation: jest.Mocked; + let toolkit: CdkToolkit; + let ioHost = CliIoHost.instance(); + + beforeEach(async () => { + cloudExecutable = await MockCloudExecutable.create({ + stacks: [ + { + stackName: 'Stack1', + template: { + Resources: { + Bucket: { Type: 'AWS::S3::Bucket' }, + }, + }, + }, + ], + }, undefined, ioHost); + + cloudFormation = instanceMockFrom(Deployments); + + toolkit = new CdkToolkit({ + cloudExecutable, + deployments: cloudFormation, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + }); + + // Mock the toolkit.publish method from toolkit-lib + jest.spyOn((toolkit as any).toolkit, 'publish').mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('publishes with correct stack selector and force option', async () => { + // WHEN + await toolkit.publish({ + selector: { patterns: ['Stack1'] }, + force: true, + }); + + // THEN + expect((toolkit as any).toolkit.publish).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + stacks: expect.objectContaining({ + patterns: ['Stack1'], + }), + force: true, + }), + ); + }); + + test('publishes successfully', async () => { + // WHEN + await toolkit.publish({ + selector: { patterns: ['Stack1'] }, + }); + + // THEN + expect((toolkit as any).toolkit.publish).toHaveBeenCalled(); + }); + + test('passes all options correctly', async () => { + // WHEN + await toolkit.publish({ + selector: { patterns: ['Stack1'] }, + force: true, + concurrency: 5, + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + }); + + // THEN + expect((toolkit as any).toolkit.publish).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + force: true, + concurrency: 5, + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + }), + ); + }); +});