diff --git a/packages/@aws-cdk/aws-codebuild/lib/build-spec.ts b/packages/@aws-cdk/aws-codebuild/lib/build-spec.ts new file mode 100644 index 0000000000000..616739c0ef675 --- /dev/null +++ b/packages/@aws-cdk/aws-codebuild/lib/build-spec.ts @@ -0,0 +1,110 @@ +import { IResolveContext, Lazy, Stack } from '@aws-cdk/cdk'; + +/** + * BuildSpec for CodeBuild projects + */ +export abstract class BuildSpec { + public static fromObject(value: {[key: string]: any}): BuildSpec { + return new ObjectBuildSpec(value); + } + + /** + * Use a file from the source as buildspec + * + * Use this if you want to use a file different from 'buildspec.yml'` + */ + public static fromSourceFilename(filename: string): BuildSpec { + return new FilenameBuildSpec(filename); + } + + /** + * Whether the buildspec is directly available or deferred until build-time + */ + public abstract readonly isImmediate: boolean; + + protected constructor() { + } + + /** + * Render the represented BuildSpec + */ + public abstract toBuildSpec(): string; +} + +/** + * BuildSpec that just returns the input unchanged + */ +class FilenameBuildSpec extends BuildSpec { + public readonly isImmediate: boolean = false; + + constructor(private readonly filename: string) { + super(); + } + + public toBuildSpec(): string { + return this.filename; + } + + public toString() { + return ``; + } +} + +/** + * BuildSpec that understands about structure + */ +class ObjectBuildSpec extends BuildSpec { + public readonly isImmediate: boolean = true; + + constructor(public readonly spec: {[key: string]: any}) { + super(); + } + + public toBuildSpec(): string { + // We have to pretty-print the buildspec, otherwise + // CodeBuild will not recognize it as an inline buildspec. + return Lazy.stringValue({ produce: (ctx: IResolveContext) => + Stack.of(ctx.scope).toJsonString(this.spec, 2) + }); + } +} + +/** + * Merge two buildspecs into a new BuildSpec + * + * NOTE: will currently only merge commands, not artifact + * declarations, environment variables, secrets, or any + * other configuration elements. + * + * Internal for now because it's not complete/good enough + * to expose on the objects directly, but we need to it to + * keep feature-parity for Project. + * + * @internal + */ +export function mergeBuildSpecs(lhs: BuildSpec, rhs: BuildSpec): BuildSpec { + if (!(lhs instanceof ObjectBuildSpec) || !(rhs instanceof ObjectBuildSpec)) { + throw new Error('Can only merge buildspecs created using BuildSpec.fromObject()'); + } + + return new ObjectBuildSpec(copyCommands(lhs.spec, rhs.spec)); +} + +/** + * Extend buildSpec phases with the contents of another one + */ +function copyCommands(buildSpec: any, extend: any): any { + if (buildSpec.version === '0.1') { + throw new Error('Cannot extend buildspec at version "0.1". Set the version to "0.2" or higher instead.'); + } + + const ret = Object.assign({}, buildSpec); // Return a copy + ret.phases = Object.assign({}, ret.phases); + + for (const phaseName of Object.keys(extend.phases)) { + const phase = ret.phases[phaseName] = Object.assign({}, ret.phases[phaseName]); + phase.commands = [...phase.commands || [], ...extend.phases[phaseName].commands]; + } + + return ret; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-codebuild/lib/index.ts b/packages/@aws-cdk/aws-codebuild/lib/index.ts index a2394316f82fa..f1409a3c456d9 100644 --- a/packages/@aws-cdk/aws-codebuild/lib/index.ts +++ b/packages/@aws-cdk/aws-codebuild/lib/index.ts @@ -4,6 +4,7 @@ export * from './project'; export * from './source'; export * from './artifacts'; export * from './cache'; +export * from './build-spec'; // AWS::CodeBuild CloudFormation Resources: export * from './codebuild.generated'; diff --git a/packages/@aws-cdk/aws-codebuild/lib/project.ts b/packages/@aws-cdk/aws-codebuild/lib/project.ts index 381b2c7c2ffff..db24bd78f3277 100644 --- a/packages/@aws-cdk/aws-codebuild/lib/project.ts +++ b/packages/@aws-cdk/aws-codebuild/lib/project.ts @@ -8,6 +8,7 @@ import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); import { Aws, Construct, IResource, Lazy, PhysicalName, Resource, ResourceIdentifiers, Stack } from '@aws-cdk/cdk'; import { BuildArtifacts, CodePipelineBuildArtifacts, NoBuildArtifacts } from './artifacts'; +import { BuildSpec, mergeBuildSpecs } from './build-spec'; import { Cache } from './cache'; import { CfnProject } from './codebuild.generated'; import { BuildSource, NoSource, SourceType } from './source'; @@ -371,7 +372,7 @@ export interface CommonProjectProps { * * @default - Empty buildspec. */ - readonly buildSpec?: any; + readonly buildSpec?: BuildSpec; /** * Run a script from an asset as build script @@ -647,12 +648,14 @@ export class Project extends ProjectBase { // Inject download commands for asset if requested const environmentVariables = props.environmentVariables || {}; - const buildSpec = props.buildSpec || {}; + let buildSpec = props.buildSpec; if (props.buildScriptAsset) { environmentVariables[S3_BUCKET_ENV] = { value: props.buildScriptAsset.s3BucketName }; environmentVariables[S3_KEY_ENV] = { value: props.buildScriptAsset.s3ObjectKey }; - extendBuildSpec(buildSpec, this.buildImage.runScriptBuildspec(props.buildScriptAssetEntrypoint || 'build.sh')); + + const runScript = this.buildImage.runScriptBuildspec(props.buildScriptAssetEntrypoint || 'build.sh'); + buildSpec = buildSpec ? mergeBuildSpecs(buildSpec, runScript) : runScript; props.buildScriptAsset.grantRead(this.role); } @@ -662,24 +665,15 @@ export class Project extends ProjectBase { throw new Error(`Badge is not supported for source type ${this.source.type}`); } - const sourceJson = this.source._toSourceJSON(); - if (typeof buildSpec === 'string') { - return { - ...sourceJson, - buildSpec // Filename to buildspec file - }; - } else if (Object.keys(buildSpec).length > 0) { - // We have to pretty-print the buildspec, otherwise - // CodeBuild will not recognize it as an inline buildspec. - return { - ...sourceJson, - buildSpec: JSON.stringify(buildSpec, undefined, 2) - }; - } else if (this.source.type === SourceType.None) { - throw new Error("If the Project's source is NoSource, you need to provide a buildSpec"); - } else { - return sourceJson; + if (this.source.type === SourceType.None && (buildSpec === undefined || !buildSpec.isImmediate)) { + throw new Error("If the Project's source is NoSource, you need to provide a concrete buildSpec"); } + + const sourceJson = this.source._toSourceJSON(); + return { + ...sourceJson, + buildSpec: buildSpec && buildSpec.toBuildSpec() + }; }; this._secondarySources = []; @@ -1025,7 +1019,7 @@ export interface IBuildImage { /** * Make a buildspec to run the indicated script */ - runScriptBuildspec(entrypoint: string): any; + runScriptBuildspec(entrypoint: string): BuildSpec; } /** @@ -1124,8 +1118,8 @@ export class LinuxBuildImage implements IBuildImage { return []; } - public runScriptBuildspec(entrypoint: string): any { - return { + public runScriptBuildspec(entrypoint: string): BuildSpec { + return BuildSpec.fromObject({ version: '0.2', phases: { pre_build: { @@ -1149,7 +1143,7 @@ export class LinuxBuildImage implements IBuildImage { ] } } - }; + }); } } @@ -1220,8 +1214,8 @@ export class WindowsBuildImage implements IBuildImage { return ret; } - public runScriptBuildspec(entrypoint: string): any { - return { + public runScriptBuildspec(entrypoint: string): BuildSpec { + return BuildSpec.fromObject({ version: '0.2', phases: { pre_build: { @@ -1242,7 +1236,7 @@ export class WindowsBuildImage implements IBuildImage { ] } } - }; + }); } } @@ -1272,33 +1266,6 @@ export enum BuildEnvironmentVariableType { ParameterStore = 'PARAMETER_STORE' } -/** - * Extend buildSpec phases with the contents of another one - */ -function extendBuildSpec(buildSpec: any, extend: any) { - if (typeof buildSpec === 'string') { - throw new Error('Cannot extend buildspec that is given as a string. Pass the buildspec as a structure instead.'); - } - if (buildSpec.version === '0.1') { - throw new Error('Cannot extend buildspec at version "0.1". Set the version to "0.2" or higher instead.'); - } - if (buildSpec.version === undefined) { - buildSpec.version = extend.version; - } - - if (!buildSpec.phases) { - buildSpec.phases = {}; - } - - for (const phaseName of Object.keys(extend.phases)) { - if (!(phaseName in buildSpec.phases)) { buildSpec.phases[phaseName] = {}; } - const phase = buildSpec.phases[phaseName]; - - if (!(phase.commands)) { phase.commands = []; } - phase.commands.push(...extend.phases[phaseName].commands); - } -} - function ecrAccessForCodeBuildService(): iam.PolicyStatement { return new iam.PolicyStatement() .describe('CodeBuild') diff --git a/packages/@aws-cdk/aws-codebuild/test/integ.caching.ts b/packages/@aws-cdk/aws-codebuild/test/integ.caching.ts index 506db0bd746ec..0646a552933ab 100644 --- a/packages/@aws-cdk/aws-codebuild/test/integ.caching.ts +++ b/packages/@aws-cdk/aws-codebuild/test/integ.caching.ts @@ -14,14 +14,14 @@ const bucket = new s3.Bucket(stack, 'CacheBucket', { new codebuild.Project(stack, 'MyProject', { cache: Cache.bucket(bucket), - buildSpec: { + buildSpec: codebuild.BuildSpec.fromObject({ build: { commands: ['echo Hello'] }, cache: { paths: ['/root/.cache/pip/**/*'] } - } + }) }); app.synth(); diff --git a/packages/@aws-cdk/aws-codebuild/test/integ.defaults.lit.ts b/packages/@aws-cdk/aws-codebuild/test/integ.defaults.lit.ts index 5ed599c0a6430..4697e2c4c5d47 100644 --- a/packages/@aws-cdk/aws-codebuild/test/integ.defaults.lit.ts +++ b/packages/@aws-cdk/aws-codebuild/test/integ.defaults.lit.ts @@ -7,7 +7,7 @@ class TestStack extends cdk.Stack { /// !show new codebuild.Project(this, 'MyProject', { - buildSpec: { + buildSpec: codebuild.BuildSpec.fromObject({ version: '0.2', phases: { build: { @@ -16,7 +16,7 @@ class TestStack extends cdk.Stack { ] } } - } + }) }); /// !hide } diff --git a/packages/@aws-cdk/aws-codebuild/test/integ.docker-asset.lit.ts b/packages/@aws-cdk/aws-codebuild/test/integ.docker-asset.lit.ts index a40a36d5b1bfc..37c695ceafccb 100644 --- a/packages/@aws-cdk/aws-codebuild/test/integ.docker-asset.lit.ts +++ b/packages/@aws-cdk/aws-codebuild/test/integ.docker-asset.lit.ts @@ -7,14 +7,14 @@ class TestStack extends cdk.Stack { super(scope, id); new codebuild.Project(this, 'MyProject', { - buildSpec: { + buildSpec: codebuild.BuildSpec.fromObject({ version: "0.2", phases: { build: { commands: [ 'ls' ] } } - }, + }), /// !show environment: { buildImage: codebuild.LinuxBuildImage.fromAsset(this, 'MyImage', { diff --git a/packages/@aws-cdk/aws-codebuild/test/integ.ecr.lit.ts b/packages/@aws-cdk/aws-codebuild/test/integ.ecr.lit.ts index 18fcca37d71f7..4c53ed1e3bbb5 100644 --- a/packages/@aws-cdk/aws-codebuild/test/integ.ecr.lit.ts +++ b/packages/@aws-cdk/aws-codebuild/test/integ.ecr.lit.ts @@ -9,14 +9,14 @@ class TestStack extends cdk.Stack { const ecrRepository = new ecr.Repository(this, 'MyRepo'); new codebuild.Project(this, 'MyProject', { - buildSpec: { + buildSpec: codebuild.BuildSpec.fromObject({ version: "0.2", phases: { build: { commands: [ 'ls' ] } } - }, + }), /// !show environment: { buildImage: codebuild.LinuxBuildImage.fromEcrRepository(ecrRepository, "v1.0") diff --git a/packages/@aws-cdk/aws-codebuild/test/integ.project-secondary-sources-artifacts.ts b/packages/@aws-cdk/aws-codebuild/test/integ.project-secondary-sources-artifacts.ts index ab78972787c82..8f33a229d4561 100644 --- a/packages/@aws-cdk/aws-codebuild/test/integ.project-secondary-sources-artifacts.ts +++ b/packages/@aws-cdk/aws-codebuild/test/integ.project-secondary-sources-artifacts.ts @@ -11,9 +11,9 @@ const bucket = new s3.Bucket(stack, 'MyBucket', { }); new codebuild.Project(stack, 'MyProject', { - buildSpec: { + buildSpec: codebuild.BuildSpec.fromObject({ version: '0.2', - }, + }), secondarySources: [ new codebuild.S3BucketSource({ bucket, diff --git a/packages/@aws-cdk/aws-codebuild/test/test.codebuild.ts b/packages/@aws-cdk/aws-codebuild/test/test.codebuild.ts index 9badd9435488e..c71fc625ac0b0 100644 --- a/packages/@aws-cdk/aws-codebuild/test/test.codebuild.ts +++ b/packages/@aws-cdk/aws-codebuild/test/test.codebuild.ts @@ -762,9 +762,9 @@ export = { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'Bucket'); new codebuild.Project(stack, 'Project', { - buildSpec: { + buildSpec: codebuild.BuildSpec.fromObject({ version: '0.2', - }, + }), artifacts: new codebuild.S3BucketBuildArtifacts({ path: 'some/path', name: 'some_name', @@ -791,9 +791,9 @@ export = { test.throws(() => { new codebuild.Project(stack, 'MyProject', { - buildSpec: { + buildSpec: codebuild.BuildSpec.fromObject({ version: '0.2', - }, + }), secondarySources: [ new codebuild.CodePipelineSource(), ], @@ -857,9 +857,9 @@ export = { test.throws(() => { new codebuild.Project(stack, 'MyProject', { - buildSpec: { + buildSpec: codebuild.BuildSpec.fromObject({ version: '0.2', - }, + }), secondaryArtifacts: [ new codebuild.S3BucketBuildArtifacts({ bucket: new s3.Bucket(stack, 'MyBucket'), diff --git a/packages/@aws-cdk/aws-codebuild/test/test.project.ts b/packages/@aws-cdk/aws-codebuild/test/test.project.ts index 174ce65be4eae..8f7372d58b471 100644 --- a/packages/@aws-cdk/aws-codebuild/test/test.project.ts +++ b/packages/@aws-cdk/aws-codebuild/test/test.project.ts @@ -17,7 +17,7 @@ export = { // WHEN new codebuild.Project(stack, 'Project', { source: new codebuild.CodePipelineSource(), - buildSpec: 'hello.yml', + buildSpec: codebuild.BuildSpec.fromSourceFilename('hello.yml'), }); // THEN @@ -37,7 +37,7 @@ export = { // WHEN new codebuild.Project(stack, 'Project', { source: new codebuild.CodePipelineSource(), - buildSpec: { phases: ['say hi'] } + buildSpec: codebuild.BuildSpec.fromObject({ phases: ['say hi'] }) }); // THEN @@ -50,6 +50,33 @@ export = { test.done(); }, + 'must supply buildspec when using nosource'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + test.throws(() => { + new codebuild.Project(stack, 'Project', { + source: new codebuild.NoSource(), + }); + }, /you need to provide a concrete buildSpec/); + + test.done(); + }, + + 'must supply literal buildspec when using nosource'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + test.throws(() => { + new codebuild.Project(stack, 'Project', { + source: new codebuild.NoSource(), + buildSpec: codebuild.BuildSpec.fromSourceFilename('bla.yml'), + }); + }, /you need to provide a concrete buildSpec/); + + test.done(); + }, + 'GitHub source': { 'has reportBuildStatus on by default'(test: Test) { // GIVEN diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.lambda-deployed-through-codepipeline.lit.ts b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.lambda-deployed-through-codepipeline.lit.ts index 582eb90d002b6..9477b4406e120 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.lambda-deployed-through-codepipeline.lit.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.lambda-deployed-through-codepipeline.lit.ts @@ -47,7 +47,7 @@ const cdkBuildProject = new codebuild.Project(pipelineStack, 'CdkBuildProject', environment: { buildImage: codebuild.LinuxBuildImage.UBUNTU_14_04_NODEJS_10_1_0, }, - buildSpec: { + buildSpec: codebuild.BuildSpec.fromObject({ version: '0.2', phases: { install: { @@ -63,7 +63,7 @@ const cdkBuildProject = new codebuild.Project(pipelineStack, 'CdkBuildProject', artifacts: { files: 'LambdaStack.template.yaml', }, - }, + }), }); const cdkBuildOutput = new codepipeline.Artifact(); const cdkBuildAction = new codepipeline_actions.CodeBuildAction({ @@ -80,7 +80,7 @@ const lambdaBuildProject = new codebuild.Project(pipelineStack, 'LambdaBuildProj environment: { buildImage: codebuild.LinuxBuildImage.UBUNTU_14_04_NODEJS_10_1_0, }, - buildSpec: { + buildSpec: codebuild.BuildSpec.fromObject({ version: '0.2', phases: { install: { @@ -96,7 +96,7 @@ const lambdaBuildProject = new codebuild.Project(pipelineStack, 'LambdaBuildProj 'node_modules/**/*', ], }, - }, + }), }); const lambdaBuildOutput = new codepipeline.Artifact(); const lambdaBuildAction = new codepipeline_actions.CodeBuildAction({ diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-ecs-deploy.ts b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-ecs-deploy.ts index 0dd51f1c806a1..0c28e37681bb4 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-ecs-deploy.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-ecs-deploy.ts @@ -47,7 +47,7 @@ const project = new codebuild.PipelineProject(stack, 'EcsProject', { buildImage: codebuild.LinuxBuildImage.UBUNTU_14_04_DOCKER_17_09_0, privileged: true, }, - buildSpec: { + buildSpec: codebuild.BuildSpec.fromObject({ version: '0.2', phases: { pre_build: { @@ -66,7 +66,7 @@ const project = new codebuild.PipelineProject(stack, 'EcsProject', { artifacts: { files: 'imagedefinitions.json', }, - }, + }), environmentVariables: { 'REPOSITORY_URI': { value: repository.repositoryUri, diff --git a/packages/@aws-cdk/cdk/lib/cloudformation-lang.ts b/packages/@aws-cdk/cdk/lib/cloudformation-lang.ts index cb07b064c594a..cc43cb3702815 100644 --- a/packages/@aws-cdk/cdk/lib/cloudformation-lang.ts +++ b/packages/@aws-cdk/cdk/lib/cloudformation-lang.ts @@ -20,8 +20,9 @@ export class CloudFormationLang { * in CloudFormation will fail. * * @param obj The object to stringify + * @param space Indentation to use (default: no pretty-printing) */ - public static toJSON(obj: any): string { + public static toJSON(obj: any, space?: number): string { // This works in two stages: // // First, resolve everything. This gets rid of the lazy evaluations, evaluation @@ -61,7 +62,7 @@ export class CloudFormationLang { JSON.stringify(resolve(obj, { scope: ctx.scope, resolver: new IntrinsincWrapper() - })) + }), undefined, space) }); function wrap(value: any): any { diff --git a/packages/@aws-cdk/cdk/lib/stack.ts b/packages/@aws-cdk/cdk/lib/stack.ts index 44f0aec2219c5..7d88e7e84f31e 100644 --- a/packages/@aws-cdk/cdk/lib/stack.ts +++ b/packages/@aws-cdk/cdk/lib/stack.ts @@ -181,8 +181,8 @@ export class Stack extends Construct implements ITaggable { /** * Convert an object, potentially containing tokens, to a JSON string */ - public toJsonString(obj: any): string { - return CloudFormationLang.toJSON(obj).toString(); + public toJsonString(obj: any, space?: number): string { + return CloudFormationLang.toJSON(obj, space).toString(); } /**