Skip to content

Commit 86a2192

Browse files
authored
refactor(codebuild): introduce BuildSpec object (#2820)
Minimal representation of BuildSpec, formalizing some logic that was already in `Project` but not really well-placed there. It has very minimal support for merging buildspecs (only commands, not artifacts or anything else). Internal representation is hidden though, and can be improved and extended later. BREAKING CHANGE: * **codebuild**: buildSpec argument is now a `BuildSpec` object.
1 parent b088c8c commit 86a2192

14 files changed

+188
-82
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { IResolveContext, Lazy, Stack } from '@aws-cdk/cdk';
2+
3+
/**
4+
* BuildSpec for CodeBuild projects
5+
*/
6+
export abstract class BuildSpec {
7+
public static fromObject(value: {[key: string]: any}): BuildSpec {
8+
return new ObjectBuildSpec(value);
9+
}
10+
11+
/**
12+
* Use a file from the source as buildspec
13+
*
14+
* Use this if you want to use a file different from 'buildspec.yml'`
15+
*/
16+
public static fromSourceFilename(filename: string): BuildSpec {
17+
return new FilenameBuildSpec(filename);
18+
}
19+
20+
/**
21+
* Whether the buildspec is directly available or deferred until build-time
22+
*/
23+
public abstract readonly isImmediate: boolean;
24+
25+
protected constructor() {
26+
}
27+
28+
/**
29+
* Render the represented BuildSpec
30+
*/
31+
public abstract toBuildSpec(): string;
32+
}
33+
34+
/**
35+
* BuildSpec that just returns the input unchanged
36+
*/
37+
class FilenameBuildSpec extends BuildSpec {
38+
public readonly isImmediate: boolean = false;
39+
40+
constructor(private readonly filename: string) {
41+
super();
42+
}
43+
44+
public toBuildSpec(): string {
45+
return this.filename;
46+
}
47+
48+
public toString() {
49+
return `<buildspec file: ${this.filename}>`;
50+
}
51+
}
52+
53+
/**
54+
* BuildSpec that understands about structure
55+
*/
56+
class ObjectBuildSpec extends BuildSpec {
57+
public readonly isImmediate: boolean = true;
58+
59+
constructor(public readonly spec: {[key: string]: any}) {
60+
super();
61+
}
62+
63+
public toBuildSpec(): string {
64+
// We have to pretty-print the buildspec, otherwise
65+
// CodeBuild will not recognize it as an inline buildspec.
66+
return Lazy.stringValue({ produce: (ctx: IResolveContext) =>
67+
Stack.of(ctx.scope).toJsonString(this.spec, 2)
68+
});
69+
}
70+
}
71+
72+
/**
73+
* Merge two buildspecs into a new BuildSpec
74+
*
75+
* NOTE: will currently only merge commands, not artifact
76+
* declarations, environment variables, secrets, or any
77+
* other configuration elements.
78+
*
79+
* Internal for now because it's not complete/good enough
80+
* to expose on the objects directly, but we need to it to
81+
* keep feature-parity for Project.
82+
*
83+
* @internal
84+
*/
85+
export function mergeBuildSpecs(lhs: BuildSpec, rhs: BuildSpec): BuildSpec {
86+
if (!(lhs instanceof ObjectBuildSpec) || !(rhs instanceof ObjectBuildSpec)) {
87+
throw new Error('Can only merge buildspecs created using BuildSpec.fromObject()');
88+
}
89+
90+
return new ObjectBuildSpec(copyCommands(lhs.spec, rhs.spec));
91+
}
92+
93+
/**
94+
* Extend buildSpec phases with the contents of another one
95+
*/
96+
function copyCommands(buildSpec: any, extend: any): any {
97+
if (buildSpec.version === '0.1') {
98+
throw new Error('Cannot extend buildspec at version "0.1". Set the version to "0.2" or higher instead.');
99+
}
100+
101+
const ret = Object.assign({}, buildSpec); // Return a copy
102+
ret.phases = Object.assign({}, ret.phases);
103+
104+
for (const phaseName of Object.keys(extend.phases)) {
105+
const phase = ret.phases[phaseName] = Object.assign({}, ret.phases[phaseName]);
106+
phase.commands = [...phase.commands || [], ...extend.phases[phaseName].commands];
107+
}
108+
109+
return ret;
110+
}

packages/@aws-cdk/aws-codebuild/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './project';
44
export * from './source';
55
export * from './artifacts';
66
export * from './cache';
7+
export * from './build-spec';
78

89
// AWS::CodeBuild CloudFormation Resources:
910
export * from './codebuild.generated';

packages/@aws-cdk/aws-codebuild/lib/project.ts

Lines changed: 21 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import iam = require('@aws-cdk/aws-iam');
88
import kms = require('@aws-cdk/aws-kms');
99
import { Aws, Construct, IResource, Lazy, PhysicalName, Resource, ResourceIdentifiers, Stack } from '@aws-cdk/cdk';
1010
import { BuildArtifacts, CodePipelineBuildArtifacts, NoBuildArtifacts } from './artifacts';
11+
import { BuildSpec, mergeBuildSpecs } from './build-spec';
1112
import { Cache } from './cache';
1213
import { CfnProject } from './codebuild.generated';
1314
import { BuildSource, NoSource, SourceType } from './source';
@@ -371,7 +372,7 @@ export interface CommonProjectProps {
371372
*
372373
* @default - Empty buildspec.
373374
*/
374-
readonly buildSpec?: any;
375+
readonly buildSpec?: BuildSpec;
375376

376377
/**
377378
* Run a script from an asset as build script
@@ -647,12 +648,14 @@ export class Project extends ProjectBase {
647648

648649
// Inject download commands for asset if requested
649650
const environmentVariables = props.environmentVariables || {};
650-
const buildSpec = props.buildSpec || {};
651+
let buildSpec = props.buildSpec;
651652

652653
if (props.buildScriptAsset) {
653654
environmentVariables[S3_BUCKET_ENV] = { value: props.buildScriptAsset.s3BucketName };
654655
environmentVariables[S3_KEY_ENV] = { value: props.buildScriptAsset.s3ObjectKey };
655-
extendBuildSpec(buildSpec, this.buildImage.runScriptBuildspec(props.buildScriptAssetEntrypoint || 'build.sh'));
656+
657+
const runScript = this.buildImage.runScriptBuildspec(props.buildScriptAssetEntrypoint || 'build.sh');
658+
buildSpec = buildSpec ? mergeBuildSpecs(buildSpec, runScript) : runScript;
656659
props.buildScriptAsset.grantRead(this.role);
657660
}
658661

@@ -662,24 +665,15 @@ export class Project extends ProjectBase {
662665
throw new Error(`Badge is not supported for source type ${this.source.type}`);
663666
}
664667

665-
const sourceJson = this.source._toSourceJSON();
666-
if (typeof buildSpec === 'string') {
667-
return {
668-
...sourceJson,
669-
buildSpec // Filename to buildspec file
670-
};
671-
} else if (Object.keys(buildSpec).length > 0) {
672-
// We have to pretty-print the buildspec, otherwise
673-
// CodeBuild will not recognize it as an inline buildspec.
674-
return {
675-
...sourceJson,
676-
buildSpec: JSON.stringify(buildSpec, undefined, 2)
677-
};
678-
} else if (this.source.type === SourceType.None) {
679-
throw new Error("If the Project's source is NoSource, you need to provide a buildSpec");
680-
} else {
681-
return sourceJson;
668+
if (this.source.type === SourceType.None && (buildSpec === undefined || !buildSpec.isImmediate)) {
669+
throw new Error("If the Project's source is NoSource, you need to provide a concrete buildSpec");
682670
}
671+
672+
const sourceJson = this.source._toSourceJSON();
673+
return {
674+
...sourceJson,
675+
buildSpec: buildSpec && buildSpec.toBuildSpec()
676+
};
683677
};
684678

685679
this._secondarySources = [];
@@ -1025,7 +1019,7 @@ export interface IBuildImage {
10251019
/**
10261020
* Make a buildspec to run the indicated script
10271021
*/
1028-
runScriptBuildspec(entrypoint: string): any;
1022+
runScriptBuildspec(entrypoint: string): BuildSpec;
10291023
}
10301024

10311025
/**
@@ -1124,8 +1118,8 @@ export class LinuxBuildImage implements IBuildImage {
11241118
return [];
11251119
}
11261120

1127-
public runScriptBuildspec(entrypoint: string): any {
1128-
return {
1121+
public runScriptBuildspec(entrypoint: string): BuildSpec {
1122+
return BuildSpec.fromObject({
11291123
version: '0.2',
11301124
phases: {
11311125
pre_build: {
@@ -1149,7 +1143,7 @@ export class LinuxBuildImage implements IBuildImage {
11491143
]
11501144
}
11511145
}
1152-
};
1146+
});
11531147
}
11541148
}
11551149

@@ -1220,8 +1214,8 @@ export class WindowsBuildImage implements IBuildImage {
12201214
return ret;
12211215
}
12221216

1223-
public runScriptBuildspec(entrypoint: string): any {
1224-
return {
1217+
public runScriptBuildspec(entrypoint: string): BuildSpec {
1218+
return BuildSpec.fromObject({
12251219
version: '0.2',
12261220
phases: {
12271221
pre_build: {
@@ -1242,7 +1236,7 @@ export class WindowsBuildImage implements IBuildImage {
12421236
]
12431237
}
12441238
}
1245-
};
1239+
});
12461240
}
12471241
}
12481242

@@ -1272,33 +1266,6 @@ export enum BuildEnvironmentVariableType {
12721266
ParameterStore = 'PARAMETER_STORE'
12731267
}
12741268

1275-
/**
1276-
* Extend buildSpec phases with the contents of another one
1277-
*/
1278-
function extendBuildSpec(buildSpec: any, extend: any) {
1279-
if (typeof buildSpec === 'string') {
1280-
throw new Error('Cannot extend buildspec that is given as a string. Pass the buildspec as a structure instead.');
1281-
}
1282-
if (buildSpec.version === '0.1') {
1283-
throw new Error('Cannot extend buildspec at version "0.1". Set the version to "0.2" or higher instead.');
1284-
}
1285-
if (buildSpec.version === undefined) {
1286-
buildSpec.version = extend.version;
1287-
}
1288-
1289-
if (!buildSpec.phases) {
1290-
buildSpec.phases = {};
1291-
}
1292-
1293-
for (const phaseName of Object.keys(extend.phases)) {
1294-
if (!(phaseName in buildSpec.phases)) { buildSpec.phases[phaseName] = {}; }
1295-
const phase = buildSpec.phases[phaseName];
1296-
1297-
if (!(phase.commands)) { phase.commands = []; }
1298-
phase.commands.push(...extend.phases[phaseName].commands);
1299-
}
1300-
}
1301-
13021269
function ecrAccessForCodeBuildService(): iam.PolicyStatement {
13031270
return new iam.PolicyStatement()
13041271
.describe('CodeBuild')

packages/@aws-cdk/aws-codebuild/test/integ.caching.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ const bucket = new s3.Bucket(stack, 'CacheBucket', {
1414

1515
new codebuild.Project(stack, 'MyProject', {
1616
cache: Cache.bucket(bucket),
17-
buildSpec: {
17+
buildSpec: codebuild.BuildSpec.fromObject({
1818
build: {
1919
commands: ['echo Hello']
2020
},
2121
cache: {
2222
paths: ['/root/.cache/pip/**/*']
2323
}
24-
}
24+
})
2525
});
2626

2727
app.synth();

packages/@aws-cdk/aws-codebuild/test/integ.defaults.lit.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class TestStack extends cdk.Stack {
77

88
/// !show
99
new codebuild.Project(this, 'MyProject', {
10-
buildSpec: {
10+
buildSpec: codebuild.BuildSpec.fromObject({
1111
version: '0.2',
1212
phases: {
1313
build: {
@@ -16,7 +16,7 @@ class TestStack extends cdk.Stack {
1616
]
1717
}
1818
}
19-
}
19+
})
2020
});
2121
/// !hide
2222
}

packages/@aws-cdk/aws-codebuild/test/integ.docker-asset.lit.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ class TestStack extends cdk.Stack {
77
super(scope, id);
88

99
new codebuild.Project(this, 'MyProject', {
10-
buildSpec: {
10+
buildSpec: codebuild.BuildSpec.fromObject({
1111
version: "0.2",
1212
phases: {
1313
build: {
1414
commands: [ 'ls' ]
1515
}
1616
}
17-
},
17+
}),
1818
/// !show
1919
environment: {
2020
buildImage: codebuild.LinuxBuildImage.fromAsset(this, 'MyImage', {

packages/@aws-cdk/aws-codebuild/test/integ.ecr.lit.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ class TestStack extends cdk.Stack {
99
const ecrRepository = new ecr.Repository(this, 'MyRepo');
1010

1111
new codebuild.Project(this, 'MyProject', {
12-
buildSpec: {
12+
buildSpec: codebuild.BuildSpec.fromObject({
1313
version: "0.2",
1414
phases: {
1515
build: {
1616
commands: [ 'ls' ]
1717
}
1818
}
19-
},
19+
}),
2020
/// !show
2121
environment: {
2222
buildImage: codebuild.LinuxBuildImage.fromEcrRepository(ecrRepository, "v1.0")

packages/@aws-cdk/aws-codebuild/test/integ.project-secondary-sources-artifacts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ const bucket = new s3.Bucket(stack, 'MyBucket', {
1111
});
1212

1313
new codebuild.Project(stack, 'MyProject', {
14-
buildSpec: {
14+
buildSpec: codebuild.BuildSpec.fromObject({
1515
version: '0.2',
16-
},
16+
}),
1717
secondarySources: [
1818
new codebuild.S3BucketSource({
1919
bucket,

packages/@aws-cdk/aws-codebuild/test/test.codebuild.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -762,9 +762,9 @@ export = {
762762
const stack = new cdk.Stack();
763763
const bucket = new s3.Bucket(stack, 'Bucket');
764764
new codebuild.Project(stack, 'Project', {
765-
buildSpec: {
765+
buildSpec: codebuild.BuildSpec.fromObject({
766766
version: '0.2',
767-
},
767+
}),
768768
artifacts: new codebuild.S3BucketBuildArtifacts({
769769
path: 'some/path',
770770
name: 'some_name',
@@ -791,9 +791,9 @@ export = {
791791

792792
test.throws(() => {
793793
new codebuild.Project(stack, 'MyProject', {
794-
buildSpec: {
794+
buildSpec: codebuild.BuildSpec.fromObject({
795795
version: '0.2',
796-
},
796+
}),
797797
secondarySources: [
798798
new codebuild.CodePipelineSource(),
799799
],
@@ -857,9 +857,9 @@ export = {
857857

858858
test.throws(() => {
859859
new codebuild.Project(stack, 'MyProject', {
860-
buildSpec: {
860+
buildSpec: codebuild.BuildSpec.fromObject({
861861
version: '0.2',
862-
},
862+
}),
863863
secondaryArtifacts: [
864864
new codebuild.S3BucketBuildArtifacts({
865865
bucket: new s3.Bucket(stack, 'MyBucket'),

0 commit comments

Comments
 (0)