diff --git a/.github/workflows/cloud-runner-integrity-localstack.yml b/.github/workflows/cloud-runner-integrity-localstack.yml new file mode 100644 index 00000000..ec779bdb --- /dev/null +++ b/.github/workflows/cloud-runner-integrity-localstack.yml @@ -0,0 +1,83 @@ +name: cloud-runner-integrity-localstack + +on: + workflow_call: + inputs: + runGithubIntegrationTests: + description: 'Run GitHub Checks integration tests' + required: false + default: 'false' + type: string + +permissions: + checks: write + contents: read + actions: write + packages: read + pull-requests: write + statuses: write + id-token: write + +env: + AWS_REGION: us-east-1 + AWS_DEFAULT_REGION: us-east-1 + AWS_STACK_NAME: game-ci-local + AWS_ENDPOINT: http://localhost:4566 + AWS_ENDPOINT_URL: http://localhost:4566 + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + CLOUD_RUNNER_BRANCH: ${{ github.ref }} + DEBUG: true + PROJECT_PATH: test-project + USE_IL2CPP: false + +jobs: + tests: + name: Cloud Runner Tests (LocalStack) + runs-on: ubuntu-latest + services: + localstack: + image: localstack/localstack + ports: + - 4566:4566 + env: + SERVICES: cloudformation,ecs,kinesis,cloudwatch,s3,logs + strategy: + fail-fast: false + matrix: + test: + - 'cloud-runner-end2end-locking' + - 'cloud-runner-end2end-caching' + - 'cloud-runner-end2end-retaining' + - 'cloud-runner-caching' + - 'cloud-runner-environment' + - 'cloud-runner-image' + - 'cloud-runner-hooks' + - 'cloud-runner-local-persistence' + - 'cloud-runner-locking-core' + - 'cloud-runner-locking-get-locked' + steps: + - uses: actions/checkout@v4 + with: + lfs: false + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'yarn' + - run: yarn install --frozen-lockfile + - run: yarn run test "${{ matrix.test }}" --detectOpenHandles --forceExit --runInBand + timeout-minutes: 60 + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + PROJECT_PATH: test-project + TARGET_PLATFORM: StandaloneWindows64 + cloudRunnerTests: true + versioning: None + KUBE_STORAGE_CLASS: local-path + PROVIDER_STRATEGY: aws + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }} diff --git a/README.md b/README.md index ba406f04..1eba600c 100644 --- a/README.md +++ b/README.md @@ -2,39 +2,45 @@ (Not affiliated with Unity Technologies) -GitHub Action to -[build Unity projects](https://github.com/marketplace/actions/unity-builder) -for different platforms. +GitHub Action to [build Unity projects](https://github.com/marketplace/actions/unity-builder) for different platforms. -Part of the GameCI open source project. -
-
+Part of the GameCI open source project.

