diff --git a/.github/workflows/aws-tests.yml b/.github/workflows/aws-tests.yml new file mode 100644 index 00000000..331f0b21 --- /dev/null +++ b/.github/workflows/aws-tests.yml @@ -0,0 +1,59 @@ +name: AWS + +on: + push: { branches: [aws, aws-ts] } + +env: + AWS_REGION: "eu-west-1" + +jobs: + buildForAllPlatforms: + name: AWS Fargate Build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + projectPath: + - test-project + unityVersion: + # - 2019.2.11f1 + - 2019.3.15f1 + targetPlatform: + #- StandaloneOSX # Build a macOS standalone (Intel 64-bit). + #- StandaloneWindows64 # Build a Windows 64-bit standalone. + - StandaloneLinux64 # Build a Linux 64-bit standalone. + #- iOS # Build an iOS player. + #- Android # Build an Android .apk. + #- WebGL # WebGL. + # - StandaloneWindows # Build a Windows standalone. + # - WSAPlayer # Build an Windows Store Apps player. + # - PS4 # Build a PS4 Standalone. + # - XboxOne # Build a Xbox One Standalone. + # - tvOS # Build to Apple's tvOS platform. + # - Switch # Build a Nintendo Switch player + # steps + steps: + - name: Checkout (default) + uses: actions/checkout@v2 + if: github.event.event_type != 'pull_request_target' + with: + lfs: true + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: eu-west-2 + - uses: ./ + id: aws-fargate-unity-build + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: eu-west-2 + with: + remoteBuildCluster: aws + projectPath: ${{ matrix.projectPath }} + unityVersion: ${{ matrix.unityVersion }} + targetPlatform: ${{ matrix.targetPlatform }} + githubToken: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/action.yml b/action.yml index 7b306525..92668de6 100644 --- a/action.yml +++ b/action.yml @@ -30,6 +30,10 @@ inputs: required: false default: '' description: 'Path to a Namespace.Class.StaticMethod to run to perform the build.' + remoteBuildCluster: + default: '' + required: false + description: 'To use a remote build cluster specify either, aws or k8s as well as the required parameters.' kubeConfig: default: '' required: false diff --git a/src/index.ts b/src/index.ts index 74fe3d0e..20edb1c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import * as core from '@actions/core'; -import { Action, BuildParameters, Cache, Docker, ImageTag, Kubernetes, Output } from './model'; +import { Action, BuildParameters, Cache, Docker, ImageTag, Kubernetes, Output, AWS } from './model'; async function run() { try { @@ -10,14 +10,27 @@ async function run() { const buildParameters = await BuildParameters.create(); const baseImage = new ImageTag(buildParameters); - if (buildParameters.kubeConfig) { - core.info('Building with Kubernetes'); - await Kubernetes.runBuildJob(buildParameters, baseImage); - } else { - // Build docker image - // TODO: No image required (instead use a version published to dockerhub for the action, supply credentials for github cloning) - const builtImage = await Docker.build({ path: actionFolder, dockerfile, baseImage }); - await Docker.run(builtImage, { workspace, ...buildParameters }); + let builtImage; + + switch (buildParameters.remoteBuildCluster) { + case 'k8s': + core.info('Building with Kubernetes'); + await Kubernetes.runBuildJob(buildParameters, baseImage); + break; + + case 'aws': + core.info('Building with AWS'); + await AWS.runBuildJob(buildParameters, baseImage); + break; + + // default and local case + default: + core.info('Building locally'); + // Build docker image + // TODO: No image required (instead use a version published to dockerhub for the action, supply credentials for github cloning) + builtImage = await Docker.build({ path: actionFolder, dockerfile, baseImage }); + await Docker.run(builtImage, { workspace, ...buildParameters }); + break; } // Set output diff --git a/src/model/aws.ts b/src/model/aws.ts new file mode 100644 index 00000000..e9995eea --- /dev/null +++ b/src/model/aws.ts @@ -0,0 +1,447 @@ +/* eslint-disable no-plusplus */ +/* eslint-disable no-await-in-loop */ +import * as SDK from 'aws-sdk'; +import { nanoid } from 'nanoid'; + +const fs = require('fs'); +const core = require('@actions/core'); +const zlib = require('zlib'); + +class AWS { + static async runBuildJob(buildParameters, baseImage) { + try{ + + let buildUid = nanoid(); + await this.run(buildUid, + buildParameters.awsStackName, + 'alpine/git', + ['/bin/sh'], + [ + '-c', + `apk update; + apk add git-lfs; + apk add jq; + ls; + git clone https://$GITHUB_TOKEN@github.com/${process.env.GITHUB_REPOSITORY}.git ${buildUid}/repo; + git clone https://$GITHUB_TOKEN@github.com/webbertakken/unity-builder.git ${buildUid}/builder; + cd ${buildUid}/repo; + git checkout $GITHUB_SHA; + `], + '/data', + '/data/', + [ + { + name: 'GITHUB_SHA', + value: process.env.GITHUB_SHA, + }, + ], + [ + { + ParameterKey: 'GithubToken', + ParameterValue: buildParameters.githubToken, + }, + ], + ); + await this.run(buildUid, + buildParameters.awsStackName, + baseImage.toString(), + ['/bin/sh'], + ['-c', ` + cp -r /data/${buildUid}/builder/action/default-build-script /UnityBuilderAction; + cp -r /data/${buildUid}/builder/action/entrypoint.sh /entrypoint.sh; + cp -r /data/${buildUid}/builder/action/steps /steps; + ls; + chmod -R +x /entrypoint.sh; + chmod -R +x /steps; + /entrypoint.sh; + ls + `], + '/data', + `/data/${buildUid}/repo/`, + [ + { + name: 'GITHUB_WORKSPACE', + value: `/data/${buildUid}/repo/`, + }, + { + name: 'PROJECT_PATH', + value: buildParameters.projectPath, + }, + { + name: 'BUILD_PATH', + value: buildParameters.buildPath, + }, + { + name: 'BUILD_FILE', + value: buildParameters.buildFile, + }, + { + name: 'BUILD_NAME', + value: buildParameters.buildName, + }, + { + name: 'BUILD_METHOD', + value: buildParameters.buildMethod, + }, + { + name: 'CUSTOM_PARAMETERS', + value: buildParameters.customParameters, + }, + { + name: 'BUILD_TARGET', + value: buildParameters.platform, + }, + { + name: 'ANDROID_VERSION_CODE', + value: buildParameters.androidVersionCode.toString(), + }, + { + name: 'ANDROID_KEYSTORE_NAME', + value: buildParameters.androidKeystoreName, + }, + { + name: 'ANDROID_KEYALIAS_NAME', + value: buildParameters.androidKeyaliasName, + }, + ], + [ + { + ParameterKey: 'GithubToken', + ParameterValue: buildParameters.githubToken, + }, + { + ParameterKey: 'UnityLicense', + ParameterValue: process.env.UNITY_LICENSE?process.env.UNITY_LICENSE:'0' + }, + { + ParameterKey: 'UnityEmail', + ParameterValue: process.env.UNITY_EMAIL?process.env.UNITY_EMAIL:'0' + }, + { + ParameterKey: 'UnityPassword', + ParameterValue: process.env.UNITY_PASSWORD?process.env.UNITY_PASSWORD:'0' + }, + { + ParameterKey: 'UnitySerial', + ParameterValue: process.env.UNITY_SERIAL?process.env.UNITY_SERIAL:'0' + }, + { + ParameterKey: 'AndroidKeystoreBase64', + ParameterValue: buildParameters.androidKeystoreBase64?buildParameters.androidKeystoreBase64:'0' + }, + { + ParameterKey: 'AndroidKeystorePass', + ParameterValue: buildParameters.androidKeystorePass?buildParameters.androidKeystorePass:'0' + }, + { + ParameterKey: 'AndroidKeyAliasPass', + ParameterValue: buildParameters.androidKeyaliasPass?buildParameters.androidKeyaliasPass:'0' + }, + ] + ); + // Cleanup + await this.run(buildUid, + buildParameters.awsStackName, + 'alpine', + ['/bin/sh'], + [ + '-c', + ` + apk update; + apk add zip + zip -r ./${buildUid}/output.zip ./${buildUid}/repo/build + ls + `], + '/data', + '/data/', + [ + { + name: 'GITHUB_SHA', + value: process.env.GITHUB_SHA, + }, + ], + [ + { + ParameterKey: 'GithubToken', + ParameterValue: buildParameters.githubToken, + }, + ], + ); + await this.run(buildUid, + buildParameters.awsStackName, + 'amazon/aws-cli', + ['/bin/sh'], + [ + '-c', + ` + aws s3 cp ./${buildUid}/output.zip s3://game-ci-storage/${buildUid} + rm -r ${buildUid} + ls + `], + '/data', + '/data/', + [ + { + name: 'GITHUB_SHA', + value: process.env.GITHUB_SHA, + }, + { + name: 'AWS_DEFAULT_REGION', + value: process.env.AWS_DEFAULT_REGION, + }, + + ], + [ + { + ParameterKey: 'GithubToken', + ParameterValue: buildParameters.githubToken, + }, + { + ParameterKey: 'AWSAccessKeyID', + ParameterValue: process.env.AWS_ACCESS_KEY_ID, + }, + { + ParameterKey: 'AWSSecretAccessKey', + ParameterValue: process.env.AWS_SECRET_ACCESS_KEY, + }, + ], + ); + } + catch(error){ + core.setFailed(error); + core.error(error); + } + } + + static async run(buildUid, stackName, image, entrypoint, commands, mountdir, workingdir, environment, secrets) { + const ECS = new SDK.ECS(); + const CF = new SDK.CloudFormation(); + + const taskDefStackName = `${stackName}-taskDef-${image}-${buildUid}` + .toString() + .replace(/[^\da-z]/gi, ''); + core.info('Creating build job resources'); + const taskDefCloudFormation = fs.readFileSync(`${__dirname}/task-def-formation.yml`, 'utf8'); + await CF.createStack({ + StackName: taskDefStackName, + TemplateBody: taskDefCloudFormation, + Parameters: [ + { + ParameterKey: 'ImageUrl', + ParameterValue: image, + }, + { + ParameterKey: 'ServiceName', + ParameterValue: taskDefStackName, + }, + { + ParameterKey: 'Command', + ParameterValue: commands.join(','), + }, + { + ParameterKey: 'EntryPoint', + ParameterValue: entrypoint.join(','), + }, + { + ParameterKey: 'WorkingDirectory', + ParameterValue: workingdir, + }, + { + ParameterKey: 'EFSMountDirectory', + ParameterValue: mountdir, + }, + { + ParameterKey: 'BUILDID', + ParameterValue: buildUid, + } + ].concat(secrets), + }).promise(); + + const ttlCloudFormation = fs.readFileSync(`${__dirname}/cloudformation-stack-ttl.yml`, 'utf8'); + await CF.createStack({ + StackName: taskDefStackName+"-ttl", + TemplateBody: ttlCloudFormation, + Capabilities: [ "CAPABILITY_IAM" ], + Parameters: [ + { + ParameterKey: 'StackName', + ParameterValue: taskDefStackName, + }, + { + ParameterKey: 'TTL', + ParameterValue: "100", + }, + ], + }).promise(); + + try{ + await CF.waitFor('stackCreateComplete', { StackName: taskDefStackName }).promise(); + }catch(error){ + core.error(error); + } + + const taskDefResources = await CF.describeStackResources({ + StackName: taskDefStackName, + }).promise(); + + const baseResources = await CF.describeStackResources({ StackName: stackName }).promise(); + + + const clusterName = baseResources.StackResources?.find( + (x) => x.LogicalResourceId === 'ECSCluster', + )?.PhysicalResourceId || ""; + const task = await ECS.runTask({ + cluster: clusterName, + taskDefinition: taskDefResources.StackResources?.find( + (x) => x.LogicalResourceId === 'TaskDefinition', + )?.PhysicalResourceId || "", + platformVersion: '1.4.0', + overrides: { + containerOverrides: [ + { + name: taskDefStackName, + environment: environment.concat([ + {name:'BUILDID', value: buildUid} + ]), + }, + ], + }, + launchType: 'FARGATE', + networkConfiguration: { + awsvpcConfiguration: { + subnets: [ + baseResources.StackResources?.find((x) => x.LogicalResourceId === 'PublicSubnetOne')?.PhysicalResourceId || "", + baseResources.StackResources?.find((x) => x.LogicalResourceId === 'PublicSubnetTwo')?.PhysicalResourceId || "", + ], + assignPublicIp: 'ENABLED', + securityGroups: [ + baseResources.StackResources?.find((x) => x.LogicalResourceId === 'ContainerSecurityGroup')?.PhysicalResourceId || "", + ], + }, + }, + }, undefined).promise(); + + core.info('Build job is starting'); + + try { + await ECS.waitFor('tasksRunning', {tasks: [task.tasks?.[0].taskArn||""], + cluster: clusterName, + }).promise(); + } catch (error) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + let describeTasks = await ECS.describeTasks({ + tasks: [task.tasks?.[0].taskArn||""], + cluster: clusterName, + }).promise(); + core.info( + `Build job has ended ${describeTasks.tasks?.[0].containers?.[0].lastStatus}` + ); + core.setFailed(error); + core.error(error); + } + + + core.info(`Build job is running`); + + // watching logs + const kinesis = new SDK.Kinesis(); + + const getTaskStatus = async () => { + const tasks = await ECS.describeTasks({ + cluster: clusterName, + tasks: [task.tasks?.[0].taskArn||""], + }).promise(); + return tasks.tasks?.[0].lastStatus; + }; + + const stream = await kinesis.describeStream({ + StreamName: taskDefResources.StackResources?.find( + (x) => x.LogicalResourceId === 'KinesisStream', + )?.PhysicalResourceId||"", + }, undefined).promise(); + + let iterator = ( + await kinesis + .getShardIterator({ + ShardIteratorType: 'TRIM_HORIZON', + StreamName: stream.StreamDescription.StreamName, + ShardId: stream.StreamDescription.Shards[0].ShardId, + }) + .promise() + ).ShardIterator||""; + + await CF.waitFor('stackCreateComplete', { StackName: taskDefStackName+"-ttl" }).promise(); + + core.info(`Task status is ${await getTaskStatus()}`); + + const logBaseUrl = `https://console.aws.amazon.com/cloudwatch/home?region=${SDK.config.region}#logsV2:log-groups/${taskDefStackName}`; + core.info(`You can also watch the logs at AWS Cloud Watch: ${logBaseUrl}`); + + let readingLogs = true; + while (readingLogs) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + if ((await getTaskStatus()) !== 'RUNNING') { + readingLogs = false; + await new Promise((resolve) => setTimeout(resolve, 35000)); + } + const records = await kinesis + .getRecords({ + ShardIterator: iterator, + }) + .promise(); + iterator = records.NextShardIterator||""; + if (records.Records.length > 0) { + for (let index = 0; index < records.Records.length; index++) { + const json = JSON.parse( + zlib.gunzipSync(Buffer.from(records.Records[index].Data.toString(), 'base64')).toString('utf8'), + ); + if (json.messageType === 'DATA_MESSAGE') { + for (let logEventsIndex = 0; logEventsIndex < json.logEvents.length; logEventsIndex++) { + core.info(json.logEvents[logEventsIndex].message); + } + } + } + } + } + + await ECS.waitFor('tasksStopped', { cluster: clusterName, tasks: [task.tasks?.[0].taskArn||""]}).promise(); + + const exitCode = (await ECS.describeTasks({ + tasks: [task.tasks?.[0].taskArn||""], + cluster: clusterName, + }).promise() + ).tasks?.[0].containers?.[0].exitCode; + + if(exitCode!=0){ + core.error(`job finished with exit code ${exitCode}`) + } + else{ + core.info(`Build job has finished with exit code 0`); + } + + await CF.deleteStack({ + StackName: taskDefStackName, + }).promise(); + + await CF.deleteStack({ + StackName: taskDefStackName+"-ttl", + }).promise(); + + await CF.waitFor('stackDeleteComplete', { + StackName: taskDefStackName + }).promise(); + + await CF.waitFor('stackDeleteComplete', { + StackName: taskDefStackName+"-ttl" + }).promise(); + + core.info('Cleanup complete'); + } + + static onlog(batch) { + batch.forEach((log) => { + core.info(`log: ${log}`); + }); + } +} +export default AWS; \ No newline at end of file diff --git a/src/model/build-parameters.ts b/src/model/build-parameters.ts index cd727da7..684c4d82 100644 --- a/src/model/build-parameters.ts +++ b/src/model/build-parameters.ts @@ -33,6 +33,7 @@ class BuildParameters { androidKeyaliasName: Input.androidKeyaliasName, androidKeyaliasPass: Input.androidKeyaliasPass, customParameters: Input.customParameters, + remoteBuildCluster: Input.remoteBuildCluster, kubeConfig: Input.kubeConfig, githubToken: Input.githubToken, kubeContainerMemory: Input.kubeContainerMemory, diff --git a/src/model/input.ts b/src/model/input.ts index f0048bce..cdb6babb 100644 --- a/src/model/input.ts +++ b/src/model/input.ts @@ -85,6 +85,10 @@ class Input { return core.getInput('customParameters') || ''; } + static get remoteBuildCluster(){ + return core.getInput('remoteBuildCluster') || ''; + } + static get kubeConfig() { return core.getInput('kubeConfig') || ''; }