diff --git a/packages/@aws-cdk/pipelines/.npmignore b/packages/@aws-cdk/pipelines/.npmignore index 8b1d5e48f3c78..0f236edd1e0c1 100644 --- a/packages/@aws-cdk/pipelines/.npmignore +++ b/packages/@aws-cdk/pipelines/.npmignore @@ -26,3 +26,5 @@ jest.config.js junit.xml package/node_modules test/ + +!*.lit.ts \ No newline at end of file diff --git a/packages/@aws-cdk/pipelines/lib/blueprint/index.ts b/packages/@aws-cdk/pipelines/lib/blueprint/index.ts index 431d18aa5b162..b5ab8ac33196e 100644 --- a/packages/@aws-cdk/pipelines/lib/blueprint/index.ts +++ b/packages/@aws-cdk/pipelines/lib/blueprint/index.ts @@ -1,6 +1,6 @@ export * from './asset-type'; export * from './blueprint'; -export * from './blueprint-queries'; +export * from '../helpers-internal/blueprint-queries'; export * from './file-set'; export * from './script-step'; export * from './stack-deployment'; diff --git a/packages/@aws-cdk/pipelines/lib/blueprint/stack-deployment.ts b/packages/@aws-cdk/pipelines/lib/blueprint/stack-deployment.ts index 777cf7ccc20fa..772a02dd1486a 100644 --- a/packages/@aws-cdk/pipelines/lib/blueprint/stack-deployment.ts +++ b/packages/@aws-cdk/pipelines/lib/blueprint/stack-deployment.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import { parse as parseUrl } from 'url'; import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; import { AssetManifestReader, DockerImageManifestEntry, FileManifestEntry } from '../private/asset-manifest'; @@ -17,7 +18,8 @@ export interface StackDeploymentProps { readonly tags?: Record; readonly customCloudAssembly?: IFileSet; readonly absoluteTemplatePath: string; - readonly requiredAssets?: StackAsset[]; + readonly assets?: StackAsset[]; + readonly templateS3Uri?: string; } export class StackDeployment { @@ -38,7 +40,8 @@ export class StackDeployment { absoluteTemplatePath: path.join(stackArtifact.assembly.directory, stackArtifact.templateFile), assumeRoleArn: stackArtifact.assumeRoleArn, executionRoleArn: stackArtifact.cloudFormationExecutionRoleArn, - requiredAssets: extractStackAssets(stackArtifact), + assets: extractStackAssets(stackArtifact), + templateS3Uri: stackArtifact.stackTemplateAssetObjectUrl, }); } @@ -53,9 +56,23 @@ export class StackDeployment { public readonly customCloudAssembly?: FileSet; public readonly absoluteTemplatePath: string; public readonly requiredAssets: StackAsset[]; - public readonly dependsOnStacks: StackDeployment[] = []; + /** + * The asset that represents the CloudFormation template for this stack. + */ + public readonly templateAsset?: StackAsset; + + /** + * The S3 URL which points to the template asset location in the publishing + * bucket. + * + * This is `undefined` if the stack template is not published. + * + * @example https://bucket.s3.amazonaws.com/object/key + */ + public readonly templateUrl?: string; + constructor(props: StackDeploymentProps) { this.stackArtifactId = props.stackArtifactId; this.stackHierarchicalId = props.stackHierarchicalId; @@ -67,7 +84,17 @@ export class StackDeployment { this.stackName = props.stackName; this.customCloudAssembly = props.customCloudAssembly?.primaryOutput; this.absoluteTemplatePath = props.absoluteTemplatePath; - this.requiredAssets = props.requiredAssets ?? []; + this.templateUrl = props.templateS3Uri ? s3UrlFromUri(props.templateS3Uri, props.region) : undefined; + + this.requiredAssets = new Array(); + + for (const asset of props.assets ?? []) { + if (asset.isTemplate) { + this.templateAsset = asset; + } else { + this.requiredAssets.push(asset); + } + } } public relativeTemplatePath(root: string) { @@ -107,6 +134,11 @@ export interface StackAsset { * Type of asset to publish */ readonly assetType: AssetType; + + /** + * Does this asset represent the template. + */ + readonly isTemplate?: boolean; } function extractStackAssets(stackArtifact: cxapi.CloudFormationStackArtifact): StackAsset[] { @@ -118,14 +150,12 @@ function extractStackAssets(stackArtifact: cxapi.CloudFormationStackArtifact): S for (const entry of manifest.entries) { let assetType: AssetType; + let isTemplate; + if (entry instanceof DockerImageManifestEntry) { assetType = AssetType.DOCKER_IMAGE; } else if (entry instanceof FileManifestEntry) { - // Don't publishg the template for this stack - if (entry.source.packaging === 'file' && entry.source.path === stackArtifact.templateFile) { - continue; - } - + isTemplate = entry.source.packaging === 'file' && entry.source.path === stackArtifact.templateFile; assetType = AssetType.FILE; } else { throw new Error(`Unrecognized asset type: ${entry.type}`); @@ -136,9 +166,22 @@ function extractStackAssets(stackArtifact: cxapi.CloudFormationStackArtifact): S assetId: entry.id.assetId, assetSelector: entry.id.toString(), assetType, + isTemplate, }); } } return ret; +} + +/** + * Takes an s3://bucket/object-key uri and returns a region-aware https:// url for it + * + * @param uri The s3 URI + * @param region The region (if undefined, we will return the global endpoint) + * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access + */ +function s3UrlFromUri(uri: string, region: string | undefined) { + const url = parseUrl(uri); + return `https://${url.hostname}.s3.${region ? `${region}.` : ''}amazonaws.com${url.path}`; } \ No newline at end of file diff --git a/packages/@aws-cdk/pipelines/lib/blueprint/step.ts b/packages/@aws-cdk/pipelines/lib/blueprint/step.ts index 8ae8f73469b79..ef0104675ec0f 100644 --- a/packages/@aws-cdk/pipelines/lib/blueprint/step.ts +++ b/packages/@aws-cdk/pipelines/lib/blueprint/step.ts @@ -5,6 +5,7 @@ export abstract class Step implements IFileSet { public abstract readonly primaryOutput?: FileSet; public readonly requiredFileSets: FileSet[] = []; + public readonly isSource: boolean = false; constructor(public readonly id: string) { } diff --git a/packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline-engine.ts b/packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline-engine.ts index 76ddc3f4de691..4d644ccd6a6b1 100644 --- a/packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline-engine.ts +++ b/packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline-engine.ts @@ -8,14 +8,13 @@ import { Aws, Stack } from '@aws-cdk/core'; import * as cxapi from '@aws-cdk/cx-api'; import { Construct, Node } from 'constructs'; import { AssetType, BlueprintQueries, ManualApprovalStep, ScriptStep, StackAsset, StackDeployment, Step } from '../blueprint'; +import { GraphNode, GraphNodeCollection, isGraph, AGraphNode, PipelineGraph } from '../helpers-internal'; import { BuildDeploymentOptions, IDeploymentEngine } from '../main/engine'; import { appOf, assemblyBuilderOf, embeddedAsmPath } from '../private/construct-internals'; import { toPosixPath } from '../private/fs'; -import { GraphNode, GraphNodeCollection, isGraph } from '../private/graph'; import { enumerate, flatten, maybeSuffix } from '../private/javascript'; import { writeTemplateConfiguration } from '../private/template-configuration'; import { CodeBuildFactory, mergeBuildEnvironments, stackVariableNamespace } from './_codebuild-factory'; -import { AGraphNode, PipelineStructure } from './_pipeline-structure'; import { ArtifactMap } from './artifact-map'; import { CodeBuildStep } from './codebuild-step'; import { CodePipelineActionFactoryResult, ICodePipelineActionFactory } from './codepipeline-action-factory'; @@ -90,7 +89,7 @@ export class CodePipelineEngine implements IDeploymentEngine { restartExecutionOnUpdate: true, }); - const graphFromBp = new PipelineStructure(options.blueprint, { + const graphFromBp = new PipelineGraph(options.blueprint, { selfMutation: this.selfMutation, }); @@ -125,7 +124,7 @@ export class CodePipelineEngine implements IDeploymentEngine { return this._scope; } - private pipelineStagesAndActionsFromGraph(structure: PipelineStructure) { + private pipelineStagesAndActionsFromGraph(structure: PipelineGraph) { // Translate graph into Pipeline Stages and Actions let beforeSelfMutation = this.selfMutation; for (const stageNode of flatten(structure.graph.sortedChildren())) { @@ -456,7 +455,7 @@ export class CodePipelineEngine implements IDeploymentEngine { } interface MakeActionOptions { - readonly graphFromBp: PipelineStructure; + readonly graphFromBp: PipelineGraph; readonly runOrder: number; readonly node: AGraphNode; readonly sharedParent: AGraphNode; diff --git a/packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline-source.ts b/packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline-source.ts index 40629be3005b3..342a285c2c4b1 100644 --- a/packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline-source.ts +++ b/packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline-source.ts @@ -22,6 +22,9 @@ export abstract class CodePipelineSource extends Step implements ICodePipelineAc return new GitHubSource(repoString, props); } + // tells `PipelineGraph` to hoist a "Source" step + public readonly isSource = true; + public abstract produce(options: CodePipelineActionOptions): CodePipelineActionFactoryResult; } diff --git a/packages/@aws-cdk/pipelines/lib/blueprint/blueprint-queries.ts b/packages/@aws-cdk/pipelines/lib/helpers-internal/blueprint-queries.ts similarity index 88% rename from packages/@aws-cdk/pipelines/lib/blueprint/blueprint-queries.ts rename to packages/@aws-cdk/pipelines/lib/helpers-internal/blueprint-queries.ts index fde62eb713944..92a98a2b81ca7 100644 --- a/packages/@aws-cdk/pipelines/lib/blueprint/blueprint-queries.ts +++ b/packages/@aws-cdk/pipelines/lib/helpers-internal/blueprint-queries.ts @@ -1,7 +1,4 @@ -import { Blueprint } from './blueprint'; -import { ScriptStep, StackOutputReference } from './script-step'; -import { StackDeployment } from './stack-deployment'; -import { Step } from './step'; +import { Step, ScriptStep, StackOutputReference, Blueprint, StackDeployment } from '../blueprint'; /** * Answer some questions about a pipeline blueprint diff --git a/packages/@aws-cdk/pipelines/lib/private/graph/index.ts b/packages/@aws-cdk/pipelines/lib/helpers-internal/graph.ts similarity index 94% rename from packages/@aws-cdk/pipelines/lib/private/graph/index.ts rename to packages/@aws-cdk/pipelines/lib/helpers-internal/graph.ts index 338b5912dc105..bbed4a6a0cfce 100644 --- a/packages/@aws-cdk/pipelines/lib/private/graph/index.ts +++ b/packages/@aws-cdk/pipelines/lib/helpers-internal/graph.ts @@ -1,7 +1,7 @@ /** * A library for nested graphs */ -import { addAll, extract, flatMap } from '../../private/javascript'; +import { addAll, extract, flatMap } from '../private/javascript'; import { topoSort } from './toposort'; export interface GraphNodeProps { @@ -21,6 +21,23 @@ export class GraphNode { this.data = props.data; } + /** + * A graph-wide unique identifier for this node. Rendered by joining the IDs + * of all ancestors with hyphens. + */ + public get uniqueId(): string { + return this.ancestorPath(this.root).map(x => x.id).join('-'); + } + + /** + * The union of all dependencies of this node and the dependencies of all + * parent graphs. + */ + public get allDeps(): GraphNode[] { + const fromParent = this.parentGraph?.allDeps ?? []; + return [...this.dependencies, ...fromParent]; + } + public dependOn(...dependencies: GraphNode[]) { if (dependencies.includes(this)) { throw new Error(`Cannot add dependency on self: ${this}`); diff --git a/packages/@aws-cdk/pipelines/lib/helpers-internal/index.ts b/packages/@aws-cdk/pipelines/lib/helpers-internal/index.ts new file mode 100644 index 0000000000000..4b0dceeac2cb1 --- /dev/null +++ b/packages/@aws-cdk/pipelines/lib/helpers-internal/index.ts @@ -0,0 +1,3 @@ +export * from './pipeline-graph'; +export * from './graph'; +export * from './blueprint-queries'; \ No newline at end of file diff --git a/packages/@aws-cdk/pipelines/lib/codepipeline/_pipeline-structure.ts b/packages/@aws-cdk/pipelines/lib/helpers-internal/pipeline-graph.ts similarity index 80% rename from packages/@aws-cdk/pipelines/lib/codepipeline/_pipeline-structure.ts rename to packages/@aws-cdk/pipelines/lib/helpers-internal/pipeline-graph.ts index e96e681426414..8f9b8e04ac299 100644 --- a/packages/@aws-cdk/pipelines/lib/codepipeline/_pipeline-structure.ts +++ b/packages/@aws-cdk/pipelines/lib/helpers-internal/pipeline-graph.ts @@ -1,9 +1,28 @@ import { AssetType, Blueprint, BlueprintQueries, FileSet, ScriptStep, StackAsset, StackDeployment, StageDeployment, Step, Wave } from '../blueprint'; -import { DependencyBuilders, Graph, GraphNode, GraphNodeCollection } from '../private/graph'; -import { CodePipelineSource } from './codepipeline-source'; +import { DependencyBuilders, Graph, GraphNode, GraphNodeCollection } from './graph'; -export interface PipelineStructureProps { +export interface PipelineGraphProps { + /** + * Add a self-mutation step. + * + * @default false + */ readonly selfMutation?: boolean; + + /** + * Publishes the template asset to S3. + * + * @default false + */ + readonly publishTemplate?: boolean; + + /** + * Add a "prepare" step for each stack which can be used to create the change + * set. If this is disbled, only the "execute" step will be included. + * + * @default true + */ + readonly prepareStep?: boolean; } /** @@ -11,7 +30,7 @@ export interface PipelineStructureProps { * * This code makes all the decisions on how to lay out the CodePipeline */ -export class PipelineStructure { +export class PipelineGraph { public readonly graph: AGraph = Graph.of('', { type: 'group' }); public readonly cloudAssemblyFileSet: FileSet; public readonly queries: BlueprintQueries; @@ -21,11 +40,17 @@ export class PipelineStructure { private readonly synthNode: AGraphNode; private readonly selfMutateNode?: AGraphNode; private readonly stackOutputDependencies = new DependencyBuilders(); + private readonly publishTemplate: boolean; + private readonly prepareStep: boolean; + private lastPreparationNode: AGraphNode; private _fileAssetCtr = 0; private _dockerAssetCtr = 0; - constructor(public readonly blueprint: Blueprint, props: PipelineStructureProps = {}) { + constructor(public readonly blueprint: Blueprint, props: PipelineGraphProps = {}) { + this.publishTemplate = props.publishTemplate ?? false; + this.prepareStep = props.prepareStep ?? true; + this.queries = new BlueprintQueries(blueprint); this.synthNode = this.addBuildStep(blueprint.synthStep); @@ -90,7 +115,7 @@ export class PipelineStructure { for (const stack of stage.stacks) { const stackGraph: AGraph = Graph.of(this.simpleStackName(stack.stackName, stage.stageName), { type: 'stack-group', stack }); - const prepareNode: AGraphNode = GraphNode.of('Prepare', { type: 'prepare', stack }); + const prepareNode: AGraphNode | undefined = this.prepareStep ? GraphNode.of('Prepare', { type: 'prepare', stack }) : undefined; const deployNode: AGraphNode = GraphNode.of('Deploy', { type: 'execute', stack, @@ -98,20 +123,39 @@ export class PipelineStructure { }); retGraph.add(stackGraph); - stackGraph.add(prepareNode, deployNode); - deployNode.dependOn(prepareNode); + + stackGraph.add(deployNode); + let firstDeployNode; + if (prepareNode) { + stackGraph.add(prepareNode); + deployNode.dependOn(prepareNode); + firstDeployNode = prepareNode; + } else { + firstDeployNode = deployNode; + } + stackGraphs.set(stack, stackGraph); // Depend on Cloud Assembly const cloudAssembly = stack.customCloudAssembly?.primaryOutput ?? this.cloudAssemblyFileSet; - prepareNode.dependOn(this.addAndRecurse(cloudAssembly.producer, retGraph)); + + firstDeployNode.dependOn(this.addAndRecurse(cloudAssembly.producer, retGraph)); + + // add the template asset + if (this.publishTemplate) { + if (!stack.templateAsset) { + throw new Error(`"publishTemplate" is enabled, but stack ${stack.stackArtifactId} does not have a template asset`); + } + + firstDeployNode.dependOn(this.publishAsset(stack.templateAsset)); + } // Depend on Assets // FIXME: Custom Cloud Assembly currently doesn't actually help separating // out templates from assets!!! for (const asset of stack.requiredAssets) { const assetNode = this.publishAsset(asset); - prepareNode.dependOn(assetNode); + firstDeployNode.dependOn(assetNode); } // Add stack output synchronization point @@ -122,7 +166,15 @@ export class PipelineStructure { for (const stack of stage.stacks) { for (const dep of stack.dependsOnStacks) { - stackGraphs.get(stack)?.dependOn(stackGraphs.get(dep)!); + const stackNode = stackGraphs.get(stack); + const depNode = stackGraphs.get(dep); + if (!stackNode) { + throw new Error(`cannot find node for ${stack.stackName}`); + } + if (!depNode) { + throw new Error(`cannot find node for ${dep.stackName}`); + } + stackNode.dependOn(depNode); } } @@ -160,7 +212,7 @@ export class PipelineStructure { // If the step is a source step, change the parent to a special "Source" stage // (CodePipeline wants it that way) - if (step instanceof CodePipelineSource) { + if (step.isSource) { parent = this.topLevelGraph('Source'); } diff --git a/packages/@aws-cdk/pipelines/lib/private/graph/toposort.ts b/packages/@aws-cdk/pipelines/lib/helpers-internal/toposort.ts similarity index 98% rename from packages/@aws-cdk/pipelines/lib/private/graph/toposort.ts rename to packages/@aws-cdk/pipelines/lib/helpers-internal/toposort.ts index 6d02d3708ed22..eb5e0cc3483aa 100644 --- a/packages/@aws-cdk/pipelines/lib/private/graph/toposort.ts +++ b/packages/@aws-cdk/pipelines/lib/helpers-internal/toposort.ts @@ -1,4 +1,4 @@ -import { GraphNode } from './index'; +import { GraphNode } from './graph'; export function printDependencyMap(dependencies: Map, Set>>) { const lines = ['---']; diff --git a/packages/@aws-cdk/pipelines/test/blueprint/fixtures/file-asset1.txt b/packages/@aws-cdk/pipelines/test/blueprint/fixtures/file-asset1.txt new file mode 100644 index 0000000000000..6765125a23c6b --- /dev/null +++ b/packages/@aws-cdk/pipelines/test/blueprint/fixtures/file-asset1.txt @@ -0,0 +1 @@ +Hello, file! \ No newline at end of file diff --git a/packages/@aws-cdk/pipelines/test/blueprint/graph/dependencies.test.ts b/packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/dependencies.test.ts similarity index 50% rename from packages/@aws-cdk/pipelines/test/blueprint/graph/dependencies.test.ts rename to packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/dependencies.test.ts index 24578aa363f75..f577ffae4f80c 100644 --- a/packages/@aws-cdk/pipelines/test/blueprint/graph/dependencies.test.ts +++ b/packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/dependencies.test.ts @@ -1,6 +1,5 @@ -import { Graph, GraphNode } from '../../../lib/private/graph'; - -class PlainNode extends GraphNode { } +import { GraphNode } from '../../../lib/helpers-internal'; +import { mkGraph, nodeNames } from './util'; describe('with nested graphs', () => { const graph = mkGraph('G', G => { @@ -51,38 +50,3 @@ describe('with nested graphs', () => { ]); }); }); - -function mkGraph(name: string, block: (b: GraphBuilder) => void) { - const graph = new Graph(name); - block({ - graph(name2, deps, block2) { - const innerG = mkGraph(name2, block2); - innerG.dependOn(...deps); - graph.add(innerG); - return innerG; - }, - node(name2, deps) { - const innerN = new PlainNode(name2); - innerN.dependOn(...deps ?? []); - graph.add(innerN); - return innerN; - }, - }); - return graph; -} - - -interface GraphBuilder { - graph(name: string, deps: GraphNode[], block: (b: GraphBuilder) => void): Graph; - node(name: string, deps?: GraphNode[]): GraphNode; -} - - -function nodeNames(n: GraphNode): string; -function nodeNames(ns: GraphNode[]): string[]; -function nodeNames(ns: GraphNode[][]): string[][]; -function nodeNames(n: any): any { - if (n instanceof GraphNode) { return n.id; } - if (Array.isArray(n)) { return n.map(nodeNames); } - throw new Error('oh no'); -} \ No newline at end of file diff --git a/packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/graph.test.ts b/packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/graph.test.ts new file mode 100644 index 0000000000000..169d1d08e9341 --- /dev/null +++ b/packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/graph.test.ts @@ -0,0 +1,44 @@ +import { GraphNode } from '../../../lib/helpers-internal'; +import { flatten } from '../../../lib/private/javascript'; +import { mkGraph } from './util'; + + +test('"uniqueId" renders a graph-wide unique id for each node', () => { + const g = mkGraph('MyGraph', G => { + G.graph('g1', [], G1 => { + G1.node('n1'); + G1.node('n2'); + G1.graph('g2', [], G2 => { + G2.node('n3'); + }); + }); + G.node('n4'); + }); + + g.consoleLog(); + + expect(Array.from(flatten(g.sortedLeaves())).map(n => n.uniqueId)).toStrictEqual([ + 'g1-n1', + 'g1-n2', + 'g1-g2-n3', + 'n4', + ]); +}); + +test('"allDeps" combines node deps and parent deps', () => { + let n4: any; + const g = mkGraph('MyGraph', G => { + G.graph('g1', [], G1 => { + G1.node('n1'); + const n2 = G1.node('n2'); + G1.graph('g2', [n2], G2 => { + const n3 = G2.node('n3'); + n4 = G2.node('n4', [n3]); + }); + }); + }); + + g.consoleLog(); + + expect((n4 as GraphNode).allDeps.map(x => x.uniqueId)).toStrictEqual(['g1-g2-n3', 'g1-n2']); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/pipelines/test/blueprint/codepipeline/pipeline-structure.test.ts b/packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/pipeline-graph.test.ts similarity index 75% rename from packages/@aws-cdk/pipelines/test/blueprint/codepipeline/pipeline-structure.test.ts rename to packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/pipeline-graph.test.ts index 7430d4057978f..5a79f9c9bb307 100644 --- a/packages/@aws-cdk/pipelines/test/blueprint/codepipeline/pipeline-structure.test.ts +++ b/packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/pipeline-graph.test.ts @@ -1,8 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies */ import '@aws-cdk/assert-internal/jest'; import * as cdkp from '../../../lib'; -import { PipelineStructure } from '../../../lib/codepipeline/_pipeline-structure'; -import { Graph, GraphNode } from '../../../lib/private/graph'; +import { Graph, GraphNode, PipelineGraph } from '../../../lib/helpers-internal'; import { flatten } from '../../../lib/private/javascript'; import { AppWithOutput, OneStackApp } from '../test-app'; import { TestApp } from '../testutil'; @@ -31,7 +30,7 @@ describe('blueprint with one stage', () => { test('simple app gets graphed correctly', () => { // WHEN - const graph = new PipelineStructure(blueprint).graph; + const graph = new PipelineGraph(blueprint).graph; // THEN expect(childrenAt(graph)).toEqual([ @@ -52,7 +51,7 @@ describe('blueprint with one stage', () => { test('self mutation gets inserted at the right place', () => { // WHEN - const graph = new PipelineStructure(blueprint, { selfMutation: true }).graph; + const graph = new PipelineGraph(blueprint, { selfMutation: true }).graph; // THEN expect(childrenAt(graph)).toEqual([ @@ -88,7 +87,7 @@ describe('blueprint with wave and stage', () => { blueprint.waves[0].stages[0].addPost(new cdkp.ManualApprovalStep('Approve')); // WHEN - const graph = new PipelineStructure(blueprint).graph; + const graph = new PipelineGraph(blueprint).graph; // THEN expect(childrenAt(graph, 'Wave')).toEqual([ @@ -107,7 +106,7 @@ describe('blueprint with wave and stage', () => { blueprint.waves[0].stages[0].addPre(new cdkp.ManualApprovalStep('Gogogo')); // WHEN - const graph = new PipelineStructure(blueprint).graph; + const graph = new PipelineGraph(blueprint).graph; // THEN expect(childrenAt(graph, 'Wave', 'Alpha')).toEqual([ @@ -117,6 +116,47 @@ describe('blueprint with wave and stage', () => { }); }); +describe('options for other engines', () => { + test('"publishTemplate" will add steps to publish CFN templates as assets', () => { + // GIVEN + const blueprint = new cdkp.Blueprint({ + synthStep: new cdkp.SynthStep('Synth', { + commands: ['build'], + }), + }); + blueprint.addStage(new OneStackApp(app, 'Alpha')); + + // WHEN + const graph = new PipelineGraph(blueprint, { + publishTemplate: true, + }); + + // THEN + expect(childrenAt(graph.graph, 'Assets')).toStrictEqual(['FileAsset1']); + }); + + test('"prepareStep: false" can be used to disable the "prepare" step for stack deployments', () => { + // GIVEN + const blueprint = new cdkp.Blueprint({ + synthStep: new cdkp.SynthStep('Synth', { + commands: ['build'], + }), + }); + blueprint.addStage(new OneStackApp(app, 'Alpha')); + + // WHEN + const graph = new PipelineGraph(blueprint, { + prepareStep: false, + }); + + // THEN + // if "prepareStep" was true (default), the "Stack" node would have "Prepare" and "Deploy" + // since "prepareStep" is false, it only has "Deploy". + expect(childrenAt(graph.graph, 'Alpha', 'Stack')).toStrictEqual(['Deploy']); + }); +}); + + describe('with app with output', () => { let blueprint: cdkp.Blueprint; let myApp: AppWithOutput; @@ -145,7 +185,7 @@ describe('with app with output', () => { }); // WHEN - const graph = new PipelineStructure(blueprint).graph; + const graph = new PipelineGraph(blueprint).graph; // THEN expect(childrenAt(graph, 'Alpha')).toEqual([ @@ -164,7 +204,7 @@ describe('with app with output', () => { }); // WHEN - const graph = new PipelineStructure(blueprint).graph; + const graph = new PipelineGraph(blueprint).graph; expect(() => { assertGraph(nodeAt(graph, 'Alpha')).sortedLeaves(); }).toThrow(/Dependency cycle/); @@ -178,7 +218,7 @@ describe('with app with output', () => { // WHEN expect(() => { - new PipelineStructure(blueprint).graph; + new PipelineGraph(blueprint).graph; }).toThrow(/is not in the pipeline/); }); }); diff --git a/packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/util.ts b/packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/util.ts new file mode 100644 index 0000000000000..61e899aef71ce --- /dev/null +++ b/packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/util.ts @@ -0,0 +1,38 @@ +import { Graph, GraphNode } from '../../../lib/helpers-internal'; + +class PlainNode extends GraphNode { } + +export function mkGraph(name: string, block: (b: GraphBuilder) => void) { + const graph = new Graph(name); + block({ + graph(name2, deps, block2) { + const innerG = mkGraph(name2, block2); + innerG.dependOn(...deps); + graph.add(innerG); + return innerG; + }, + node(name2, deps) { + const innerN = new PlainNode(name2); + innerN.dependOn(...deps ?? []); + graph.add(innerN); + return innerN; + }, + }); + return graph; +} + + +interface GraphBuilder { + graph(name: string, deps: GraphNode[], block: (b: GraphBuilder) => void): Graph; + node(name: string, deps?: GraphNode[]): GraphNode; +} + + +export function nodeNames(n: GraphNode): string; +export function nodeNames(ns: GraphNode[]): string[]; +export function nodeNames(ns: GraphNode[][]): string[][]; +export function nodeNames(n: any): any { + if (n instanceof GraphNode) { return n.id; } + if (Array.isArray(n)) { return n.map(nodeNames); } + throw new Error('oh no'); +} \ No newline at end of file diff --git a/packages/@aws-cdk/pipelines/test/blueprint/stack-deployment.test.ts b/packages/@aws-cdk/pipelines/test/blueprint/stack-deployment.test.ts new file mode 100644 index 0000000000000..8117863160aa9 --- /dev/null +++ b/packages/@aws-cdk/pipelines/test/blueprint/stack-deployment.test.ts @@ -0,0 +1,66 @@ +import * as path from 'path'; +import * as assets from '@aws-cdk/aws-s3-assets'; +import { Stack, Stage } from '@aws-cdk/core'; +import { StageDeployment } from '../../lib'; +import { TestApp } from './testutil'; + +test('"templateAsset" represents the CFN template of the stack', () => { + // GIVEN + const stage = new Stage(new TestApp(), 'MyStage'); + new Stack(stage, 'MyStack'); + + // WHEN + const sd = StageDeployment.fromStage(stage); + + // THEN + expect(sd.stacks[0].templateAsset).not.toBeUndefined(); + expect(sd.stacks[0].templateAsset?.assetId).not.toBeUndefined(); + expect(sd.stacks[0].templateAsset?.assetManifestPath).not.toBeUndefined(); + expect(sd.stacks[0].templateAsset?.assetSelector).not.toBeUndefined(); + expect(sd.stacks[0].templateAsset?.assetType).toBe('file'); + expect(sd.stacks[0].templateAsset?.isTemplate).toBeTruthy(); +}); + +describe('templateUrl', () => { + test('includes the https:// s3 URL of the template file', () => { + // GIVEN + const stage = new Stage(new TestApp(), 'MyStage', { env: { account: '111', region: 'us-east-1' } }); + new Stack(stage, 'MyStack'); + + // WHEN + const sd = StageDeployment.fromStage(stage); + + // THEN + expect(sd.stacks[0].templateUrl).toBe('https://cdk-hnb659fds-assets-111-us-east-1.s3.us-east-1.amazonaws.com/4ef627170a212f66f5d1d9240d967ef306f4820ff9cb05b3a7ec703df6af6c3e.json'); + }); + + test('without region', () => { + // GIVEN + const stage = new Stage(new TestApp(), 'MyStage', { env: { account: '111' } }); + new Stack(stage, 'MyStack'); + + // WHEN + const sd = StageDeployment.fromStage(stage); + + // THEN + expect(sd.stacks[0].templateUrl).toBe('https://cdk-hnb659fds-assets-111-.s3.amazonaws.com/$%7BAWS::Region%7D/4ef627170a212f66f5d1d9240d967ef306f4820ff9cb05b3a7ec703df6af6c3e.json'); + }); + +}); + + +test('"requiredAssets" contain only assets that are not the template', () => { + // GIVEN + const stage = new Stage(new TestApp(), 'MyStage'); + const stack = new Stack(stage, 'MyStack'); + new assets.Asset(stack, 'Asset', { path: path.join(__dirname, 'fixtures') }); + + // WHEN + const sd = StageDeployment.fromStage(stage); + + // THEN + expect(sd.stacks[0].requiredAssets.length).toBe(1); + expect(sd.stacks[0].requiredAssets[0].assetType).toBe('file'); + expect(sd.stacks[0].requiredAssets[0].isTemplate).toBeFalsy(); +}); + diff --git a/packages/@aws-cdk/pipelines/test/rename-this-folder/pipeline.test.ts b/packages/@aws-cdk/pipelines/test/rename-this-folder/pipeline.test.ts index fc8b03411e5ac..57be0c803281b 100644 --- a/packages/@aws-cdk/pipelines/test/rename-this-folder/pipeline.test.ts +++ b/packages/@aws-cdk/pipelines/test/rename-this-folder/pipeline.test.ts @@ -47,14 +47,14 @@ behavior('references stack template in subassembly', (suite) => { Stages: arrayWith({ Name: 'App', Actions: arrayWith( - objectLike({ - Name: 'Stack.Prepare', - InputArtifacts: [objectLike({})], - Configuration: objectLike({ - StackName: 'App-Stack', - TemplatePath: stringLike('*::assembly-App/*.template.json'), + objectLike({ + Name: 'Stack.Prepare', + InputArtifacts: [objectLike({})], + Configuration: objectLike({ + StackName: 'App-Stack', + TemplatePath: stringLike('*::assembly-App/*.template.json'), + }), }), - }), ), }), }); @@ -69,14 +69,14 @@ behavior('references stack template in subassembly', (suite) => { Stages: arrayWith({ Name: 'AppMain', Actions: arrayWith( - objectLike({ - Name: 'Prepare', - InputArtifacts: [objectLike({})], - Configuration: objectLike({ - StackName: 'AppMain-Stack', - TemplatePath: stringLike('*::assembly-AppMain/*.template.json'), + objectLike({ + Name: 'Prepare', + InputArtifacts: [objectLike({})], + Configuration: objectLike({ + StackName: 'AppMain-Stack', + TemplatePath: stringLike('*::assembly-AppMain/*.template.json'), + }), }), - }), ), }), }); @@ -423,7 +423,7 @@ behavior('generates CodeBuild project in privileged mode', (suite) => { PrivilegedMode: true, }, }); - }) + }); }); behavior('overridden stack names are respected', (suite) => { @@ -435,24 +435,24 @@ behavior('overridden stack names are respected', (suite) => { // THEN expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { Stages: arrayWith( - { - Name: 'App1', - Actions: arrayWith(objectLike({ - Name: 'MyFancyStack.Prepare', - Configuration: objectLike({ - StackName: 'MyFancyStack', - }), - })), - }, - { - Name: 'App2', - Actions: arrayWith(objectLike({ - Name: 'MyFancyStack.Prepare', - Configuration: objectLike({ - StackName: 'MyFancyStack', - }), - })), - }, + { + Name: 'App1', + Actions: arrayWith(objectLike({ + Name: 'MyFancyStack.Prepare', + Configuration: objectLike({ + StackName: 'MyFancyStack', + }), + })), + }, + { + Name: 'App2', + Actions: arrayWith(objectLike({ + Name: 'MyFancyStack.Prepare', + Configuration: objectLike({ + StackName: 'MyFancyStack', + }), + })), + }, ), }); }); @@ -548,14 +548,14 @@ behavior('tags get reflected in legacyPipeline', (suite) => { Stages: arrayWith({ Name: 'App', Actions: arrayWith( - objectLike({ - Name: 'Stack.Prepare', - InputArtifacts: [objectLike({})], - Configuration: objectLike({ - StackName: 'App-Stack', - TemplateConfiguration: templateConfig.capture(stringLike('*::assembly-App/*.template.*json')), + objectLike({ + Name: 'Stack.Prepare', + InputArtifacts: [objectLike({})], + Configuration: objectLike({ + StackName: 'App-Stack', + TemplateConfiguration: templateConfig.capture(stringLike('*::assembly-App/*.template.*json')), + }), }), - }), ), }), });