[![Builds - Ubuntu](https://github.com/game-ci/unity-builder/actions/workflows/build-tests-ubuntu.yml/badge.svg)](https://github.com/game-ci/unity-builder/actions/workflows/build-tests-ubuntu.yml) [![Builds - Windows](https://github.com/game-ci/unity-builder/actions/workflows/build-tests-windows.yml/badge.svg)](https://github.com/game-ci/unity-builder/actions/workflows/build-tests-windows.yml) [![Builds - MacOS](https://github.com/game-ci/unity-builder/actions/workflows/build-tests-mac.yml/badge.svg)](https://github.com/game-ci/unity-builder/actions/workflows/build-tests-mac.yml) [![codecov - test coverage](https://codecov.io/gh/game-ci/unity-builder/branch/master/graph/badge.svg)](https://codecov.io/gh/game-ci/unity-builder) -
-
+

## How to use -Find the -[docs](https://game.ci/docs/github/builder) -on the GameCI -[documentation website](https://game.ci/docs). +Find the [docs](https://game.ci/docs/github/builder) on the GameCI [documentation website](https://game.ci/docs). ## Related actions -Visit the -GameCI Unity Actions -status repository for related Actions. +Visit the GameCI Unity Actions status repository for related +Actions. + +## AWS provider with local emulator + +The AWS provider can target a local AWS emulator such as [LocalStack](https://github.com/localstack/localstack). +Configure the endpoint URLs through environment variables before running tests or the action: + +``` +AWS_ENDPOINT=http://localhost:4566 +AWS_ACCESS_KEY_ID=test +AWS_SECRET_ACCESS_KEY=test +``` + +When these variables are set, Unity Builder will direct its CloudFormation, ECS, Kinesis, CloudWatch Logs and S3 clients +to the emulator instead of the real AWS services. See `.github/workflows/cloud-runner-integrity-localstack.yml` for an +example configuration. ## Community Feel free to join us on -Discord -and engage with the community. +Discord and engage with the +community. ## Contributing diff --git a/src/model/build-parameters.ts b/src/model/build-parameters.ts index 55bc29bd..62d731a1 100644 --- a/src/model/build-parameters.ts +++ b/src/model/build-parameters.ts @@ -56,6 +56,12 @@ class BuildParameters { public providerStrategy!: string; public gitPrivateToken!: string; public awsStackName!: string; + public awsEndpoint?: string; + public awsCloudFormationEndpoint?: string; + public awsEcsEndpoint?: string; + public awsKinesisEndpoint?: string; + public awsCloudWatchLogsEndpoint?: string; + public awsS3Endpoint?: string; public kubeConfig!: string; public containerMemory!: string; public containerCpu!: string; @@ -199,6 +205,12 @@ class BuildParameters { githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || 'game-ci/unity-builder', isCliMode: Cli.isCliMode, awsStackName: CloudRunnerOptions.awsStackName, + awsEndpoint: CloudRunnerOptions.awsEndpoint, + awsCloudFormationEndpoint: CloudRunnerOptions.awsCloudFormationEndpoint, + awsEcsEndpoint: CloudRunnerOptions.awsEcsEndpoint, + awsKinesisEndpoint: CloudRunnerOptions.awsKinesisEndpoint, + awsCloudWatchLogsEndpoint: CloudRunnerOptions.awsCloudWatchLogsEndpoint, + awsS3Endpoint: CloudRunnerOptions.awsS3Endpoint, gitSha: Input.gitSha, logId: customAlphabet(CloudRunnerConstants.alphabet, 9)(), buildGuid: CloudRunnerBuildGuid.generateGuid(Input.runNumber, Input.targetPlatform), diff --git a/src/model/cloud-runner/options/cloud-runner-options.ts b/src/model/cloud-runner/options/cloud-runner-options.ts index 814d104c..9bd75e29 100644 --- a/src/model/cloud-runner/options/cloud-runner-options.ts +++ b/src/model/cloud-runner/options/cloud-runner-options.ts @@ -195,6 +195,30 @@ class CloudRunnerOptions { return CloudRunnerOptions.getInput('awsStackName') || 'game-ci'; } + static get awsEndpoint(): string | undefined { + return CloudRunnerOptions.getInput('awsEndpoint'); + } + + static get awsCloudFormationEndpoint(): string | undefined { + return CloudRunnerOptions.getInput('awsCloudFormationEndpoint') || CloudRunnerOptions.awsEndpoint; + } + + static get awsEcsEndpoint(): string | undefined { + return CloudRunnerOptions.getInput('awsEcsEndpoint') || CloudRunnerOptions.awsEndpoint; + } + + static get awsKinesisEndpoint(): string | undefined { + return CloudRunnerOptions.getInput('awsKinesisEndpoint') || CloudRunnerOptions.awsEndpoint; + } + + static get awsCloudWatchLogsEndpoint(): string | undefined { + return CloudRunnerOptions.getInput('awsCloudWatchLogsEndpoint') || CloudRunnerOptions.awsEndpoint; + } + + static get awsS3Endpoint(): string | undefined { + return CloudRunnerOptions.getInput('awsS3Endpoint') || CloudRunnerOptions.awsEndpoint; + } + // ### ### ### // K8s // ### ### ### diff --git a/src/model/cloud-runner/providers/aws/aws-client-factory.ts b/src/model/cloud-runner/providers/aws/aws-client-factory.ts new file mode 100644 index 00000000..7bde368d --- /dev/null +++ b/src/model/cloud-runner/providers/aws/aws-client-factory.ts @@ -0,0 +1,71 @@ +import { CloudFormation } from '@aws-sdk/client-cloudformation'; +import { ECS } from '@aws-sdk/client-ecs'; +import { Kinesis } from '@aws-sdk/client-kinesis'; +import { CloudWatchLogs } from '@aws-sdk/client-cloudwatch-logs'; +import { S3 } from '@aws-sdk/client-s3'; +import { Input } from '../../..'; +import CloudRunnerOptions from '../../options/cloud-runner-options'; + +export class AwsClientFactory { + private static cloudFormation: CloudFormation; + private static ecs: ECS; + private static kinesis: Kinesis; + private static cloudWatchLogs: CloudWatchLogs; + private static s3: S3; + + static getCloudFormation(): CloudFormation { + if (!this.cloudFormation) { + this.cloudFormation = new CloudFormation({ + region: Input.region, + endpoint: CloudRunnerOptions.awsCloudFormationEndpoint, + }); + } + + return this.cloudFormation; + } + + static getECS(): ECS { + if (!this.ecs) { + this.ecs = new ECS({ + region: Input.region, + endpoint: CloudRunnerOptions.awsEcsEndpoint, + }); + } + + return this.ecs; + } + + static getKinesis(): Kinesis { + if (!this.kinesis) { + this.kinesis = new Kinesis({ + region: Input.region, + endpoint: CloudRunnerOptions.awsKinesisEndpoint, + }); + } + + return this.kinesis; + } + + static getCloudWatchLogs(): CloudWatchLogs { + if (!this.cloudWatchLogs) { + this.cloudWatchLogs = new CloudWatchLogs({ + region: Input.region, + endpoint: CloudRunnerOptions.awsCloudWatchLogsEndpoint, + }); + } + + return this.cloudWatchLogs; + } + + static getS3(): S3 { + if (!this.s3) { + this.s3 = new S3({ + region: Input.region, + endpoint: CloudRunnerOptions.awsS3Endpoint, + forcePathStyle: true, + }); + } + + return this.s3; + } +} diff --git a/src/model/cloud-runner/providers/aws/aws-task-runner.ts b/src/model/cloud-runner/providers/aws/aws-task-runner.ts index afdbfbd8..37b18147 100644 --- a/src/model/cloud-runner/providers/aws/aws-task-runner.ts +++ b/src/model/cloud-runner/providers/aws/aws-task-runner.ts @@ -1,5 +1,5 @@ -import { DescribeTasksCommand, ECS, RunTaskCommand, waitUntilTasksRunning } from '@aws-sdk/client-ecs'; -import { DescribeStreamCommand, GetRecordsCommand, GetShardIteratorCommand, Kinesis } from '@aws-sdk/client-kinesis'; +import { DescribeTasksCommand, RunTaskCommand, waitUntilTasksRunning } from '@aws-sdk/client-ecs'; +import { DescribeStreamCommand, GetRecordsCommand, GetShardIteratorCommand } from '@aws-sdk/client-kinesis'; import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable'; import * as core from '@actions/core'; import CloudRunnerAWSTaskDef from './cloud-runner-aws-task-def'; @@ -11,10 +11,9 @@ import { CommandHookService } from '../../services/hooks/command-hook-service'; import { FollowLogStreamService } from '../../services/core/follow-log-stream-service'; import CloudRunnerOptions from '../../options/cloud-runner-options'; import GitHub from '../../../github'; +import { AwsClientFactory } from './aws-client-factory'; class AWSTaskRunner { - public static ECS: ECS; - public static Kinesis: Kinesis; private static readonly encodedUnderscore = `$252F`; static async runTask( taskDef: CloudRunnerAWSTaskDef, @@ -61,7 +60,7 @@ class AWSTaskRunner { throw new Error(`Container Overrides length must be at most 8192`); } - const task = await AWSTaskRunner.ECS.send(new RunTaskCommand(runParameters as any)); + const task = await AwsClientFactory.getECS().send(new RunTaskCommand(runParameters as any)); const taskArn = task.tasks?.[0].taskArn || ''; CloudRunnerLogger.log('Cloud runner job is starting'); await AWSTaskRunner.waitUntilTaskRunning(taskArn, cluster); @@ -115,7 +114,7 @@ class AWSTaskRunner { try { await waitUntilTasksRunning( { - client: AWSTaskRunner.ECS, + client: AwsClientFactory.getECS(), maxWaitTime: 120, }, { tasks: [taskArn], cluster }, @@ -136,7 +135,7 @@ class AWSTaskRunner { let delayMs = 1000; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - const tasks = await AWSTaskRunner.ECS.send( + const tasks = await AwsClientFactory.getECS().send( new DescribeTasksCommand({ cluster: clusterName, tasks: [taskArn] }), ); if (tasks.tasks?.[0]) { @@ -193,12 +192,13 @@ class AWSTaskRunner { ) { let records: any; try { - records = await AWSTaskRunner.Kinesis.send(new GetRecordsCommand({ ShardIterator: iterator })); + records = await AwsClientFactory.getKinesis().send(new GetRecordsCommand({ ShardIterator: iterator })); } catch (error: any) { const isThrottle = error?.name === 'ThrottlingException' || /rate exceeded/i.test(String(error?.message)); if (isThrottle) { CloudRunnerLogger.log(`AWS throttled GetRecords, backing off 1000ms`); await new Promise((r) => setTimeout(r, 1000)); + return { iterator, shouldReadLogs, output, shouldCleanup }; } throw error; @@ -263,13 +263,13 @@ class AWSTaskRunner { } private static async getLogStream(kinesisStreamName: string) { - return await AWSTaskRunner.Kinesis.send(new DescribeStreamCommand({ StreamName: kinesisStreamName })); + return await AwsClientFactory.getKinesis().send(new DescribeStreamCommand({ StreamName: kinesisStreamName })); } private static async getLogIterator(stream: any) { return ( ( - await AWSTaskRunner.Kinesis.send( + await AwsClientFactory.getKinesis().send( new GetShardIteratorCommand({ ShardIteratorType: 'TRIM_HORIZON', StreamName: stream.StreamDescription?.StreamName ?? '', diff --git a/src/model/cloud-runner/providers/aws/index.ts b/src/model/cloud-runner/providers/aws/index.ts index 2486d92e..d57febdd 100644 --- a/src/model/cloud-runner/providers/aws/index.ts +++ b/src/model/cloud-runner/providers/aws/index.ts @@ -1,6 +1,4 @@ import { CloudFormation, DeleteStackCommand, waitUntilStackDeleteComplete } from '@aws-sdk/client-cloudformation'; -import { ECS as ECSClient } from '@aws-sdk/client-ecs'; -import { Kinesis } from '@aws-sdk/client-kinesis'; import CloudRunnerSecret from '../../options/cloud-runner-secret'; import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable'; import CloudRunnerAWSTaskDef from './cloud-runner-aws-task-def'; @@ -16,6 +14,7 @@ import { ProviderResource } from '../provider-resource'; import { ProviderWorkflow } from '../provider-workflow'; import { TaskService } from './services/task-service'; import CloudRunnerOptions from '../../options/cloud-runner-options'; +import { AwsClientFactory } from './aws-client-factory'; class AWSBuildEnvironment implements ProviderInterface { private baseStackName: string; @@ -77,7 +76,7 @@ class AWSBuildEnvironment implements ProviderInterface { defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[], ) { process.env.AWS_REGION = Input.region; - const CF = new CloudFormation({ region: Input.region }); + const CF = AwsClientFactory.getCloudFormation(); await new AwsBaseStack(this.baseStackName).setupBaseStack(CF); } @@ -91,10 +90,9 @@ class AWSBuildEnvironment implements ProviderInterface { secrets: CloudRunnerSecret[], ): Promise { process.env.AWS_REGION = Input.region; - const ECS = new ECSClient({ region: Input.region }); - const CF = new CloudFormation({ region: Input.region }); - AwsTaskRunner.ECS = ECS; - AwsTaskRunner.Kinesis = new Kinesis({ region: Input.region }); + AwsClientFactory.getECS(); + const CF = AwsClientFactory.getCloudFormation(); + AwsClientFactory.getKinesis(); CloudRunnerLogger.log(`AWS Region: ${CF.config.region}`); const entrypoint = ['/bin/sh']; const startTimeMs = Date.now(); diff --git a/src/model/cloud-runner/providers/aws/services/garbage-collection-service.ts b/src/model/cloud-runner/providers/aws/services/garbage-collection-service.ts index ccdddf7a..a92c2cd0 100644 --- a/src/model/cloud-runner/providers/aws/services/garbage-collection-service.ts +++ b/src/model/cloud-runner/providers/aws/services/garbage-collection-service.ts @@ -1,14 +1,10 @@ -import { - CloudFormation, - DeleteStackCommand, - DeleteStackCommandInput, - DescribeStackResourcesCommand, -} from '@aws-sdk/client-cloudformation'; -import { CloudWatchLogs, DeleteLogGroupCommand } from '@aws-sdk/client-cloudwatch-logs'; -import { ECS, StopTaskCommand } from '@aws-sdk/client-ecs'; +import { DeleteStackCommand, DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation'; +import { DeleteLogGroupCommand } from '@aws-sdk/client-cloudwatch-logs'; +import { StopTaskCommand } from '@aws-sdk/client-ecs'; import Input from '../../../../input'; import CloudRunnerLogger from '../../../services/core/cloud-runner-logger'; import { TaskService } from './task-service'; +import { AwsClientFactory } from '../aws-client-factory'; export class GarbageCollectionService { static isOlderThan1day(date: Date) { @@ -19,9 +15,9 @@ export class GarbageCollectionService { public static async cleanup(deleteResources = false, OneDayOlderOnly: boolean = false) { process.env.AWS_REGION = Input.region; - const CF = new CloudFormation({ region: Input.region }); - const ecs = new ECS({ region: Input.region }); - const cwl = new CloudWatchLogs({ region: Input.region }); + const CF = AwsClientFactory.getCloudFormation(); + const ecs = AwsClientFactory.getECS(); + const cwl = AwsClientFactory.getCloudWatchLogs(); const taskDefinitionsInUse = new Array(); const tasks = await TaskService.getTasks(); @@ -57,8 +53,7 @@ export class GarbageCollectionService { } CloudRunnerLogger.log(`Deleting ${element.StackName}`); - const deleteStackInput: DeleteStackCommandInput = { StackName: element.StackName }; - await CF.send(new DeleteStackCommand(deleteStackInput)); + await CF.send(new DeleteStackCommand({ StackName: element.StackName })); } } const logGroups = await TaskService.getLogGroups(); diff --git a/src/model/cloud-runner/providers/aws/services/task-service.ts b/src/model/cloud-runner/providers/aws/services/task-service.ts index 039969bb..f4df3323 100644 --- a/src/model/cloud-runner/providers/aws/services/task-service.ts +++ b/src/model/cloud-runner/providers/aws/services/task-service.ts @@ -1,31 +1,17 @@ import { - CloudFormation, DescribeStackResourcesCommand, DescribeStacksCommand, ListStacksCommand, - StackSummary, } from '@aws-sdk/client-cloudformation'; -import { - CloudWatchLogs, - DescribeLogGroupsCommand, - DescribeLogGroupsCommandInput, - LogGroup, -} from '@aws-sdk/client-cloudwatch-logs'; -import { - DescribeTasksCommand, - DescribeTasksCommandInput, - ECS, - ListClustersCommand, - ListTasksCommand, - ListTasksCommandInput, - Task, -} from '@aws-sdk/client-ecs'; -import { ListObjectsCommand, ListObjectsCommandInput, S3 } from '@aws-sdk/client-s3'; +import { DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs'; +import { DescribeTasksCommand, ListClustersCommand, ListTasksCommand } from '@aws-sdk/client-ecs'; +import { ListObjectsCommand } from '@aws-sdk/client-s3'; import Input from '../../../../input'; import CloudRunnerLogger from '../../../services/core/cloud-runner-logger'; import { BaseStackFormation } from '../cloud-formations/base-stack-formation'; import AwsTaskRunner from '../aws-task-runner'; import CloudRunner from '../../../cloud-runner'; +import { AwsClientFactory } from '../aws-client-factory'; export class TaskService { static async watch() { @@ -39,11 +25,11 @@ export class TaskService { return output; } public static async getCloudFormationJobStacks() { - const result: StackSummary[] = []; + const result: any[] = []; CloudRunnerLogger.log(``); CloudRunnerLogger.log(`List Cloud Formation Stacks`); process.env.AWS_REGION = Input.region; - const CF = new CloudFormation({ region: Input.region }); + const CF = AwsClientFactory.getCloudFormation(); const stacks = (await CF.send(new ListStacksCommand({}))).StackSummaries?.filter( (_x) => @@ -91,21 +77,20 @@ export class TaskService { return result; } public static async getTasks() { - const result: { taskElement: Task; element: string }[] = []; + const result: { taskElement: any; element: string }[] = []; CloudRunnerLogger.log(``); CloudRunnerLogger.log(`List Tasks`); process.env.AWS_REGION = Input.region; - const ecs = new ECS({ region: Input.region }); + const ecs = AwsClientFactory.getECS(); const clusters = (await ecs.send(new ListClustersCommand({}))).clusterArns || []; CloudRunnerLogger.log(`Task Clusters ${clusters.length}`); for (const element of clusters) { - const input: ListTasksCommandInput = { + const input = { cluster: element, }; - const list = (await ecs.send(new ListTasksCommand(input))).taskArns || []; if (list.length > 0) { - const describeInput: DescribeTasksCommandInput = { tasks: list, cluster: element }; + const describeInput = { tasks: list, cluster: element }; const describeList = (await ecs.send(new DescribeTasksCommand(describeInput))).tasks || []; if (describeList.length === 0) { CloudRunnerLogger.log(`No Tasks`); @@ -132,7 +117,7 @@ export class TaskService { } public static async awsDescribeJob(job: string) { process.env.AWS_REGION = Input.region; - const CF = new CloudFormation({ region: Input.region }); + const CF = AwsClientFactory.getCloudFormation(); try { const stack = (await CF.send(new ListStacksCommand({}))).StackSummaries?.find((_x) => _x.StackName === job) || undefined; @@ -163,10 +148,10 @@ export class TaskService { } } public static async getLogGroups() { - const result: Array = []; + const result: any[] = []; process.env.AWS_REGION = Input.region; - const ecs = new CloudWatchLogs(); - let logStreamInput: DescribeLogGroupsCommandInput = { + const ecs = AwsClientFactory.getCloudWatchLogs(); + let logStreamInput: any = { /* logGroupNamePrefix: 'game-ci' */ }; let logGroupsDescribe = await ecs.send(new DescribeLogGroupsCommand(logStreamInput)); @@ -197,8 +182,8 @@ export class TaskService { } public static async getLocks() { process.env.AWS_REGION = Input.region; - const s3 = new S3({ region: Input.region }); - const listRequest: ListObjectsCommandInput = { + const s3 = AwsClientFactory.getS3(); + const listRequest = { Bucket: CloudRunner.buildParameters.awsStackName, };