diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..e853c32 --- /dev/null +++ b/action.yml @@ -0,0 +1,18 @@ +name: '"AWS CodeBuild run project" Action For GitHub Actions' +description: 'Execute CodeBuild::startBuild for the current repo.' +branding: + icon: 'cloud' + color: 'orange' +inputs: + project-name: + description: 'AWS CodeBuild Project Name' + required: true + buildspec-override: + description: 'Buildspec Override' + required: false + env-passthrough: + description: 'Comma separated list of environment variables to send to CodeBuild' + required: false +runs: + using: 'node12' + main: 'dist/index.js' \ No newline at end of file diff --git a/code-build.js b/code-build.js new file mode 100644 index 0000000..6f474c0 --- /dev/null +++ b/code-build.js @@ -0,0 +1,139 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const core = require("@actions/core"); +const github = require("@actions/github"); +const aws = require("aws-sdk"); +const assert = require("assert"); + +module.exports = { + buildProject, + _buildProject, + waitForBuildEndTime, + inputs2Parameters, + buildSdk, + logName +}; + +function buildProject() { + // get a codeBuild instance from the SDK + const sdk = buildSdk(); + + // Get input options for startBuild + const params = inputs2Parameters(); + + return _buildProject(sdk, params); +} + +async function _buildProject(sdk, params) { + // Start the build + const start = await sdk.codeBuild.startBuild(params).promise(); + + // Wait for the build to "complete" + const build = await waitForBuildEndTime(sdk, start.build); + + // Signal the outcome + assert( + build.buildStatus === "SUCCEEDED", + `Build status: ${build.buildStatus}` + ); +} + +async function waitForBuildEndTime(sdk, { id, logs }, nextToken) { + const { codeBuild, cloudWatchLogs, wait = 1000 * 5 } = sdk; + + // Get the CloudWatchLog info + const startFromHead = true; + const { cloudWatchLogsArn } = logs; + const { logGroupName, logStreamName } = logName(cloudWatchLogsArn); + + // Check the state + const [batch, cloudWatch = {}] = await Promise.all([ + codeBuild.batchGetBuilds({ ids: [id] }).promise(), + // The CloudWatchLog _may_ not be set up, only make the call if we have a logGroupName + logGroupName && + cloudWatchLogs + .getLogEvents({ logGroupName, logStreamName, startFromHead, nextToken }) + .promise() + ]); + // Pluck off the relevant state + const [current] = batch.builds; + const { nextForwardToken, events = [] } = cloudWatch; + + // stdout the CloudWatchLog (everyone likes progress...) + events.forEach(({ message }) => console.log(message)); + + // We did it! We can stop looking! + if (current.endTime && !events.length) return current; + + // More to do: Sleep for 5 seconds :) + await new Promise(resolve => setTimeout(resolve, wait)); + + // Try again + return waitForBuildEndTime(sdk, current, nextForwardToken); +} + +function inputs2Parameters() { + const projectName = core.getInput("project-name", { required: true }); + + // The github.context.sha is evaluated on import. + // This makes it hard to test. + // So I use the raw ENV + const sourceVersion = process.env[`GITHUB_SHA`]; + const sourceTypeOverride = "GITHUB"; + const { owner, repo } = github.context.repo; + const sourceLocationOverride = `https://github.com/${owner}/${repo}.git`; + + const buildspecOverride = + core.getInput("buildspec-override", { required: false }) || undefined; + + const envVars = core + .getInput("env-passthrough", { required: false }) + .split(",") + + .map(i => i.trim()); + + const environmentVariablesOverride = Object.entries(process.env) + .filter(([key]) => key.startsWith("GITHUB_") || envVars.includes(key)) + .map(([name, value]) => ({ name, value, type: "PLAINTEXT" })); + + // The idempotencyToken is intentionally not set. + // This way the GitHub events can manage the builds. + return { + projectName, + sourceVersion, + sourceTypeOverride, + sourceLocationOverride, + buildspecOverride, + environmentVariablesOverride + }; +} + +function buildSdk() { + const codeBuild = new aws.CodeBuild({ + customUserAgent: "aws-codbuild-run-project" + }); + + const cloudWatchLogs = new aws.CloudWatchLogs({ + customUserAgent: "aws-codbuild-run-project" + }); + + assert( + codeBuild.config.credentials && cloudWatchLogs.config.credentials, + "No credentials. Try adding @aws-actions/configure-aws-credentials earlier in your job to set up AWS credentials." + ); + + return { codeBuild, cloudWatchLogs }; +} + +function logName(Arn) { + const [logGroupName, logStreamName] = Arn.split(":log-group:") + .pop() + .split(":log-stream:"); + if (logGroupName === "null" || logStreamName === "null") + return { + logGroupName: undefined, + logStreamName: undefined + }; + return { logGroupName, logStreamName }; +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..9521c56 --- /dev/null +++ b/index.js @@ -0,0 +1,20 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const core = require("@actions/core"); +const { buildProject } = require("./code-build"); + +/* istanbul ignore next */ +if (require.main === module) { + run(); +} + +module.exports = run; + +async function run() { + try { + await buildProject(); + } catch (error) { + core.setFailed(error.message); + } +} diff --git a/test/code-build-test.js b/test/code-build-test.js new file mode 100644 index 0000000..d861edc --- /dev/null +++ b/test/code-build-test.js @@ -0,0 +1,258 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { + logName, + inputs2Parameters, + waitForBuildEndTime +} = require("../code-build"); +const { expect } = require("chai"); + +describe("logName", () => { + it("return the logGroupName and logStreamName from an ARN", () => { + const arn = + "arn:aws:logs:us-west-2:111122223333:log-group:/aws/codebuild/CloudWatchLogGroup:log-stream:1234abcd-12ab-34cd-56ef-1234567890ab"; + const test = logName(arn); + expect(test) + .to.haveOwnProperty("logGroupName") + .and.to.equal("/aws/codebuild/CloudWatchLogGroup"); + expect(test) + .to.haveOwnProperty("logStreamName") + .and.to.equal("1234abcd-12ab-34cd-56ef-1234567890ab"); + }); + + it("return undefined when the group and stream are null", () => { + const arn = + "arn:aws:logs:us-west-2:111122223333:log-group:null:log-stream:null"; + const test = logName(arn); + expect(test) + .to.haveOwnProperty("logGroupName") + .and.to.equal(undefined); + expect(test) + .to.haveOwnProperty("logStreamName") + .and.to.equal(undefined); + }); +}); + +describe("inputs2Parameters", () => { + const OLD_ENV = { ...process.env }; + afterEach(() => { + process.env = { ...OLD_ENV }; + }); + + const projectName = "project_name"; + const repoInfo = "owner/repo"; + const sha = "1234abcd-12ab-34cd-56ef-1234567890ab"; + + it("build basic parameters for codeBuild.startBuild", () => { + // This is how GITHUB injects its input values. + // It would be nice if there was an easy way to test this... + process.env[`INPUT_PROJECT-NAME`] = projectName; + process.env[`GITHUB_REPOSITORY`] = repoInfo; + process.env[`GITHUB_SHA`] = sha; + const test = inputs2Parameters(); + expect(test) + .to.haveOwnProperty("projectName") + .and.to.equal(projectName); + expect(test) + .to.haveOwnProperty("sourceVersion") + .and.to.equal(sha); + expect(test) + .to.haveOwnProperty("sourceTypeOverride") + .and.to.equal("GITHUB"); + expect(test) + .to.haveOwnProperty("sourceLocationOverride") + .and.to.equal(`https://github.com/owner/repo.git`); + expect(test) + .to.haveOwnProperty("buildspecOverride") + .and.to.equal(undefined); + + // I send everything that starts 'GITHUB_' + expect(test) + .to.haveOwnProperty("environmentVariablesOverride") + .and.to.have.lengthOf(2); + expect(test.environmentVariablesOverride[0]) + .to.haveOwnProperty("name") + .and.to.equal("GITHUB_REPOSITORY"); + expect(test.environmentVariablesOverride[0]) + .to.haveOwnProperty("value") + .and.to.equal(repoInfo); + expect(test.environmentVariablesOverride[0]) + .to.haveOwnProperty("type") + .and.to.equal("PLAINTEXT"); + + expect(test.environmentVariablesOverride[1]) + .to.haveOwnProperty("name") + .and.to.equal("GITHUB_SHA"); + expect(test.environmentVariablesOverride[1]) + .to.haveOwnProperty("value") + .and.to.equal(sha); + expect(test.environmentVariablesOverride[1]) + .to.haveOwnProperty("type") + .and.to.equal("PLAINTEXT"); + }); + + it("a project name is required.", () => { + expect(() => inputs2Parameters()).to.throw(); + }); + + it("can send env-passthrough", () => { + // This is how GITHUB injects its input values. + // It would be nice if there was an easy way to test this... + process.env[`INPUT_PROJECT-NAME`] = projectName; + process.env[`GITHUB_REPOSITORY`] = repoInfo; + process.env[`GITHUB_SHA`] = sha; + + process.env[`INPUT_ENV-PASSTHROUGH`] = `one, two + , three, + four `; + + process.env.one = "_one_"; + process.env.two = "_two_"; + process.env.three = "_three_"; + process.env.four = "_four_"; + + const test = inputs2Parameters(); + + expect(test) + .to.haveOwnProperty("environmentVariablesOverride") + .and.to.have.lengthOf(6); + + expect(test.environmentVariablesOverride[2]) + .to.haveOwnProperty("name") + .and.to.equal("one"); + expect(test.environmentVariablesOverride[2]) + .to.haveOwnProperty("value") + .and.to.equal("_one_"); + expect(test.environmentVariablesOverride[2]) + .to.haveOwnProperty("type") + .and.to.equal("PLAINTEXT"); + + expect(test.environmentVariablesOverride[3]) + .to.haveOwnProperty("name") + .and.to.equal("two"); + expect(test.environmentVariablesOverride[3]) + .to.haveOwnProperty("value") + .and.to.equal("_two_"); + expect(test.environmentVariablesOverride[3]) + .to.haveOwnProperty("type") + .and.to.equal("PLAINTEXT"); + + expect(test.environmentVariablesOverride[4]) + .to.haveOwnProperty("name") + .and.to.equal("three"); + expect(test.environmentVariablesOverride[4]) + .to.haveOwnProperty("value") + .and.to.equal("_three_"); + expect(test.environmentVariablesOverride[4]) + .to.haveOwnProperty("type") + .and.to.equal("PLAINTEXT"); + + expect(test.environmentVariablesOverride[5]) + .to.haveOwnProperty("name") + .and.to.equal("four"); + expect(test.environmentVariablesOverride[5]) + .to.haveOwnProperty("value") + .and.to.equal("_four_"); + expect(test.environmentVariablesOverride[5]) + .to.haveOwnProperty("type") + .and.to.equal("PLAINTEXT"); + }); +}); + +describe("waitForBuildEndTime", () => { + it("basic usages", async () => { + let count = 0; + const buildID = "buildID"; + const cloudWatchLogsArn = + "arn:aws:logs:us-west-2:111122223333:log-group:/aws/codebuild/CloudWatchLogGroup:log-stream:1234abcd-12ab-34cd-56ef-1234567890ab"; + + const buildReplies = [ + { + builds: [ + { id: buildID, logs: { cloudWatchLogsArn }, endTime: "endTime" } + ] + } + ]; + const logReplies = [{ events: [] }]; + const sdk = help( + () => buildReplies[count++], + () => logReplies[count - 1] + ); + + const test = await waitForBuildEndTime(sdk, { + id: buildID, + logs: { cloudWatchLogsArn } + }); + + expect(test).to.equal(buildReplies.pop().builds[0]); + }); + + it("waits for a build endTime **and** no cloud watch log events", async function() { + this.timeout(25000); + let count = 0; + const buildID = "buildID"; + const nullArn = + "arn:aws:logs:us-west-2:111122223333:log-group:null:log-stream:null"; + const cloudWatchLogsArn = + "arn:aws:logs:us-west-2:111122223333:log-group:/aws/codebuild/CloudWatchLogGroup:log-stream:1234abcd-12ab-34cd-56ef-1234567890ab"; + + const buildReplies = [ + { builds: [{ id: buildID, logs: { cloudWatchLogsArn } }] }, + { + builds: [ + { id: buildID, logs: { cloudWatchLogsArn }, endTime: "endTime" } + ] + }, + { + builds: [ + { id: buildID, logs: { cloudWatchLogsArn }, endTime: "endTime" } + ] + } + ]; + const logReplies = [ + undefined, + { events: [{ message: "got one" }] }, + { events: [] } + ]; + const sdk = help( + () => buildReplies[count++], + () => logReplies[count - 1] + ); + + const test = await waitForBuildEndTime(sdk, { + id: buildID, + logs: { cloudWatchLogsArn: nullArn } + }); + expect(test).to.equal(buildReplies.pop().builds[0]); + }); +}); + +function help(builds, logs) { + const codeBuild = { + batchGetBuilds() { + return { + async promise() { + return ret(builds); + } + }; + } + }; + + const cloudWatchLogs = { + getLogEvents() { + return { + async promise() { + return ret(logs); + } + }; + } + }; + + return { codeBuild, cloudWatchLogs, wait: 10 }; + + function ret(thing) { + if (typeof thing === "function") return thing(); + return thing; + } +}