mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-05-31 22:06:16 -07:00
Compare commits
2 Commits
feature/cl
...
MichaelBuh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be7fee4d7c | ||
|
|
5eccc32436 |
@@ -14,8 +14,7 @@
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true,
|
||||
"jest/globals": true,
|
||||
"es2020": true
|
||||
"jest/globals": true
|
||||
},
|
||||
"rules": {
|
||||
// Error out for code formatting errors
|
||||
@@ -78,13 +77,5 @@
|
||||
"unicorn/prefer-spread": "off",
|
||||
// Temp disable to prevent mixing changes with other PRs
|
||||
"i18n-text/no-en": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["jest.setup.js"],
|
||||
"rules": {
|
||||
"import/no-commonjs": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
12
.github/workflows/build-tests-mac.yml
vendored
12
.github/workflows/build-tests-mac.yml
vendored
@@ -18,19 +18,12 @@ jobs:
|
||||
projectPath:
|
||||
- test-project
|
||||
unityVersion:
|
||||
- 2021.3.45f1
|
||||
- 2021.3.32f1
|
||||
- 2022.3.13f1
|
||||
- 2023.2.2f1
|
||||
targetPlatform:
|
||||
- StandaloneOSX # Build a MacOS executable
|
||||
- iOS # Build an iOS executable
|
||||
include:
|
||||
# Additionally test enableGpu build for a standalone windows target
|
||||
- unityVersion: 6000.0.36f1
|
||||
targetPlatform: StandaloneOSX
|
||||
- unityVersion: 6000.0.36f1
|
||||
targetPlatform: StandaloneOSX
|
||||
buildProfile: 'Assets/Settings/Build Profiles/Sample macOS Build Profile.asset'
|
||||
|
||||
steps:
|
||||
###########################
|
||||
@@ -72,7 +65,6 @@ jobs:
|
||||
projectPath: ${{ matrix.projectPath }}
|
||||
unityVersion: ${{ matrix.unityVersion }}
|
||||
targetPlatform: ${{ matrix.targetPlatform }}
|
||||
buildProfile: ${{ matrix.buildProfile }}
|
||||
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue
|
||||
# We use dirty build because we are replacing the default project settings file above
|
||||
allowDirtyBuild: true
|
||||
@@ -82,6 +74,6 @@ jobs:
|
||||
###########################
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Build ${{ matrix.targetPlatform }} on MacOS (${{ matrix.unityVersion }})${{ matrix.buildProfile && ' With Build Profile' || '' }}
|
||||
name: Build ${{ matrix.targetPlatform }} on MacOS (${{ matrix.unityVersion }})
|
||||
path: build
|
||||
retention-days: 14
|
||||
|
||||
21
.github/workflows/build-tests-windows.yml
vendored
21
.github/workflows/build-tests-windows.yml
vendored
@@ -26,20 +26,7 @@ jobs:
|
||||
- StandaloneWindows64 # Build a Windows 64-bit standalone.
|
||||
- WSAPlayer # Build a UWP App
|
||||
- tvOS # Build an Apple TV XCode project
|
||||
enableGpu:
|
||||
- false
|
||||
include:
|
||||
# Additionally test enableGpu build for a standalone windows target
|
||||
- projectPath: test-project
|
||||
unityVersion: 2023.2.2f1
|
||||
targetPlatform: StandaloneWindows64
|
||||
enableGpu: true
|
||||
- unityVersion: 6000.0.36f1
|
||||
targetPlatform: StandaloneWindows64
|
||||
- unityVersion: 6000.0.36f1
|
||||
targetPlatform: StandaloneWindows64
|
||||
buildProfile: 'Assets/Settings/Build Profiles/Sample Windows Build Profile.asset'
|
||||
|
||||
|
||||
steps:
|
||||
###########################
|
||||
# Checkout #
|
||||
@@ -84,8 +71,6 @@ jobs:
|
||||
projectPath: ${{ matrix.projectPath }}
|
||||
unityVersion: ${{ matrix.unityVersion }}
|
||||
targetPlatform: ${{ matrix.targetPlatform }}
|
||||
buildProfile: ${{ matrix.buildProfile }}
|
||||
enableGpu: ${{ matrix.enableGpu }}
|
||||
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue
|
||||
allowDirtyBuild: true
|
||||
# We use dirty build because we are replacing the default project settings file above
|
||||
@@ -111,7 +96,6 @@ jobs:
|
||||
projectPath: ${{ matrix.projectPath }}
|
||||
unityVersion: ${{ matrix.unityVersion }}
|
||||
targetPlatform: ${{ matrix.targetPlatform }}
|
||||
enableGpu: ${{ matrix.enableGpu }}
|
||||
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue
|
||||
allowDirtyBuild: true
|
||||
# We use dirty build because we are replacing the default project settings file above
|
||||
@@ -136,7 +120,6 @@ jobs:
|
||||
projectPath: ${{ matrix.projectPath }}
|
||||
unityVersion: ${{ matrix.unityVersion }}
|
||||
targetPlatform: ${{ matrix.targetPlatform }}
|
||||
enableGpu: ${{ matrix.enableGpu }}
|
||||
customParameters: -profile SomeProfile -someBoolean -someValue exampleValue
|
||||
allowDirtyBuild: true
|
||||
# We use dirty build because we are replacing the default project settings file above
|
||||
@@ -146,6 +129,6 @@ jobs:
|
||||
###########################
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Build ${{ matrix.targetPlatform }} on Windows (${{ matrix.unityVersion }})${{ matrix.enableGpu && ' With GPU' || '' }}${{ matrix.buildProfile && ' With Build Profile' || '' }}
|
||||
name: Build ${{ matrix.targetPlatform }} on Windows (${{ matrix.unityVersion }})
|
||||
path: build
|
||||
retention-days: 14
|
||||
|
||||
37
.github/workflows/cleanup.yml
vendored
Normal file
37
.github/workflows/cleanup.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Cleanup (cron)
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 10 * * SUN' # every sunday at 10:30
|
||||
|
||||
jobs:
|
||||
deleteArtifacts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delete old artifacts
|
||||
uses: kolpav/purge-artifacts-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
expire-in: 21 days
|
||||
cleanupCloudRunner:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
if: github.event.event_type != 'pull_request_target'
|
||||
with:
|
||||
lfs: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
- run: yarn
|
||||
- run: yarn run cli --help
|
||||
env:
|
||||
AWS_REGION: eu-west-2
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: eu-west-2
|
||||
- run: yarn run cli -m list-resources
|
||||
env:
|
||||
AWS_REGION: eu-west-2
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: eu-west-2
|
||||
@@ -18,16 +18,15 @@ env:
|
||||
GKE_CLUSTER: 'game-ci-github-pipelines'
|
||||
GCP_LOGGING: true
|
||||
GCP_PROJECT: unitykubernetesbuilder
|
||||
GCP_LOG_FILE: ${{ github.workspace }}/orchestrator-logs.txt
|
||||
# Commented out: Using LocalStack tests instead of real AWS
|
||||
# AWS_REGION: eu-west-2
|
||||
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
# AWS_DEFAULT_REGION: eu-west-2
|
||||
# AWS_STACK_NAME: game-ci-github-pipelines
|
||||
ORCHESTRATOR_BRANCH: ${{ github.ref }}
|
||||
ORCHESTRATOR_DEBUG: true
|
||||
ORCHESTRATOR_DEBUG_TREE: true
|
||||
GCP_LOG_FILE: ${{ github.workspace }}/cloud-runner-logs.txt
|
||||
AWS_REGION: eu-west-2
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: eu-west-2
|
||||
AWS_STACK_NAME: game-ci-github-pipelines
|
||||
CLOUD_RUNNER_BRANCH: ${{ github.ref }}
|
||||
CLOUD_RUNNER_DEBUG: true
|
||||
CLOUD_RUNNER_DEBUG_TREE: true
|
||||
DEBUG: true
|
||||
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
||||
PROJECT_PATH: test-project
|
||||
@@ -47,14 +46,13 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GIT_PRIVATE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TARGET_PLATFORM: StandaloneWindows64
|
||||
orchestratorTests: true
|
||||
cloudRunnerTests: true
|
||||
versioning: None
|
||||
ORCHESTRATOR_CLUSTER: local-docker
|
||||
# Commented out: Using LocalStack tests instead of real AWS
|
||||
# AWS_STACK_NAME: game-ci-github-pipelines
|
||||
CLOUD_RUNNER_CLUSTER: local-docker
|
||||
AWS_STACK_NAME: game-ci-github-pipelines
|
||||
CHECKS_UPDATE: ${{ github.event.inputs.checksObject }}
|
||||
run: |
|
||||
git clone -b main https://github.com/game-ci/unity-builder
|
||||
git clone -b cloud-runner-develop https://github.com/game-ci/unity-builder
|
||||
cd unity-builder
|
||||
yarn
|
||||
ls
|
||||
209
.github/workflows/cloud-runner-ci-pipeline.yml
vendored
Normal file
209
.github/workflows/cloud-runner-ci-pipeline.yml
vendored
Normal file
@@ -0,0 +1,209 @@
|
||||
name: Cloud Runner CI Pipeline
|
||||
|
||||
on:
|
||||
push: { branches: [cloud-runner-develop, cloud-runner-preview, main] }
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
checks: write
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
env:
|
||||
GKE_ZONE: 'us-central1'
|
||||
GKE_REGION: 'us-central1'
|
||||
GKE_PROJECT: 'unitykubernetesbuilder'
|
||||
GKE_CLUSTER: 'game-ci-github-pipelines'
|
||||
GCP_LOGGING: true
|
||||
GCP_PROJECT: unitykubernetesbuilder
|
||||
GCP_LOG_FILE: ${{ github.workspace }}/cloud-runner-logs.txt
|
||||
AWS_REGION: eu-west-2
|
||||
AWS_DEFAULT_REGION: eu-west-2
|
||||
AWS_STACK_NAME: game-ci-team-pipelines
|
||||
CLOUD_RUNNER_BRANCH: ${{ github.ref }}
|
||||
DEBUG: true
|
||||
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
||||
PROJECT_PATH: test-project
|
||||
UNITY_VERSION: 2019.3.15f1
|
||||
USE_IL2CPP: false
|
||||
USE_GKE_GCLOUD_AUTH_PLUGIN: true
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Tests
|
||||
if: github.event.event_type != 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
- name: Checkout (default)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
- 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
|
||||
- run: yarn
|
||||
- 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: local-docker
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
k8sTests:
|
||||
name: K8s Tests
|
||||
if: github.event.event_type != 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
test:
|
||||
# - 'cloud-runner-async-workflow'
|
||||
- 'cloud-runner-end2end-locking'
|
||||
- 'cloud-runner-end2end-caching'
|
||||
- 'cloud-runner-end2end-retaining'
|
||||
- 'cloud-runner-kubernetes'
|
||||
- 'cloud-runner-environment'
|
||||
- 'cloud-runner-github-checks'
|
||||
steps:
|
||||
- name: Checkout (default)
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
lfs: false
|
||||
- run: yarn
|
||||
- name: actions-k3s
|
||||
uses: debianmaster/actions-k3s@v1.0.5
|
||||
with:
|
||||
version: 'latest'
|
||||
- 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: k8s
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
awsTests:
|
||||
name: AWS Tests
|
||||
if: github.event.event_type != 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
test:
|
||||
- 'cloud-runner-end2end-locking'
|
||||
- 'cloud-runner-end2end-caching'
|
||||
- 'cloud-runner-end2end-retaining'
|
||||
- 'cloud-runner-environment'
|
||||
- 'cloud-runner-s3-steps'
|
||||
steps:
|
||||
- name: Checkout (default)
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
lfs: false
|
||||
- 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
|
||||
- run: yarn
|
||||
- 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: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
buildTargetTests:
|
||||
name: Local Build Target Tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
providerStrategy:
|
||||
#- aws
|
||||
- local-docker
|
||||
#- k8s
|
||||
targetPlatform:
|
||||
- StandaloneOSX # Build a macOS standalone (Intel 64-bit).
|
||||
- StandaloneWindows64 # Build a Windows 64-bit standalone.
|
||||
- StandaloneLinux64 # Build a Linux 64-bit standalone.
|
||||
- WebGL # WebGL.
|
||||
- iOS # Build an iOS player.
|
||||
# - Android # Build an Android .apk.
|
||||
steps:
|
||||
- name: Checkout (default)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
- run: yarn
|
||||
- uses: ./
|
||||
id: unity-build
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
||||
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
cloudRunnerTests: true
|
||||
versioning: None
|
||||
targetPlatform: ${{ matrix.targetPlatform }}
|
||||
providerStrategy: ${{ matrix.providerStrategy }}
|
||||
- run: |
|
||||
cp ./cloud-runner-cache/cache/${{ steps.unity-build.outputs.CACHE_KEY }}/build/${{ steps.unity-build.outputs.BUILD_ARTIFACT }} ${{ steps.unity-build.outputs.BUILD_ARTIFACT }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.providerStrategy }} Build (${{ matrix.targetPlatform }})
|
||||
path: ${{ steps.unity-build.outputs.BUILD_ARTIFACT }}
|
||||
retention-days: 14
|
||||
14
.github/workflows/integrity-check.yml
vendored
14
.github/workflows/integrity-check.yml
vendored
@@ -4,11 +4,6 @@ on:
|
||||
push: { branches: [main] }
|
||||
pull_request: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
statuses: write
|
||||
|
||||
env:
|
||||
CODECOV_TOKEN: '2f2eb890-30e2-4724-83eb-7633832cf0de'
|
||||
|
||||
@@ -27,12 +22,7 @@ jobs:
|
||||
node-version: '18'
|
||||
- run: yarn
|
||||
- run: yarn lint
|
||||
- run: yarn test:ci --coverage
|
||||
- run: yarn test --coverage
|
||||
- run: bash <(curl -s https://codecov.io/bash)
|
||||
- run: yarn build || { echo "build command should always succeed" ; exit 61; }
|
||||
# - run: yarn build --quiet && git diff --quiet dist || { echo "dist should be auto generated" ; git diff dist ; exit 62; }
|
||||
|
||||
orchestrator:
|
||||
name: Orchestrator Integrity
|
||||
uses: ./.github/workflows/orchestrator-integrity.yml
|
||||
secrets: inherit
|
||||
# - run: yarn build --quiet && git diff --quiet dist || { echo "dist should be auto generated" ; git diff dist ; exit 62; }
|
||||
|
||||
1109
.github/workflows/orchestrator-integrity.yml
vendored
1109
.github/workflows/orchestrator-integrity.yml
vendored
File diff suppressed because it is too large
Load Diff
170
.github/workflows/release-cli.yml
vendored
170
.github/workflows/release-cli.yml
vendored
@@ -1,170 +0,0 @@
|
||||
name: Release CLI
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build (e.g., v2.0.0). Uses latest release if empty.'
|
||||
required: false
|
||||
type: string
|
||||
publish-npm:
|
||||
description: 'Publish to npm'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.release.tag_name || inputs.tag || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-binaries:
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: linux-x64
|
||||
os: ubuntu-latest
|
||||
pkg-target: node20-linux-x64
|
||||
binary-name: game-ci-linux-x64
|
||||
- target: linux-arm64
|
||||
os: ubuntu-latest
|
||||
pkg-target: node20-linux-arm64
|
||||
binary-name: game-ci-linux-arm64
|
||||
- target: macos-x64
|
||||
os: macos-latest
|
||||
pkg-target: node20-macos-x64
|
||||
binary-name: game-ci-macos-x64
|
||||
- target: macos-arm64
|
||||
os: macos-latest
|
||||
pkg-target: node20-macos-arm64
|
||||
binary-name: game-ci-macos-arm64
|
||||
- target: windows-x64
|
||||
os: windows-latest
|
||||
pkg-target: node20-win-x64
|
||||
binary-name: game-ci-windows-x64.exe
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || inputs.tag || github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build TypeScript
|
||||
run: yarn build
|
||||
|
||||
- name: Verify CLI before packaging
|
||||
run: node lib/cli.js version
|
||||
|
||||
- name: Build standalone binary
|
||||
run: npx pkg lib/cli.js --target ${{ matrix.pkg-target }} --output ${{ matrix.binary-name }} --compress GZip
|
||||
|
||||
- name: Verify standalone binary (non-cross-compiled)
|
||||
if: |
|
||||
(matrix.target == 'linux-x64' && runner.os == 'Linux') ||
|
||||
(matrix.target == 'macos-arm64' && runner.os == 'macOS' && runner.arch == 'ARM64') ||
|
||||
(matrix.target == 'macos-x64' && runner.os == 'macOS' && runner.arch == 'X64') ||
|
||||
(matrix.target == 'windows-x64' && runner.os == 'Windows')
|
||||
run: ./${{ matrix.binary-name }} version
|
||||
shell: bash
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-${{ matrix.target }}
|
||||
path: ${{ matrix.binary-name }}
|
||||
retention-days: 5
|
||||
|
||||
create-checksums-and-upload:
|
||||
name: Checksums and release upload
|
||||
needs: build-binaries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: binaries
|
||||
pattern: binary-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List binaries
|
||||
run: ls -la binaries/
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
run: |
|
||||
cd binaries
|
||||
sha256sum game-ci-* > checksums.txt
|
||||
echo "=== checksums.txt ==="
|
||||
cat checksums.txt
|
||||
|
||||
- name: Determine release tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
|
||||
elif [ -n "${{ inputs.tag }}" ]; then
|
||||
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No release tag available. Skipping upload."
|
||||
echo "tag=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload binaries to release
|
||||
if: steps.tag.outputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
cd binaries
|
||||
for f in game-ci-* checksums.txt; do
|
||||
echo "Uploading $f..."
|
||||
gh release upload "${{ steps.tag.outputs.tag }}" "$f" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--clobber
|
||||
done
|
||||
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
needs: build-binaries
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event_name == 'release') || (github.event_name == 'workflow_dispatch' && inputs.publish-npm)
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || inputs.tag || github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
||||
- name: Run tests
|
||||
run: yarn test
|
||||
|
||||
- name: Verify CLI
|
||||
run: |
|
||||
node lib/cli.js version
|
||||
node lib/cli.js --help
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,5 +5,3 @@ lib/
|
||||
.vsconfig
|
||||
yarn-error.log
|
||||
.orig
|
||||
$LOG_FILE
|
||||
temp/
|
||||
|
||||
56
action.yml
56
action.yml
@@ -104,11 +104,11 @@ inputs:
|
||||
gitPrivateToken:
|
||||
required: false
|
||||
default: ''
|
||||
description: '[Orchestrator] Github private token to pull from github'
|
||||
description: '[CloudRunner] Github private token to pull from github'
|
||||
githubOwner:
|
||||
required: false
|
||||
default: ''
|
||||
description: '[Orchestrator] GitHub owner name or organization/team name'
|
||||
description: '[CloudRunner] GitHub owner name or organization/team name'
|
||||
runAsHostUser:
|
||||
required: false
|
||||
default: 'false'
|
||||
@@ -149,101 +149,97 @@ inputs:
|
||||
allowDirtyBuild:
|
||||
required: false
|
||||
default: ''
|
||||
description: '[Orchestrator] Allows the branch of the build to be dirty, and still generate the build.'
|
||||
description: '[CloudRunner] Allows the branch of the build to be dirty, and still generate the build.'
|
||||
postBuildSteps:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'[Orchestrator] run a post build job in yaml format with the keys image, secrets (name, value object array),
|
||||
'[CloudRunner] run a post build job in yaml format with the keys image, secrets (name, value object array),
|
||||
command string'
|
||||
preBuildSteps:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'[Orchestrator] Run a pre build job after the repository setup but before the build job (in yaml format with the
|
||||
'[CloudRunner] Run a pre build job after the repository setup but before the build job (in yaml format with the
|
||||
keys image, secrets (name, value object array), command line string)'
|
||||
containerHookFiles:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'[Orchestrator] Specify the names (by file name) of custom steps to run before or after orchestrator jobs, must
|
||||
'[CloudRunner] Specify the names (by file name) of custom steps to run before or after cloud runner jobs, must
|
||||
match a yaml step file inside your repo in the folder .game-ci/steps/'
|
||||
customHookFiles:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'[Orchestrator] Specify the names (by file name) of custom hooks to run before or after orchestrator jobs, must
|
||||
'[CloudRunner] Specify the names (by file name) of custom hooks to run before or after cloud runner jobs, must
|
||||
match a yaml step file inside your repo in the folder .game-ci/hooks/'
|
||||
customCommandHooks:
|
||||
required: false
|
||||
default: ''
|
||||
description: '[Orchestrator] Specify custom commands and trigger hooks (injects commands into jobs)'
|
||||
description: '[CloudRunner] Specify custom commands and trigger hooks (injects commands into jobs)'
|
||||
customJob:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with the
|
||||
'[CloudRunner] Run a custom job instead of the standard build automation for cloud runner (in yaml format with the
|
||||
keys image, secrets (name, value object array), command line string)'
|
||||
awsStackName:
|
||||
default: 'game-ci'
|
||||
required: false
|
||||
description: '[Orchestrator] The Cloud Formation stack name that must be setup before using this option.'
|
||||
description: '[CloudRunner] The Cloud Formation stack name that must be setup before using this option.'
|
||||
providerStrategy:
|
||||
default: 'local'
|
||||
required: false
|
||||
description:
|
||||
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
|
||||
'[CloudRunner] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
|
||||
be configured.'
|
||||
resourceTracking:
|
||||
default: 'false'
|
||||
required: false
|
||||
description: '[Orchestrator] Enable resource tracking logs for disk usage and allocation summaries.'
|
||||
containerCpu:
|
||||
default: ''
|
||||
required: false
|
||||
description: '[Orchestrator] Amount of CPU time to assign the remote build container'
|
||||
description: '[CloudRunner] Amount of CPU time to assign the remote build container'
|
||||
containerMemory:
|
||||
default: ''
|
||||
required: false
|
||||
description: '[Orchestrator] Amount of memory to assign the remote build container'
|
||||
description: '[CloudRunner] Amount of memory to assign the remote build container'
|
||||
readInputFromOverrideList:
|
||||
default: ''
|
||||
required: false
|
||||
description: '[Orchestrator] Comma separated list of input value names to read from "input override command"'
|
||||
description: '[CloudRunner] Comma separated list of input value names to read from "input override command"'
|
||||
readInputOverrideCommand:
|
||||
default: ''
|
||||
required: false
|
||||
description:
|
||||
'[Orchestrator] Extend game ci by specifying a command to execute to pull input from external source e.g cloud
|
||||
'[CloudRunner] Extend game ci by specifying a command to execute to pull input from external source e.g cloud
|
||||
provider secret managers'
|
||||
kubeConfig:
|
||||
default: ''
|
||||
required: false
|
||||
description:
|
||||
'[Orchestrator] Supply a base64 encoded kubernetes config to run builds on kubernetes and stream logs until
|
||||
'[CloudRunner] Supply a base64 encoded kubernetes config to run builds on kubernetes and stream logs until
|
||||
completion.'
|
||||
kubeVolume:
|
||||
default: ''
|
||||
required: false
|
||||
description: '[Orchestrator] Supply a Persistent Volume Claim name to use for the Unity build.'
|
||||
description: '[CloudRunner] Supply a Persistent Volume Claim name to use for the Unity build.'
|
||||
kubeStorageClass:
|
||||
default: ''
|
||||
required: false
|
||||
description:
|
||||
'[Orchestrator] Kubernetes storage class to use for orchestrator jobs, leave empty to install rook cluster.'
|
||||
'[CloudRunner] Kubernetes storage class to use for cloud runner jobs, leave empty to install rook cluster.'
|
||||
kubeVolumeSize:
|
||||
default: '5Gi'
|
||||
required: false
|
||||
description: '[Orchestrator] Amount of disc space to assign the Kubernetes Persistent Volume'
|
||||
description: '[CloudRunner] Amount of disc space to assign the Kubernetes Persistent Volume'
|
||||
cacheKey:
|
||||
default: ''
|
||||
required: false
|
||||
description: '[Orchestrator] Cache key to indicate bucket for cache'
|
||||
description: '[CloudRunner] Cache key to indicate bucket for cache'
|
||||
watchToEnd:
|
||||
default: 'true'
|
||||
required: false
|
||||
description:
|
||||
'[Orchestrator] Whether or not to watch the build to the end. Can be used for especially long running jobs e.g
|
||||
'[CloudRunner] Whether or not to watch the build to the end. Can be used for especially long running jobs e.g
|
||||
imports or self-hosted ephemeral runners.'
|
||||
cacheUnityInstallationOnMac:
|
||||
default: 'false'
|
||||
@@ -269,16 +265,6 @@ inputs:
|
||||
default: 'false'
|
||||
required: false
|
||||
description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.'
|
||||
cloneDepth:
|
||||
default: '50'
|
||||
required: false
|
||||
description: '[Orchestrator] Specifies the depth of the git clone for the repository. Use 0 for full clone.'
|
||||
orchestratorRepoName:
|
||||
default: 'game-ci/unity-builder'
|
||||
required: false
|
||||
description:
|
||||
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
|
||||
fixes.'
|
||||
|
||||
outputs:
|
||||
volume:
|
||||
|
||||
@@ -56,19 +56,14 @@ namespace UnityBuilderAction
|
||||
// of either `UnityEditor.BuildPlayerOptions` or `UnityEditor.BuildPlayerWithProfileOptions`
|
||||
dynamic buildPlayerOptions;
|
||||
|
||||
if (options.TryGetValue("activeBuildProfile", out var buildProfilePath)) {
|
||||
if (string.IsNullOrEmpty(buildProfilePath)) {
|
||||
throw new Exception("`-activeBuildProfile` is set but with an empty value; this shouldn't happen");
|
||||
}
|
||||
if (options["customBuildProfile"] != "") {
|
||||
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
// Load build profile from Assets folder
|
||||
var buildProfile = AssetDatabase.LoadAssetAtPath<BuildProfile>(buildProfilePath)
|
||||
?? throw new Exception("Build profile file not found at path: " + buildProfilePath);
|
||||
BuildProfile buildProfile = AssetDatabase.LoadAssetAtPath<BuildProfile>(options["customBuildProfile"]);
|
||||
|
||||
// no need to set active profile, as already set by `-activeBuildProfile` CLI argument
|
||||
// BuildProfile.SetActiveBuildProfile(buildProfile);
|
||||
Debug.Log($"build profile: {buildProfile.name}");
|
||||
// Set it as active
|
||||
BuildProfile.SetActiveBuildProfile(buildProfile);
|
||||
|
||||
// Define BuildPlayerWithProfileOptions
|
||||
buildPlayerOptions = new BuildPlayerWithProfileOptions {
|
||||
@@ -76,16 +71,12 @@ namespace UnityBuilderAction
|
||||
locationPathName = options["customBuildPath"],
|
||||
options = buildOptions,
|
||||
};
|
||||
#else // UNITY_6000_0_OR_NEWER
|
||||
#else
|
||||
throw new Exception("Build profiles are not supported by this version of Unity (" + Application.unityVersion +")");
|
||||
#endif // UNITY_6000_0_OR_NEWER
|
||||
#endif
|
||||
|
||||
} else {
|
||||
|
||||
#if BUILD_PROFILE_LOADED
|
||||
throw new Exception("Build profile's define symbol present; shouldn't happen");
|
||||
#endif // BUILD_PROFILE_LOADED
|
||||
|
||||
// Gather values from project
|
||||
var scenes = EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(s => s.path).ToArray();
|
||||
|
||||
|
||||
@@ -74,20 +74,7 @@ namespace UnityBuilderAction.Input
|
||||
string symbolType;
|
||||
if (options.TryGetValue("androidSymbolType", out symbolType) && !string.IsNullOrEmpty(symbolType))
|
||||
{
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
switch (symbolType)
|
||||
{
|
||||
case "public":
|
||||
SetDebugSymbols("SymbolTable");
|
||||
break;
|
||||
case "debugging":
|
||||
SetDebugSymbols("Full");
|
||||
break;
|
||||
case "none":
|
||||
SetDebugSymbols("None");
|
||||
break;
|
||||
}
|
||||
#elif UNITY_2021_1_OR_NEWER
|
||||
#if UNITY_2021_1_OR_NEWER
|
||||
switch (symbolType)
|
||||
{
|
||||
case "public":
|
||||
@@ -114,37 +101,5 @@ namespace UnityBuilderAction.Input
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
private static void SetDebugSymbols(string enumValueName)
|
||||
{
|
||||
// UnityEditor.Android.UserBuildSettings and Unity.Android.Types.DebugSymbolLevel are part of the Unity Android module.
|
||||
// Reflection is used here to ensure the code works even if the module is not installed.
|
||||
|
||||
var debugSymbolsType = Type.GetType("UnityEditor.Android.UserBuildSettings+DebugSymbols, UnityEditor.Android.Extensions");
|
||||
if (debugSymbolsType == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var levelProp = debugSymbolsType.GetProperty("level", BindingFlags.Static | BindingFlags.Public);
|
||||
if (levelProp == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enumType = Type.GetType("Unity.Android.Types.DebugSymbolLevel, Unity.Android.Types");
|
||||
if (enumType == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse(enumType, enumValueName, false , out var enumValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
levelProp.SetValue(null, enumValue);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,19 +21,6 @@ namespace UnityBuilderAction.Input
|
||||
EditorApplication.Exit(110);
|
||||
}
|
||||
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
var buildProfileSupport = true;
|
||||
#else
|
||||
var buildProfileSupport = false;
|
||||
#endif // UNITY_6000_0_OR_NEWER
|
||||
|
||||
string buildProfile;
|
||||
if (buildProfileSupport && validatedOptions.TryGetValue("activeBuildProfile", out buildProfile)) {
|
||||
if (validatedOptions.ContainsKey("buildTarget")) {
|
||||
Console.WriteLine("Extra argument -buildTarget");
|
||||
EditorApplication.Exit(122);
|
||||
}
|
||||
} else {
|
||||
string buildTarget;
|
||||
if (!validatedOptions.TryGetValue("buildTarget", out buildTarget)) {
|
||||
Console.WriteLine("Missing argument -buildTarget");
|
||||
@@ -44,7 +31,6 @@ namespace UnityBuilderAction.Input
|
||||
Console.WriteLine(buildTarget + " is not a defined " + typeof(BuildTarget).Name);
|
||||
EditorApplication.Exit(121);
|
||||
}
|
||||
}
|
||||
|
||||
string customBuildPath;
|
||||
if (!validatedOptions.TryGetValue("customBuildPath", out customBuildPath)) {
|
||||
|
||||
168264
dist/index.js
generated
vendored
168264
dist/index.js
generated
vendored
File diff suppressed because one or more lines are too long
2
dist/index.js.map
generated
vendored
2
dist/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
15424
dist/licenses.txt
generated
vendored
15424
dist/licenses.txt
generated
vendored
File diff suppressed because it is too large
Load Diff
74
dist/platforms/mac/steps/activate.sh
vendored
74
dist/platforms/mac/steps/activate.sh
vendored
@@ -4,69 +4,21 @@
|
||||
echo "Changing to \"$ACTIVATE_LICENSE_PATH\" directory."
|
||||
pushd "$ACTIVATE_LICENSE_PATH"
|
||||
|
||||
if [[ -n "$UNITY_SERIAL" && -n "$UNITY_EMAIL" && -n "$UNITY_PASSWORD" ]]; then
|
||||
#
|
||||
# SERIAL LICENSE MODE
|
||||
#
|
||||
# This will activate unity, using the serial activation process.
|
||||
#
|
||||
echo "Requesting activation"
|
||||
|
||||
echo "Requesting activation"
|
||||
# Activate license
|
||||
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity \
|
||||
-logFile - \
|
||||
-batchmode \
|
||||
-nographics \
|
||||
-quit \
|
||||
-serial "$UNITY_SERIAL" \
|
||||
-username "$UNITY_EMAIL" \
|
||||
-password "$UNITY_PASSWORD" \
|
||||
-projectPath "$ACTIVATE_LICENSE_PATH"
|
||||
|
||||
# Activate license
|
||||
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity \
|
||||
-logFile - \
|
||||
-batchmode \
|
||||
-nographics \
|
||||
-quit \
|
||||
-serial "$UNITY_SERIAL" \
|
||||
-username "$UNITY_EMAIL" \
|
||||
-password "$UNITY_PASSWORD" \
|
||||
-projectPath "$ACTIVATE_LICENSE_PATH"
|
||||
|
||||
# Store the exit code from the verify command
|
||||
UNITY_EXIT_CODE=$?
|
||||
|
||||
elif [[ -n "$UNITY_LICENSING_SERVER" ]]; then
|
||||
#
|
||||
# Custom Unity License Server
|
||||
#
|
||||
echo "Adding licensing server config"
|
||||
mkdir -p "$UNITY_LICENSE_PATH/config/"
|
||||
cp "$ACTION_FOLDER/unity-config/services-config.json" "$UNITY_LICENSE_PATH/config/services-config.json"
|
||||
|
||||
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/Frameworks/UnityLicensingClient.app/Contents/MacOS/Unity.Licensing.Client \
|
||||
--acquire-floating > license.txt
|
||||
|
||||
# Store the exit code from the verify command
|
||||
UNITY_EXIT_CODE=$?
|
||||
|
||||
if [ $UNITY_EXIT_CODE -eq 0 ]; then
|
||||
PARSEDFILE=$(grep -oE '\"[^"]*\"' < license.txt | tr -d '"')
|
||||
export FLOATING_LICENSE
|
||||
FLOATING_LICENSE=$(sed -n 2p <<< "$PARSEDFILE")
|
||||
FLOATING_LICENSE_TIMEOUT=$(sed -n 4p <<< "$PARSEDFILE")
|
||||
|
||||
echo "Acquired floating license: \"$FLOATING_LICENSE\" with timeout $FLOATING_LICENSE_TIMEOUT"
|
||||
fi
|
||||
else
|
||||
#
|
||||
# NO LICENSE ACTIVATION STRATEGY MATCHED
|
||||
#
|
||||
# This will exit since no activation strategies could be matched.
|
||||
#
|
||||
echo "License activation strategy could not be determined."
|
||||
echo ""
|
||||
echo "Visit https://game.ci/docs/github/activation for more"
|
||||
echo "details on how to set up one of the possible activation strategies."
|
||||
|
||||
echo "::error ::No valid license activation strategy could be determined. Make sure to provide UNITY_EMAIL, UNITY_PASSWORD, and either a UNITY_SERIAL \
|
||||
or UNITY_LICENSE. Otherwise please use UNITY_LICENSING_SERVER. See more info at https://game.ci/docs/github/activation"
|
||||
|
||||
# Immediately exit as no UNITY_EXIT_CODE can be derived.
|
||||
exit 1;
|
||||
|
||||
fi
|
||||
# Store the exit code from the verify command
|
||||
UNITY_EXIT_CODE=$?
|
||||
|
||||
#
|
||||
# Display information about the result
|
||||
|
||||
5
dist/platforms/mac/steps/build.sh
vendored
5
dist/platforms/mac/steps/build.sh
vendored
@@ -149,13 +149,14 @@ echo ""
|
||||
$( [ "${MANUAL_EXIT}" == "true" ] || echo "-quit" ) \
|
||||
-batchmode \
|
||||
$( [ "${ENABLE_GPU}" == "true" ] || echo "-nographics" ) \
|
||||
-username "$UNITY_EMAIL" \
|
||||
-password "$UNITY_PASSWORD" \
|
||||
-customBuildName "$BUILD_NAME" \
|
||||
-projectPath "$UNITY_PROJECT_PATH" \
|
||||
$( [ -z "$BUILD_PROFILE" ] && echo "-buildTarget $BUILD_TARGET") \
|
||||
-buildTarget "$BUILD_TARGET" \
|
||||
-customBuildTarget "$BUILD_TARGET" \
|
||||
-customBuildPath "$CUSTOM_BUILD_PATH" \
|
||||
-customBuildProfile "$BUILD_PROFILE" \
|
||||
${BUILD_PROFILE:+-activeBuildProfile} ${BUILD_PROFILE:+"$BUILD_PROFILE"} \
|
||||
-executeMethod "$BUILD_METHOD" \
|
||||
-buildVersion "$VERSION" \
|
||||
-androidVersionCode "$ANDROID_VERSION_CODE" \
|
||||
|
||||
32
dist/platforms/mac/steps/return_license.sh
vendored
32
dist/platforms/mac/steps/return_license.sh
vendored
@@ -4,29 +4,15 @@
|
||||
echo "Changing to \"$ACTIVATE_LICENSE_PATH\" directory."
|
||||
pushd "$ACTIVATE_LICENSE_PATH"
|
||||
|
||||
if [[ -n "$UNITY_LICENSING_SERVER" ]]; then
|
||||
#
|
||||
# Return any floating license used.
|
||||
#
|
||||
echo "Returning floating license: \"$FLOATING_LICENSE\""
|
||||
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/Frameworks/UnityLicensingClient.app/Contents/MacOS/Unity.Licensing.Client \
|
||||
--return-floating "$FLOATING_LICENSE"
|
||||
elif [[ -n "$UNITY_SERIAL" ]]; then
|
||||
#
|
||||
# SERIAL LICENSE MODE
|
||||
#
|
||||
# This will return the license that is currently in use.
|
||||
#
|
||||
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity \
|
||||
-logFile - \
|
||||
-batchmode \
|
||||
-nographics \
|
||||
-quit \
|
||||
-username "$UNITY_EMAIL" \
|
||||
-password "$UNITY_PASSWORD" \
|
||||
-returnlicense \
|
||||
-projectPath "$ACTIVATE_LICENSE_PATH"
|
||||
fi
|
||||
/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity \
|
||||
-logFile - \
|
||||
-batchmode \
|
||||
-nographics \
|
||||
-quit \
|
||||
-username "$UNITY_EMAIL" \
|
||||
-password "$UNITY_PASSWORD" \
|
||||
-returnlicense \
|
||||
-projectPath "$ACTIVATE_LICENSE_PATH"
|
||||
|
||||
# Return to previous working directory
|
||||
popd
|
||||
|
||||
14
dist/platforms/ubuntu/steps/activate.sh
vendored
14
dist/platforms/ubuntu/steps/activate.sh
vendored
@@ -68,18 +68,14 @@ elif [[ -n "$UNITY_LICENSING_SERVER" ]]; then
|
||||
echo "Adding licensing server config"
|
||||
|
||||
/opt/unity/Editor/Data/Resources/Licensing/Client/Unity.Licensing.Client --acquire-floating > license.txt #is this accessible in a env variable?
|
||||
PARSEDFILE=$(grep -oP '\".*?\"' < license.txt | tr -d '"')
|
||||
export FLOATING_LICENSE
|
||||
FLOATING_LICENSE=$(sed -n 2p <<< "$PARSEDFILE")
|
||||
FLOATING_LICENSE_TIMEOUT=$(sed -n 4p <<< "$PARSEDFILE")
|
||||
|
||||
echo "Acquired floating license: \"$FLOATING_LICENSE\" with timeout $FLOATING_LICENSE_TIMEOUT"
|
||||
# Store the exit code from the verify command
|
||||
UNITY_EXIT_CODE=$?
|
||||
|
||||
if [ $UNITY_EXIT_CODE -eq 0 ]; then
|
||||
PARSEDFILE=$(grep -oP '\".*?\"' < license.txt | tr -d '"')
|
||||
export FLOATING_LICENSE
|
||||
FLOATING_LICENSE=$(sed -n 2p <<< "$PARSEDFILE")
|
||||
FLOATING_LICENSE_TIMEOUT=$(sed -n 4p <<< "$PARSEDFILE")
|
||||
|
||||
echo "Acquired floating license: \"$FLOATING_LICENSE\" with timeout $FLOATING_LICENSE_TIMEOUT"
|
||||
fi
|
||||
else
|
||||
#
|
||||
# NO LICENSE ACTIVATION STRATEGY MATCHED
|
||||
|
||||
3
dist/platforms/ubuntu/steps/build.sh
vendored
3
dist/platforms/ubuntu/steps/build.sh
vendored
@@ -125,11 +125,10 @@ unity-editor \
|
||||
$( [ "${MANUAL_EXIT}" == "true" ] || echo "-quit" ) \
|
||||
-customBuildName "$BUILD_NAME" \
|
||||
-projectPath "$UNITY_PROJECT_PATH" \
|
||||
$( [ -z "$BUILD_PROFILE" ] && echo "-buildTarget $BUILD_TARGET" ) \
|
||||
-buildTarget "$BUILD_TARGET" \
|
||||
-customBuildTarget "$BUILD_TARGET" \
|
||||
-customBuildPath "$CUSTOM_BUILD_PATH" \
|
||||
-customBuildProfile "$BUILD_PROFILE" \
|
||||
${BUILD_PROFILE:+-activeBuildProfile} ${BUILD_PROFILE:+"$BUILD_PROFILE"} \
|
||||
-executeMethod "$BUILD_METHOD" \
|
||||
-buildVersion "$VERSION" \
|
||||
-androidVersionCode "$ANDROID_VERSION_CODE" \
|
||||
|
||||
17
dist/platforms/windows/build.ps1
vendored
17
dist/platforms/windows/build.ps1
vendored
@@ -148,24 +148,18 @@ Write-Output "# Building project #"
|
||||
Write-Output "###########################"
|
||||
Write-Output ""
|
||||
|
||||
$unityGraphics = "-nographics"
|
||||
|
||||
if ($LLVMPIPE_INSTALLED -eq "true")
|
||||
{
|
||||
$unityGraphics = "-force-opengl"
|
||||
}
|
||||
|
||||
# If $Env:CUSTOM_PARAMETERS contains spaces and is passed directly on the command line to Unity, powershell will wrap it
|
||||
# in double quotes. To avoid this, parse $Env:CUSTOM_PARAMETERS into an array, while respecting any quotations within the string.
|
||||
$_, $customParametersArray = Invoke-Expression('Write-Output -- "" ' + $Env:CUSTOM_PARAMETERS)
|
||||
$unityArgs = @(
|
||||
"-quit",
|
||||
"-batchmode",
|
||||
$unityGraphics,
|
||||
"-nographics",
|
||||
"-silent-crashes",
|
||||
"-customBuildName", "`"$Env:BUILD_NAME`"",
|
||||
"-projectPath", "`"$Env:UNITY_PROJECT_PATH`"",
|
||||
"-executeMethod", "`"$Env:BUILD_METHOD`"",
|
||||
"-buildTarget", "`"$Env:BUILD_TARGET`"",
|
||||
"-customBuildTarget", "`"$Env:BUILD_TARGET`"",
|
||||
"-customBuildPath", "`"$Env:CUSTOM_BUILD_PATH`"",
|
||||
"-customBuildProfile", "`"$Env:BUILD_PROFILE`"",
|
||||
@@ -180,13 +174,6 @@ $unityArgs = @(
|
||||
"-logfile", "-"
|
||||
) + $customParametersArray
|
||||
|
||||
if (-not $Env:BUILD_PROFILE) {
|
||||
$unityArgs += @("-buildTarget", "`"$Env:BUILD_TARGET`"")
|
||||
}
|
||||
if ($Env:BUILD_PROFILE) {
|
||||
$unityArgs += @("-activeBuildProfile", "`"$Env:BUILD_PROFILE`"")
|
||||
}
|
||||
|
||||
# Remove null items as that will fail the Start-Process call
|
||||
$unityArgs = $unityArgs | Where-Object { $_ -ne $null }
|
||||
|
||||
|
||||
16
dist/platforms/windows/entrypoint.ps1
vendored
16
dist/platforms/windows/entrypoint.ps1
vendored
@@ -1,13 +1,5 @@
|
||||
Get-Process
|
||||
|
||||
# Copy .upmconfig.toml if it exists
|
||||
if (Test-Path "C:\githubhome\.upmconfig.toml") {
|
||||
Write-Host "Copying .upmconfig.toml to $Env:USERPROFILE\.upmconfig.toml"
|
||||
Copy-Item -Path "C:\githubhome\.upmconfig.toml" -Destination "$Env:USERPROFILE\.upmconfig.toml" -Force
|
||||
} else {
|
||||
Write-Host "No .upmconfig.toml found at C:\githubhome"
|
||||
}
|
||||
|
||||
# Import any necessary registry keys, ie: location of windows 10 sdk
|
||||
# No guarantee that there will be any necessary registry keys, ie: tvOS
|
||||
Get-ChildItem -Path c:\regkeys -File | ForEach-Object { reg import $_.fullname }
|
||||
@@ -18,17 +10,9 @@ regsvr32 C:\ProgramData\Microsoft\VisualStudio\Setup\x64\Microsoft.VisualStudio.
|
||||
# Kill the regsvr process
|
||||
Get-Process -Name regsvr32 | ForEach-Object { Stop-Process -Id $_.Id -Force }
|
||||
|
||||
# Install Visual C++ 2013 Redistributables
|
||||
. "c:\steps\install_vcredist13.ps1"
|
||||
|
||||
# Setup Git Credentials
|
||||
. "c:\steps\set_gitcredential.ps1"
|
||||
|
||||
if ($env:ENABLE_GPU -eq "true") {
|
||||
# Install LLVMpipe software graphics driver
|
||||
. "c:\steps\install_llvmpipe.ps1"
|
||||
}
|
||||
|
||||
# Activate Unity
|
||||
if ($env:SKIP_ACTIVATION -ne "true") {
|
||||
. "c:\steps\activate.ps1"
|
||||
|
||||
56
dist/platforms/windows/install_llvmpipe.ps1
vendored
56
dist/platforms/windows/install_llvmpipe.ps1
vendored
@@ -1,56 +0,0 @@
|
||||
$Private:repo = "mmozeiko/build-mesa"
|
||||
$Private:downloadPath = "$Env:TEMP\mesa.zip"
|
||||
$Private:extractPath = "$Env:TEMP\mesa"
|
||||
$Private:destinationPath = "$Env:UNITY_PATH\Editor\"
|
||||
$Private:version = "25.1.0"
|
||||
|
||||
$LLVMPIPE_INSTALLED = "false"
|
||||
|
||||
try {
|
||||
# Get the release info from GitHub API (version fixed to decrease probability of breakage)
|
||||
$releaseUrl = "https://api.github.com/repos/$repo/releases/tags/$version"
|
||||
$release = Invoke-RestMethod -Uri $releaseUrl -Headers @{ "User-Agent" = "PowerShell" }
|
||||
|
||||
# Get the download URL for the zip asset
|
||||
$zipUrl = $release.assets | Where-Object { $_.name -like "mesa-llvmpipe-x64*.zip" } | Select-Object -First 1 -ExpandProperty browser_download_url
|
||||
|
||||
if (-not $zipUrl) {
|
||||
throw "No zip file found in the latest release."
|
||||
}
|
||||
|
||||
# Download the zip file
|
||||
Write-Host "Downloading $zipUrl..."
|
||||
Invoke-WebRequest -Uri $zipUrl -OutFile $downloadPath
|
||||
|
||||
# Create extraction directory if it doesn't exist
|
||||
if (-not (Test-Path $extractPath)) {
|
||||
New-Item -ItemType Directory -Path $extractPath | Out-Null
|
||||
}
|
||||
|
||||
# Extract the zip file
|
||||
Write-Host "Extracting $downloadPath to $extractPath..."
|
||||
Expand-Archive -Path $downloadPath -DestinationPath $extractPath -Force
|
||||
|
||||
# Create destination directory if it doesn't exist
|
||||
if (-not (Test-Path $destinationPath)) {
|
||||
New-Item -ItemType Directory -Path $destinationPath | Out-Null
|
||||
}
|
||||
|
||||
# Copy extracted files to destination
|
||||
Write-Host "Copying files to $destinationPath..."
|
||||
Copy-Item -Path "$extractPath\*" -Destination $destinationPath -Recurse -Force
|
||||
|
||||
Write-Host "Successfully downloaded, extracted, and copied Mesa files to $destinationPath"
|
||||
|
||||
$LLVMPIPE_INSTALLED = "true"
|
||||
} catch {
|
||||
Write-Error "An error occurred: $_"
|
||||
} finally {
|
||||
# Clean up temporary files
|
||||
if (Test-Path $downloadPath) {
|
||||
Remove-Item $downloadPath -Force
|
||||
}
|
||||
if (Test-Path $extractPath) {
|
||||
Remove-Item $extractPath -Recurse -Force
|
||||
}
|
||||
}
|
||||
11
dist/platforms/windows/install_vcredist13.ps1
vendored
11
dist/platforms/windows/install_vcredist13.ps1
vendored
@@ -1,11 +0,0 @@
|
||||
# For some reason, Unity is failing in github actions windows runners
|
||||
# due to missing Visual C++ 2013 redistributables.
|
||||
# This script downloads and installs the required redistributables.
|
||||
Write-Output ""
|
||||
Write-Output "#########################################################"
|
||||
Write-Output "# Installing Visual C++ Redistributables (2013) #"
|
||||
Write-Output "#########################################################"
|
||||
Write-Output ""
|
||||
|
||||
|
||||
choco install vcredist2013 -y --no-progress
|
||||
122
install.ps1
122
install.ps1
@@ -1,122 +0,0 @@
|
||||
# game-ci CLI installer for Windows
|
||||
# Usage: irm https://raw.githubusercontent.com/game-ci/unity-builder/main/install.ps1 | iex
|
||||
#
|
||||
# Environment variables:
|
||||
# GAME_CI_VERSION - Install a specific version (e.g., v2.0.0). Defaults to latest.
|
||||
# GAME_CI_INSTALL - Installation directory. Defaults to $HOME\.game-ci\bin.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Repo = "game-ci/unity-builder"
|
||||
$InstallDir = if ($env:GAME_CI_INSTALL) { $env:GAME_CI_INSTALL } else { Join-Path $env:USERPROFILE ".game-ci\bin" }
|
||||
$AssetName = "game-ci-windows-x64.exe"
|
||||
$BinaryName = "game-ci.exe"
|
||||
|
||||
function Write-Info($Message) {
|
||||
Write-Host "info: " -ForegroundColor Green -NoNewline
|
||||
Write-Host $Message
|
||||
}
|
||||
|
||||
function Write-Warn($Message) {
|
||||
Write-Host "warn: " -ForegroundColor Yellow -NoNewline
|
||||
Write-Host $Message
|
||||
}
|
||||
|
||||
# Determine version
|
||||
if ($env:GAME_CI_VERSION) {
|
||||
$Version = $env:GAME_CI_VERSION
|
||||
Write-Info "Using specified version: $Version"
|
||||
} else {
|
||||
Write-Info "Fetching latest release..."
|
||||
try {
|
||||
$Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest"
|
||||
$Version = $Release.tag_name
|
||||
} catch {
|
||||
Write-Host "error: Could not determine latest version. Check https://github.com/$Repo/releases" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
$DownloadUrl = "https://github.com/$Repo/releases/download/$Version/$AssetName"
|
||||
$ChecksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt"
|
||||
$BinaryPath = Join-Path $InstallDir $BinaryName
|
||||
|
||||
Write-Host ""
|
||||
Write-Info "Installing game-ci $Version (windows-x64)"
|
||||
Write-Info " from: $DownloadUrl"
|
||||
Write-Info " to: $BinaryPath"
|
||||
Write-Host ""
|
||||
|
||||
# Create install directory
|
||||
if (-not (Test-Path $InstallDir)) {
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
}
|
||||
|
||||
# Download binary
|
||||
try {
|
||||
Invoke-WebRequest -Uri $DownloadUrl -OutFile $BinaryPath -UseBasicParsing
|
||||
} catch {
|
||||
if ($_.Exception.Response.StatusCode -eq 404) {
|
||||
Write-Host "error: Release asset not found: $AssetName ($Version)" -ForegroundColor Red
|
||||
Write-Host " Check available assets at https://github.com/$Repo/releases/tag/$Version" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "error: Download failed: $_" -ForegroundColor Red
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Verify checksum
|
||||
try {
|
||||
$Checksums = Invoke-WebRequest -Uri $ChecksumUrl -UseBasicParsing | Select-Object -ExpandProperty Content
|
||||
$ExpectedLine = $Checksums -split "`n" | Where-Object { $_ -match $AssetName } | Select-Object -First 1
|
||||
if ($ExpectedLine) {
|
||||
$ExpectedHash = ($ExpectedLine -split '\s+')[0]
|
||||
$ActualHash = (Get-FileHash -Path $BinaryPath -Algorithm SHA256).Hash.ToLower()
|
||||
if ($ExpectedHash -eq $ActualHash) {
|
||||
Write-Info "Checksum verified (SHA256)"
|
||||
} else {
|
||||
Write-Host "error: Checksum verification failed!" -ForegroundColor Red
|
||||
Write-Host " Expected: $ExpectedHash" -ForegroundColor Red
|
||||
Write-Host " Got: $ActualHash" -ForegroundColor Red
|
||||
Remove-Item $BinaryPath -Force
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# Checksums not available for this release; continue without verification
|
||||
}
|
||||
|
||||
# Verify the binary works
|
||||
try {
|
||||
$VersionOutput = & $BinaryPath version 2>&1
|
||||
Write-Info "Verified: $($VersionOutput | Select-Object -First 1)"
|
||||
} catch {
|
||||
Write-Warn "Binary downloaded but could not verify. It may still work."
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "game-ci installed successfully!" -ForegroundColor Green -BackgroundColor Black
|
||||
Write-Host ""
|
||||
|
||||
# Check PATH and offer to add
|
||||
$UserPath = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||
if ($UserPath -notlike "*$InstallDir*") {
|
||||
Write-Warn "game-ci is not in your PATH."
|
||||
Write-Host ""
|
||||
Write-Host "To add it permanently, run:" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host " [Environment]::SetEnvironmentVariable('PATH', ""$InstallDir;"" + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')"
|
||||
Write-Host ""
|
||||
Write-Info "Then restart your terminal."
|
||||
|
||||
# Offer to add automatically
|
||||
Write-Host ""
|
||||
$AddToPath = Read-Host "Add to PATH now? (Y/n)"
|
||||
if ($AddToPath -ne 'n' -and $AddToPath -ne 'N') {
|
||||
[Environment]::SetEnvironmentVariable('PATH', "$InstallDir;$UserPath", 'User')
|
||||
$env:PATH = "$InstallDir;$env:PATH"
|
||||
Write-Info "Added to PATH. You can now run: game-ci --help"
|
||||
}
|
||||
} else {
|
||||
Write-Info "game-ci is already in your PATH. Run: game-ci --help"
|
||||
}
|
||||
196
install.sh
196
install.sh
@@ -1,196 +0,0 @@
|
||||
#!/bin/sh
|
||||
# game-ci CLI installer
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/game-ci/unity-builder/main/install.sh | sh
|
||||
#
|
||||
# Environment variables:
|
||||
# GAME_CI_VERSION - Install a specific version (e.g., v2.0.0). Defaults to latest.
|
||||
# GAME_CI_INSTALL - Installation directory. Defaults to ~/.game-ci/bin.
|
||||
|
||||
set -e
|
||||
|
||||
REPO="game-ci/unity-builder"
|
||||
INSTALL_DIR="${GAME_CI_INSTALL:-$HOME/.game-ci/bin}"
|
||||
BINARY_NAME="game-ci"
|
||||
|
||||
# Colors (disabled if not a terminal)
|
||||
if [ -t 1 ]; then
|
||||
BOLD='\033[1m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
RED='\033[0;31m'
|
||||
RESET='\033[0m'
|
||||
else
|
||||
BOLD=''
|
||||
GREEN=''
|
||||
YELLOW=''
|
||||
RED=''
|
||||
RESET=''
|
||||
fi
|
||||
|
||||
info() {
|
||||
printf "${GREEN}info${RESET}: %s\n" "$1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf "${YELLOW}warn${RESET}: %s\n" "$1"
|
||||
}
|
||||
|
||||
error() {
|
||||
printf "${RED}error${RESET}: %s\n" "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Detect OS and architecture
|
||||
detect_platform() {
|
||||
OS="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
case "$OS" in
|
||||
Linux*) PLATFORM="linux" ;;
|
||||
Darwin*) PLATFORM="macos" ;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
PLATFORM="windows"
|
||||
warn "For Windows, consider using install.ps1 instead:"
|
||||
warn " irm https://raw.githubusercontent.com/game-ci/unity-builder/main/install.ps1 | iex"
|
||||
;;
|
||||
*) error "Unsupported operating system: $OS" ;;
|
||||
esac
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) ARCH="x64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
*) error "Unsupported architecture: $ARCH" ;;
|
||||
esac
|
||||
|
||||
ASSET_NAME="game-ci-${PLATFORM}-${ARCH}"
|
||||
if [ "$PLATFORM" = "windows" ]; then
|
||||
ASSET_NAME="${ASSET_NAME}.exe"
|
||||
BINARY_NAME="game-ci.exe"
|
||||
fi
|
||||
}
|
||||
|
||||
# Get latest release tag from GitHub API
|
||||
get_latest_version() {
|
||||
if [ -n "$GAME_CI_VERSION" ]; then
|
||||
VERSION="$GAME_CI_VERSION"
|
||||
info "Using specified version: $VERSION"
|
||||
return
|
||||
fi
|
||||
|
||||
info "Fetching latest release..."
|
||||
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
elif command -v wget > /dev/null 2>&1; then
|
||||
VERSION=$(wget -qO- "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
else
|
||||
error "Neither curl nor wget found. Please install one of them."
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
error "Could not determine latest version. Check https://github.com/${REPO}/releases"
|
||||
fi
|
||||
}
|
||||
|
||||
# Download and install the binary
|
||||
install() {
|
||||
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET_NAME}"
|
||||
|
||||
printf "\n"
|
||||
info "Installing game-ci ${VERSION} (${PLATFORM}-${ARCH})"
|
||||
info " from: ${DOWNLOAD_URL}"
|
||||
info " to: ${INSTALL_DIR}/${BINARY_NAME}"
|
||||
printf "\n"
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Download with progress
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
HTTP_CODE=$(curl -fSL "$DOWNLOAD_URL" -o "${INSTALL_DIR}/${BINARY_NAME}" -w "%{http_code}" 2>/dev/null) || true
|
||||
if [ "$HTTP_CODE" = "404" ]; then
|
||||
error "Release asset not found: ${ASSET_NAME} (${VERSION}). Check available assets at https://github.com/${REPO}/releases/tag/${VERSION}"
|
||||
elif [ ! -f "${INSTALL_DIR}/${BINARY_NAME}" ]; then
|
||||
error "Download failed. URL: ${DOWNLOAD_URL}"
|
||||
fi
|
||||
elif command -v wget > /dev/null 2>&1; then
|
||||
wget -q "$DOWNLOAD_URL" -O "${INSTALL_DIR}/${BINARY_NAME}" || error "Download failed. URL: ${DOWNLOAD_URL}"
|
||||
fi
|
||||
|
||||
chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
|
||||
# Verify the binary works
|
||||
if "${INSTALL_DIR}/${BINARY_NAME}" version > /dev/null 2>&1; then
|
||||
INSTALLED_VERSION=$("${INSTALL_DIR}/${BINARY_NAME}" version 2>&1 | head -1)
|
||||
info "Verified: ${INSTALLED_VERSION}"
|
||||
else
|
||||
warn "Binary downloaded but could not verify. It may still work."
|
||||
fi
|
||||
|
||||
printf "\n"
|
||||
printf "${BOLD}game-ci installed successfully!${RESET}\n"
|
||||
printf "\n"
|
||||
|
||||
# Check if install dir is in PATH
|
||||
case ":$PATH:" in
|
||||
*":${INSTALL_DIR}:"*)
|
||||
info "game-ci is already in your PATH. Run: game-ci --help"
|
||||
;;
|
||||
*)
|
||||
SHELL_NAME=$(basename "$SHELL" 2>/dev/null || echo "sh")
|
||||
case "$SHELL_NAME" in
|
||||
zsh) PROFILE="~/.zshrc" ;;
|
||||
bash) PROFILE="~/.bashrc" ;;
|
||||
fish) PROFILE="~/.config/fish/config.fish" ;;
|
||||
*) PROFILE="~/.profile" ;;
|
||||
esac
|
||||
|
||||
printf "${YELLOW}Add game-ci to your PATH by adding this to ${PROFILE}:${RESET}\n"
|
||||
printf "\n"
|
||||
if [ "$SHELL_NAME" = "fish" ]; then
|
||||
printf " set -gx PATH \"%s\" \$PATH\n" "$INSTALL_DIR"
|
||||
else
|
||||
printf " export PATH=\"%s:\$PATH\"\n" "$INSTALL_DIR"
|
||||
fi
|
||||
printf "\n"
|
||||
info "Then restart your shell or run: source ${PROFILE}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Verify checksum if checksums.txt is available
|
||||
verify_checksum() {
|
||||
if ! command -v sha256sum > /dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
CHECKSUM_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt"
|
||||
|
||||
CHECKSUMS=""
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
CHECKSUMS=$(curl -fsSL "$CHECKSUM_URL" 2>/dev/null) || return 0
|
||||
elif command -v wget > /dev/null 2>&1; then
|
||||
CHECKSUMS=$(wget -qO- "$CHECKSUM_URL" 2>/dev/null) || return 0
|
||||
fi
|
||||
|
||||
if [ -z "$CHECKSUMS" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
EXPECTED=$(echo "$CHECKSUMS" | grep "$ASSET_NAME" | awk '{print $1}')
|
||||
if [ -z "$EXPECTED" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
ACTUAL=$(sha256sum "${INSTALL_DIR}/${BINARY_NAME}" | awk '{print $1}')
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
error "Checksum verification failed!\n Expected: ${EXPECTED}\n Got: ${ACTUAL}"
|
||||
fi
|
||||
|
||||
info "Checksum verified (SHA256)"
|
||||
}
|
||||
|
||||
# Main
|
||||
detect_platform
|
||||
get_latest_version
|
||||
install
|
||||
verify_checksum
|
||||
@@ -1,11 +0,0 @@
|
||||
const base = require('./jest.config.js');
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
forceExit: true,
|
||||
detectOpenHandles: true,
|
||||
testTimeout: 120000,
|
||||
maxWorkers: 1,
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,6 @@ module.exports = {
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
modulePathIgnorePatterns: ['<rootDir>/lib/', '<rootDir>/dist/'],
|
||||
|
||||
// Use jest.setup.js to polyfill fetch for all tests
|
||||
setupFiles: ['<rootDir>/jest.setup.js'],
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
setupFilesAfterEnv: ['<rootDir>/src/jest.setup.ts'],
|
||||
};
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
const fetch = require('node-fetch');
|
||||
global.fetch = fetch;
|
||||
48
package.json
48
package.json
@@ -3,24 +3,6 @@
|
||||
"version": "3.0.0",
|
||||
"description": "Build Unity projects for different platforms.",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"game-ci": "./lib/cli.js"
|
||||
},
|
||||
"pkg": {
|
||||
"scripts": "lib/**/*.js",
|
||||
"assets": [
|
||||
"lib/**/*.json",
|
||||
"package.json"
|
||||
],
|
||||
"targets": [
|
||||
"node20-linux-x64",
|
||||
"node20-linux-arm64",
|
||||
"node20-macos-x64",
|
||||
"node20-macos-arm64",
|
||||
"node20-win-x64"
|
||||
],
|
||||
"outputPath": "dist-binaries"
|
||||
},
|
||||
"repository": "git@github.com:game-ci/unity-builder.git",
|
||||
"author": "Webber <webber@takken.io>",
|
||||
"license": "MIT",
|
||||
@@ -30,19 +12,17 @@
|
||||
"lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts",
|
||||
"format": "prettier --write \"src/**/*.{js,ts}\"",
|
||||
"cli": "yarn ts-node src/index.ts -m cli",
|
||||
"game-ci": "ts-node src/cli.ts",
|
||||
"gcp-secrets-tests": "cross-env providerStrategy=aws orchestratorTests=true inputPullCommand=\"gcp-secret-manager\" populateOverride=true pullInputList=UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD yarn test -i -t \"orchestrator\"",
|
||||
"gcp-secrets-cli": "cross-env orchestratorTests=true USE_IL2CPP=false inputPullCommand=\"gcp-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
|
||||
"aws-secrets-cli": "cross-env orchestratorTests=true inputPullCommand=\"aws-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
|
||||
"gcp-secrets-tests": "cross-env providerStrategy=aws cloudRunnerTests=true inputPullCommand=\"gcp-secret-manager\" populateOverride=true pullInputList=UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD yarn test -i -t \"cloud runner\"",
|
||||
"gcp-secrets-cli": "cross-env cloudRunnerTests=true USE_IL2CPP=false inputPullCommand=\"gcp-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
|
||||
"aws-secrets-cli": "cross-env cloudRunnerTests=true inputPullCommand=\"aws-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
|
||||
"cli-aws": "cross-env providerStrategy=aws yarn run test-cli",
|
||||
"cli-k8s": "cross-env providerStrategy=k8s yarn run test-cli",
|
||||
"test-cli": "cross-env orchestratorTests=true yarn ts-node src/index.ts -m cli --projectPath test-project",
|
||||
"test-cli": "cross-env cloudRunnerTests=true yarn ts-node src/index.ts -m cli --projectPath test-project",
|
||||
"test": "jest",
|
||||
"test:ci": "jest --config=jest.ci.config.js --runInBand",
|
||||
"test-i": "cross-env orchestratorTests=true yarn test -i -t \"orchestrator\"",
|
||||
"test-i": "cross-env cloudRunnerTests=true yarn test -i -t \"cloud runner\"",
|
||||
"test-i-*": "yarn run test-i-aws && yarn run test-i-k8s",
|
||||
"test-i-aws": "cross-env orchestratorTests=true providerStrategy=aws yarn test -i -t \"orchestrator\"",
|
||||
"test-i-k8s": "cross-env orchestratorTests=true providerStrategy=k8s yarn test -i -t \"orchestrator\""
|
||||
"test-i-aws": "cross-env cloudRunnerTests=true providerStrategy=aws yarn test -i -t \"cloud runner\"",
|
||||
"test-i-k8s": "cross-env cloudRunnerTests=true providerStrategy=k8s yarn test -i -t \"cloud runner\""
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.x"
|
||||
@@ -52,11 +32,6 @@
|
||||
"@actions/core": "^1.11.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "^6.0.0",
|
||||
"@aws-sdk/client-cloudformation": "^3.777.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.777.0",
|
||||
"@aws-sdk/client-ecs": "^3.778.0",
|
||||
"@aws-sdk/client-kinesis": "^3.777.0",
|
||||
"@aws-sdk/client-s3": "^3.779.0",
|
||||
"@kubernetes/client-node": "^0.16.3",
|
||||
"@octokit/core": "^5.1.0",
|
||||
"async-wait-until": "^2.0.12",
|
||||
@@ -69,12 +44,10 @@
|
||||
"nanoid": "^3.3.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"semver": "^7.5.2",
|
||||
"shell-quote": "^1.8.3",
|
||||
"ts-md5": "^1.3.1",
|
||||
"unity-changeset": "^3.1.0",
|
||||
"unity-changeset": "^2.0.0",
|
||||
"uuid": "^9.0.0",
|
||||
"yaml": "^2.2.2",
|
||||
"yargs": "^17.7.2"
|
||||
"yaml": "^2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/base-64": "^1.0.0",
|
||||
@@ -82,7 +55,6 @@
|
||||
"@types/node": "^17.0.23",
|
||||
"@types/semver": "^7.3.9",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"@types/yargs": "^17.0.35",
|
||||
"@typescript-eslint/parser": "4.8.1",
|
||||
"@vercel/ncc": "^0.36.1",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -97,8 +69,6 @@
|
||||
"jest-fail-on-console": "^3.0.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lefthook": "^1.6.1",
|
||||
"node-fetch": "2",
|
||||
"pkg": "^5.8.1",
|
||||
"prettier": "^2.5.1",
|
||||
"ts-jest": "^27.1.3",
|
||||
"ts-node": "10.8.1",
|
||||
|
||||
42
src/cli.ts
42
src/cli.ts
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import yargs from 'yargs';
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import buildCommand from './cli/commands/build';
|
||||
import testCommand from './cli/commands/test';
|
||||
import orchestrateCommand from './cli/commands/orchestrate';
|
||||
import activateCommand from './cli/commands/activate';
|
||||
import statusCommand from './cli/commands/status';
|
||||
import versionCommand from './cli/commands/version';
|
||||
import updateCommand from './cli/commands/update';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
.scriptName('game-ci')
|
||||
.usage('$0 <command> [options]')
|
||||
.command(buildCommand)
|
||||
.command(testCommand)
|
||||
.command(orchestrateCommand)
|
||||
.command(activateCommand)
|
||||
.command(statusCommand)
|
||||
.command(versionCommand)
|
||||
.command(updateCommand)
|
||||
.demandCommand(1, 'You must specify a command. Run game-ci --help for available commands.')
|
||||
.strict()
|
||||
.alias('h', 'help')
|
||||
.epilogue('For more information, visit https://game.ci')
|
||||
.wrap(Math.min(120, process.stdout.columns || 80));
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await cli.parse();
|
||||
} catch (error: any) {
|
||||
if (error.name !== 'YError') {
|
||||
core.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,135 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Integration tests that spawn the CLI as a child process and verify
|
||||
* exit codes and output. Uses node with --require ts-node/register to
|
||||
* run the TypeScript entry point directly so no build step is required.
|
||||
*/
|
||||
|
||||
const CLI_ENTRY = path.resolve(__dirname, '..', '..', 'cli.ts');
|
||||
|
||||
function runCli(cliArguments: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
['--require', 'ts-node/register/transpile-only', CLI_ENTRY, ...cliArguments],
|
||||
{ timeout: 30_000, cwd: path.resolve(__dirname, '..', '..', '..') },
|
||||
(error, stdout, stderr) => {
|
||||
resolve({
|
||||
code: error ? error.code ?? 1 : 0,
|
||||
stdout: stdout.toString(),
|
||||
stderr: stderr.toString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Integration tests spawn child processes which need more time than the default 5s
|
||||
jest.setTimeout(30_000);
|
||||
|
||||
describe('CLI integration', () => {
|
||||
it('exits 0 and shows all commands for --help', async () => {
|
||||
const result = await runCli(['--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('game-ci');
|
||||
expect(result.stdout).toContain('build');
|
||||
expect(result.stdout).toContain('test');
|
||||
expect(result.stdout).toContain('orchestrate');
|
||||
expect(result.stdout).toContain('activate');
|
||||
expect(result.stdout).toContain('status');
|
||||
expect(result.stdout).toContain('version');
|
||||
expect(result.stdout).toContain('update');
|
||||
});
|
||||
|
||||
it('exits 0 and shows version info for version command', async () => {
|
||||
const result = await runCli(['version']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('unity-builder');
|
||||
});
|
||||
|
||||
it('exits 0 and shows build flags for build --help', async () => {
|
||||
const result = await runCli(['build', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('--target-platform');
|
||||
expect(result.stdout).toContain('--unity-version');
|
||||
expect(result.stdout).toContain('--project-path');
|
||||
expect(result.stdout).toContain('--build-name');
|
||||
expect(result.stdout).toContain('--builds-path');
|
||||
expect(result.stdout).toContain('--build-method');
|
||||
expect(result.stdout).toContain('--custom-parameters');
|
||||
expect(result.stdout).toContain('--versioning');
|
||||
});
|
||||
|
||||
it('exits 0 and shows test flags for test --help', async () => {
|
||||
const result = await runCli(['test', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('--target-platform');
|
||||
expect(result.stdout).toContain('--test-mode');
|
||||
expect(result.stdout).toContain('--test-results-path');
|
||||
expect(result.stdout).toContain('--enable-code-coverage');
|
||||
});
|
||||
|
||||
it('exits 0 for test alias t --help', async () => {
|
||||
const result = await runCli(['t', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('--test-mode');
|
||||
});
|
||||
|
||||
it('exits non-zero for an unknown command', async () => {
|
||||
const result = await runCli(['nonexistent']);
|
||||
|
||||
expect(result.code).not.toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('exits non-zero when no command is provided', async () => {
|
||||
const result = await runCli([]);
|
||||
|
||||
expect(result.code).not.toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('exits 0 for orchestrate --help', async () => {
|
||||
const result = await runCli(['orchestrate', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('--target-platform');
|
||||
expect(result.stdout).toContain('--provider-strategy');
|
||||
});
|
||||
|
||||
it('exits 0 for orchestrate alias o --help', async () => {
|
||||
const result = await runCli(['o', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('--provider-strategy');
|
||||
});
|
||||
|
||||
it('exits 0 for activate --help', async () => {
|
||||
const result = await runCli(['activate', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('activate');
|
||||
});
|
||||
|
||||
it('exits 0 for status --help', async () => {
|
||||
const result = await runCli(['status', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('status');
|
||||
expect(result.stdout).toContain('--cache-dir');
|
||||
});
|
||||
|
||||
it('exits 0 for update --help', async () => {
|
||||
const result = await runCli(['update', '--help']);
|
||||
|
||||
expect(result.code).toStrictEqual(0);
|
||||
expect(result.stdout).toContain('update');
|
||||
expect(result.stdout).toContain('--force');
|
||||
expect(result.stdout).toContain('--version');
|
||||
});
|
||||
});
|
||||
@@ -1,312 +0,0 @@
|
||||
import buildCommand from '../commands/build';
|
||||
import testCommand from '../commands/test';
|
||||
import activateCommand from '../commands/activate';
|
||||
import orchestrateCommand from '../commands/orchestrate';
|
||||
import statusCommand from '../commands/status';
|
||||
import versionCommand from '../commands/version';
|
||||
import updateCommand from '../commands/update';
|
||||
|
||||
function createFakeYargs(): { yargs: any; options: Record<string, any> } {
|
||||
const options: Record<string, any> = {};
|
||||
const yargs: any = {
|
||||
option: jest.fn(),
|
||||
positional: jest.fn(),
|
||||
example: jest.fn(),
|
||||
env: jest.fn(),
|
||||
};
|
||||
|
||||
yargs.option.mockImplementation((name: string, config: any) => {
|
||||
options[name] = config;
|
||||
|
||||
return yargs;
|
||||
});
|
||||
yargs.positional.mockImplementation((name: string, config: any) => {
|
||||
options[name] = config;
|
||||
|
||||
return yargs;
|
||||
});
|
||||
yargs.example.mockReturnValue(yargs);
|
||||
yargs.env.mockReturnValue(yargs);
|
||||
|
||||
return { yargs, options };
|
||||
}
|
||||
|
||||
describe('CLI commands', () => {
|
||||
describe('build command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(buildCommand.command).toStrictEqual('build');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(buildCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof buildCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof buildCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('defines all expected build flags via builder', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(buildCommand.builder as Function)(yargs);
|
||||
|
||||
// Core build flags (from shared + build-specific)
|
||||
expect(options['target-platform']).toBeDefined();
|
||||
expect(options['target-platform'].demandOption).toStrictEqual(true);
|
||||
expect(options['unity-version']).toBeDefined();
|
||||
expect(options['project-path']).toBeDefined();
|
||||
expect(options['build-profile']).toBeDefined();
|
||||
expect(options['build-name']).toBeDefined();
|
||||
expect(options['builds-path']).toBeDefined();
|
||||
expect(options['build-method']).toBeDefined();
|
||||
expect(options['custom-parameters']).toBeDefined();
|
||||
expect(options['versioning']).toBeDefined();
|
||||
expect(options['version']).toBeDefined();
|
||||
expect(options['custom-image']).toBeDefined();
|
||||
expect(options['manual-exit']).toBeDefined();
|
||||
expect(options['enable-gpu']).toBeDefined();
|
||||
|
||||
// Android flags
|
||||
expect(options['android-version-code']).toBeDefined();
|
||||
expect(options['android-export-type']).toBeDefined();
|
||||
expect(options['android-keystore-name']).toBeDefined();
|
||||
expect(options['android-keystore-base64']).toBeDefined();
|
||||
expect(options['android-keystore-pass']).toBeDefined();
|
||||
expect(options['android-keyalias-name']).toBeDefined();
|
||||
expect(options['android-keyalias-pass']).toBeDefined();
|
||||
expect(options['android-target-sdk-version']).toBeDefined();
|
||||
expect(options['android-symbol-type']).toBeDefined();
|
||||
|
||||
// Docker flags
|
||||
expect(options['docker-cpu-limit']).toBeDefined();
|
||||
expect(options['docker-memory-limit']).toBeDefined();
|
||||
expect(options['docker-workspace-path']).toBeDefined();
|
||||
expect(options['run-as-host-user']).toBeDefined();
|
||||
expect(options['chown-files-to']).toBeDefined();
|
||||
|
||||
// Build should NOT have orchestrator-specific flags
|
||||
expect(options['provider-strategy']).toBeUndefined();
|
||||
expect(options['aws-stack-name']).toBeUndefined();
|
||||
expect(options['kube-config']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets correct default values', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(buildCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['unity-version'].default).toStrictEqual('auto');
|
||||
expect(options['project-path'].default).toStrictEqual('.');
|
||||
expect(options['builds-path'].default).toStrictEqual('build');
|
||||
expect(options['versioning'].default).toStrictEqual('Semantic');
|
||||
expect(options['manual-exit'].default).toStrictEqual(false);
|
||||
expect(options['enable-gpu'].default).toStrictEqual(false);
|
||||
expect(options['android-export-type'].default).toStrictEqual('androidPackage');
|
||||
expect(options['android-symbol-type'].default).toStrictEqual('none');
|
||||
});
|
||||
|
||||
it('provides camelCase aliases for kebab-case options', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(buildCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['target-platform'].alias).toStrictEqual('targetPlatform');
|
||||
expect(options['unity-version'].alias).toStrictEqual('unityVersion');
|
||||
expect(options['project-path'].alias).toStrictEqual('projectPath');
|
||||
expect(options['build-name'].alias).toStrictEqual('buildName');
|
||||
expect(options['builds-path'].alias).toStrictEqual('buildsPath');
|
||||
expect(options['build-method'].alias).toStrictEqual('buildMethod');
|
||||
});
|
||||
});
|
||||
|
||||
describe('test command', () => {
|
||||
it('exports command with alias', () => {
|
||||
expect(testCommand.command).toStrictEqual(['test', 't']);
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(testCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof testCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof testCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('defines test-specific flags', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(testCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['test-mode']).toBeDefined();
|
||||
expect(options['test-mode'].default).toStrictEqual('All');
|
||||
expect(options['test-mode'].choices).toStrictEqual(['EditMode', 'PlayMode', 'All']);
|
||||
expect(options['test-results-path']).toBeDefined();
|
||||
expect(options['test-category']).toBeDefined();
|
||||
expect(options['test-filter']).toBeDefined();
|
||||
expect(options['enable-code-coverage']).toBeDefined();
|
||||
expect(options['coverage-options']).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes shared project options', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(testCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['target-platform']).toBeDefined();
|
||||
expect(options['target-platform'].demandOption).toStrictEqual(true);
|
||||
expect(options['unity-version']).toBeDefined();
|
||||
expect(options['project-path']).toBeDefined();
|
||||
expect(options['custom-image']).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes docker options but not orchestrator options', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(testCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['docker-cpu-limit']).toBeDefined();
|
||||
expect(options['docker-memory-limit']).toBeDefined();
|
||||
expect(options['provider-strategy']).toBeUndefined();
|
||||
expect(options['aws-stack-name']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('activate command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(activateCommand.command).toStrictEqual('activate');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(activateCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof activateCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof activateCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('orchestrate command', () => {
|
||||
it('exports command with alias', () => {
|
||||
expect(orchestrateCommand.command).toStrictEqual(['orchestrate', 'o']);
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(orchestrateCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof orchestrateCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof orchestrateCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('defines key orchestrator flags', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(orchestrateCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['target-platform']).toBeDefined();
|
||||
expect(options['target-platform'].demandOption).toStrictEqual(true);
|
||||
expect(options['provider-strategy']).toBeDefined();
|
||||
expect(options['provider-strategy'].default).toStrictEqual('aws');
|
||||
expect(options['aws-stack-name']).toBeDefined();
|
||||
expect(options['kube-config']).toBeDefined();
|
||||
expect(options['kube-volume']).toBeDefined();
|
||||
expect(options['cache-key']).toBeDefined();
|
||||
expect(options['watch-to-end']).toBeDefined();
|
||||
expect(options['clone-depth']).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not include build-only options', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(orchestrateCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['build-profile']).toBeUndefined();
|
||||
expect(options['manual-exit']).toBeUndefined();
|
||||
expect(options['enable-gpu']).toBeUndefined();
|
||||
expect(options['android-version-code']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(statusCommand.command).toStrictEqual('status');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(statusCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof statusCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('includes cache-dir option', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(statusCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['cache-dir']).toBeDefined();
|
||||
expect(options['project-path']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('version command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(versionCommand.command).toStrictEqual('version');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(versionCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof versionCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update command', () => {
|
||||
it('exports the correct command name', () => {
|
||||
expect(updateCommand.command).toStrictEqual('update');
|
||||
});
|
||||
|
||||
it('has a description', () => {
|
||||
expect(updateCommand.describe).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has a builder function', () => {
|
||||
expect(typeof updateCommand.builder).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('has a handler function', () => {
|
||||
expect(typeof updateCommand.handler).toStrictEqual('function');
|
||||
});
|
||||
|
||||
it('defines force and version flags', () => {
|
||||
const { yargs, options } = createFakeYargs();
|
||||
|
||||
(updateCommand.builder as Function)(yargs);
|
||||
|
||||
expect(options['force']).toBeDefined();
|
||||
expect(options['force'].type).toStrictEqual('boolean');
|
||||
expect(options['force'].default).toStrictEqual(false);
|
||||
expect(options['version']).toBeDefined();
|
||||
expect(options['version'].type).toStrictEqual('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
import { Cli } from '../../model/cli/cli';
|
||||
import GitHub from '../../model/github';
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
Cli.options = undefined;
|
||||
});
|
||||
|
||||
describe('mapCliArgumentsToInput', () => {
|
||||
describe('basic mapping', () => {
|
||||
it('populates Cli.options from CLI arguments', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
unityVersion: '2022.3.56f1',
|
||||
projectPath: './my-project',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options).toBeDefined();
|
||||
expect(Cli.options!['targetPlatform']).toStrictEqual('StandaloneLinux64');
|
||||
expect(Cli.options!['unityVersion']).toStrictEqual('2022.3.56f1');
|
||||
expect(Cli.options!['projectPath']).toStrictEqual('./my-project');
|
||||
});
|
||||
|
||||
it('disables GitHub Actions input reading', () => {
|
||||
const cliArguments: CliArguments = { targetPlatform: 'WebGL' };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(GitHub.githubInputEnabled).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it('sets mode to cli by default when not provided', () => {
|
||||
const cliArguments: CliArguments = { targetPlatform: 'Android' };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['mode']).toStrictEqual('cli');
|
||||
});
|
||||
|
||||
it('preserves an explicitly provided mode', () => {
|
||||
const cliArguments: CliArguments = { targetPlatform: 'Android', mode: 'custom-mode' };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['mode']).toStrictEqual('custom-mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
it('omits undefined values from Cli.options', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
unityVersion: undefined,
|
||||
buildName: undefined,
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['targetPlatform']).toStrictEqual('StandaloneLinux64');
|
||||
expect(Cli.options!).not.toHaveProperty('unityVersion');
|
||||
expect(Cli.options!).not.toHaveProperty('buildName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean conversion', () => {
|
||||
it('converts boolean true to string "true"', () => {
|
||||
const cliArguments: CliArguments = { manualExit: true };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['manualExit']).toStrictEqual('true');
|
||||
});
|
||||
|
||||
it('converts boolean false to string "false"', () => {
|
||||
const cliArguments: CliArguments = { enableGpu: false };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['enableGpu']).toStrictEqual('false');
|
||||
});
|
||||
|
||||
it('converts allowDirtyBuild boolean to string', () => {
|
||||
const cliArguments: CliArguments = { allowDirtyBuild: true };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['allowDirtyBuild']).toStrictEqual('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('yargs internal properties', () => {
|
||||
it('filters out yargs _ property', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
targetPlatform: 'iOS',
|
||||
_: ['build'] as any,
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!).not.toHaveProperty('_');
|
||||
});
|
||||
|
||||
it('filters out yargs $0 property', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
targetPlatform: 'iOS',
|
||||
$0: 'game-ci' as any,
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!).not.toHaveProperty('$0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flag name conversion', () => {
|
||||
it('passes camelCase keys through directly', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
androidKeystoreName: 'my.keystore',
|
||||
androidKeystorePass: 'secret',
|
||||
dockerCpuLimit: '4',
|
||||
dockerMemoryLimit: '8g',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['androidKeystoreName']).toStrictEqual('my.keystore');
|
||||
expect(Cli.options!['androidKeystorePass']).toStrictEqual('secret');
|
||||
expect(Cli.options!['dockerCpuLimit']).toStrictEqual('4');
|
||||
expect(Cli.options!['dockerMemoryLimit']).toStrictEqual('8g');
|
||||
});
|
||||
|
||||
it('maps all android-related arguments', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
androidVersionCode: '42',
|
||||
androidExportType: 'androidAppBundle',
|
||||
androidKeystoreBase64: 'base64data',
|
||||
androidKeyaliasName: 'myalias',
|
||||
androidKeyaliasPass: 'aliaspass',
|
||||
androidTargetSdkVersion: '33',
|
||||
androidSymbolType: 'public',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['androidVersionCode']).toStrictEqual('42');
|
||||
expect(Cli.options!['androidExportType']).toStrictEqual('androidAppBundle');
|
||||
expect(Cli.options!['androidKeystoreBase64']).toStrictEqual('base64data');
|
||||
expect(Cli.options!['androidKeyaliasName']).toStrictEqual('myalias');
|
||||
expect(Cli.options!['androidKeyaliasPass']).toStrictEqual('aliaspass');
|
||||
expect(Cli.options!['androidTargetSdkVersion']).toStrictEqual('33');
|
||||
expect(Cli.options!['androidSymbolType']).toStrictEqual('public');
|
||||
});
|
||||
|
||||
it('maps docker and container arguments', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
dockerIsolationMode: 'hyperv',
|
||||
dockerWorkspacePath: '/custom/workspace',
|
||||
containerRegistryRepository: 'custom/editor',
|
||||
containerRegistryImageVersion: '5',
|
||||
runAsHostUser: 'true',
|
||||
chownFilesTo: 'root:root',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['dockerIsolationMode']).toStrictEqual('hyperv');
|
||||
expect(Cli.options!['dockerWorkspacePath']).toStrictEqual('/custom/workspace');
|
||||
expect(Cli.options!['containerRegistryRepository']).toStrictEqual('custom/editor');
|
||||
expect(Cli.options!['containerRegistryImageVersion']).toStrictEqual('5');
|
||||
expect(Cli.options!['runAsHostUser']).toStrictEqual('true');
|
||||
expect(Cli.options!['chownFilesTo']).toStrictEqual('root:root');
|
||||
});
|
||||
|
||||
it('maps orchestrator-related arguments', () => {
|
||||
const cliArguments: CliArguments = {
|
||||
providerStrategy: 'k8s',
|
||||
awsStackName: 'my-stack',
|
||||
kubeConfig: 'base64config',
|
||||
kubeVolume: 'my-pvc',
|
||||
kubeVolumeSize: '10Gi',
|
||||
kubeStorageClass: 'gp3',
|
||||
containerCpu: '2048',
|
||||
containerMemory: '4096',
|
||||
cacheKey: 'my-cache',
|
||||
watchToEnd: 'false',
|
||||
cloneDepth: '100',
|
||||
};
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.options!['providerStrategy']).toStrictEqual('k8s');
|
||||
expect(Cli.options!['awsStackName']).toStrictEqual('my-stack');
|
||||
expect(Cli.options!['kubeConfig']).toStrictEqual('base64config');
|
||||
expect(Cli.options!['kubeVolume']).toStrictEqual('my-pvc');
|
||||
expect(Cli.options!['kubeVolumeSize']).toStrictEqual('10Gi');
|
||||
expect(Cli.options!['kubeStorageClass']).toStrictEqual('gp3');
|
||||
expect(Cli.options!['containerCpu']).toStrictEqual('2048');
|
||||
expect(Cli.options!['containerMemory']).toStrictEqual('4096');
|
||||
expect(Cli.options!['cacheKey']).toStrictEqual('my-cache');
|
||||
expect(Cli.options!['watchToEnd']).toStrictEqual('false');
|
||||
expect(Cli.options!['cloneDepth']).toStrictEqual('100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cli.isCliMode integration', () => {
|
||||
it('enables CLI mode after mapping', () => {
|
||||
const cliArguments: CliArguments = { targetPlatform: 'WebGL' };
|
||||
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
expect(Cli.isCliMode).toStrictEqual(true);
|
||||
});
|
||||
|
||||
it('is not in CLI mode before mapping', () => {
|
||||
expect(Cli.isCliMode).toStrictEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
|
||||
interface ActivateArguments extends CliArguments {
|
||||
unityVersion?: string;
|
||||
unitySerial?: string;
|
||||
unityLicensingServer?: string;
|
||||
}
|
||||
|
||||
const activateCommand: CommandModule<object, ActivateArguments> = {
|
||||
command: 'activate',
|
||||
describe: 'Verify Unity license configuration',
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option('unity-version', {
|
||||
alias: 'unityVersion',
|
||||
type: 'string',
|
||||
description: 'Version of Unity to activate',
|
||||
default: 'auto',
|
||||
})
|
||||
.option('unity-licensing-server', {
|
||||
alias: 'unityLicensingServer',
|
||||
type: 'string',
|
||||
description: 'The Unity licensing server address for floating licenses',
|
||||
default: '',
|
||||
})
|
||||
.env('UNITY')
|
||||
.example(
|
||||
'UNITY_SERIAL=XXXX-XXXX-XXXX-XXXX game-ci activate',
|
||||
'Activate Unity using a serial from environment variable',
|
||||
)
|
||||
.example(
|
||||
'game-ci activate --unity-licensing-server http://license-server:8080',
|
||||
'Activate Unity using a floating license server',
|
||||
) as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
const unitySerial = process.env.UNITY_SERIAL;
|
||||
const unityLicense = process.env.UNITY_LICENSE;
|
||||
const licensingServer = cliArguments.unityLicensingServer || process.env.UNITY_LICENSING_SERVER || '';
|
||||
|
||||
if (licensingServer) {
|
||||
core.info(`Activating Unity via licensing server: ${licensingServer}`);
|
||||
core.info('Floating license activation is handled automatically during builds.');
|
||||
core.info('No manual activation step is needed when using a licensing server.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!unitySerial && !unityLicense) {
|
||||
throw new Error(
|
||||
'No Unity license found.\n\n' +
|
||||
'Provide one of the following:\n' +
|
||||
' - UNITY_SERIAL environment variable (professional license)\n' +
|
||||
' - UNITY_LICENSE environment variable (personal license file content)\n' +
|
||||
' - --unity-licensing-server flag (floating license)\n\n' +
|
||||
'For more information, visit: https://game.ci/docs/github/activation',
|
||||
);
|
||||
}
|
||||
|
||||
if (unitySerial) {
|
||||
const maskedSerial = unitySerial.length > 8 ? `${unitySerial.slice(0, 4)}...${unitySerial.slice(-4)}` : '****';
|
||||
core.info(`Unity serial detected: ${maskedSerial}`);
|
||||
core.info('License will be activated automatically when running a build.');
|
||||
} else if (unityLicense) {
|
||||
core.info('Unity license file detected from UNITY_LICENSE environment variable.');
|
||||
core.info('License will be activated automatically when running a build.');
|
||||
}
|
||||
|
||||
core.info('\nActivation verified. You can now run: game-ci build --target-platform <platform>');
|
||||
} catch (error: any) {
|
||||
core.setFailed(`Activation failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default activateCommand;
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import { BuildParameters, ImageTag } from '../../model';
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
import MacBuilder from '../../model/mac-builder';
|
||||
import Docker from '../../model/docker';
|
||||
import Action from '../../model/action';
|
||||
import PlatformSetup from '../../model/platform-setup';
|
||||
import { withProjectOptions, withDockerOptions, withAndroidOptions } from './shared-options';
|
||||
|
||||
interface BuildArguments extends CliArguments {
|
||||
targetPlatform: string;
|
||||
}
|
||||
|
||||
const buildCommand: CommandModule<object, BuildArguments> = {
|
||||
command: 'build',
|
||||
describe: 'Build a Unity project locally via Docker or native runner',
|
||||
builder: (yargs) => {
|
||||
let y = withProjectOptions(yargs);
|
||||
y = withAndroidOptions(y);
|
||||
y = withDockerOptions(y);
|
||||
|
||||
return y
|
||||
.option('build-profile', {
|
||||
alias: 'buildProfile',
|
||||
type: 'string',
|
||||
description: 'Path to the build profile to activate, relative to the project root',
|
||||
default: '',
|
||||
})
|
||||
.option('versioning', {
|
||||
type: 'string',
|
||||
description: 'The versioning scheme to use when building the project',
|
||||
default: 'Semantic',
|
||||
})
|
||||
.option('version', {
|
||||
type: 'string',
|
||||
description: 'The version, when used with the "Custom" versioning scheme',
|
||||
default: '',
|
||||
})
|
||||
.option('manual-exit', {
|
||||
alias: 'manualExit',
|
||||
type: 'boolean',
|
||||
description: 'Suppresses -quit. Exit your build method using EditorApplication.Exit(0) instead.',
|
||||
default: false,
|
||||
})
|
||||
.option('enable-gpu', {
|
||||
alias: 'enableGpu',
|
||||
type: 'boolean',
|
||||
description: 'Launches unity without specifying -nographics',
|
||||
default: false,
|
||||
})
|
||||
.option('cache-unity-installation-on-mac', {
|
||||
alias: 'cacheUnityInstallationOnMac',
|
||||
type: 'boolean',
|
||||
description: 'Whether to cache the Unity hub and editor installation on MacOS',
|
||||
default: false,
|
||||
})
|
||||
.option('unity-hub-version-on-mac', {
|
||||
alias: 'unityHubVersionOnMac',
|
||||
type: 'string',
|
||||
description: 'The version of Unity Hub to install on MacOS (e.g. 3.4.0). Defaults to latest available on brew.',
|
||||
default: '',
|
||||
})
|
||||
.example('game-ci build --target-platform StandaloneLinux64', 'Build for Linux using auto-detected Unity version')
|
||||
.example(
|
||||
'game-ci build --target-platform Android --unity-version 2022.3.56f1 --build-method MyBuild.Run',
|
||||
'Build for Android with a specific Unity version and build method',
|
||||
) as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
const buildParameters = await BuildParameters.create();
|
||||
const baseImage = new ImageTag(buildParameters);
|
||||
|
||||
core.info(`Building locally for ${buildParameters.targetPlatform}...`);
|
||||
core.info(`Unity version: ${buildParameters.editorVersion}`);
|
||||
core.info(`Project path: ${buildParameters.projectPath}`);
|
||||
|
||||
const actionFolder = Action.actionFolder;
|
||||
await PlatformSetup.setup(buildParameters, actionFolder);
|
||||
|
||||
const exitCode =
|
||||
process.platform === 'darwin'
|
||||
? await MacBuilder.run(actionFolder)
|
||||
: await Docker.run(baseImage.toString(), {
|
||||
workspace: process.cwd(),
|
||||
actionFolder,
|
||||
...buildParameters,
|
||||
});
|
||||
|
||||
core.info(`\nBuild completed with exit code: ${exitCode}`);
|
||||
core.info(`Build version: ${buildParameters.buildVersion}`);
|
||||
core.info(`Build path: ${buildParameters.buildPath}`);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Build failed with exit code ${exitCode}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
core.setFailed(`Build failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default buildCommand;
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import { BuildParameters, ImageTag, Orchestrator } from '../../model';
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
import { withProjectOptions, withOrchestratorOptions } from './shared-options';
|
||||
|
||||
interface OrchestrateArguments extends CliArguments {
|
||||
targetPlatform: string;
|
||||
providerStrategy?: string;
|
||||
}
|
||||
|
||||
const orchestrateCommand: CommandModule<object, OrchestrateArguments> = {
|
||||
command: ['orchestrate', 'o'],
|
||||
describe: 'Run a build via orchestrator providers (AWS, Kubernetes, etc.)',
|
||||
builder: (yargs) => {
|
||||
let y = withProjectOptions(yargs);
|
||||
y = withOrchestratorOptions(y);
|
||||
|
||||
return y
|
||||
.option('versioning', {
|
||||
type: 'string',
|
||||
description: 'The versioning scheme to use',
|
||||
default: 'None',
|
||||
})
|
||||
.example(
|
||||
'game-ci orchestrate --target-platform StandaloneLinux64 --provider-strategy aws',
|
||||
'Build on AWS using the orchestrator',
|
||||
)
|
||||
.example(
|
||||
'game-ci o --target-platform StandaloneLinux64 --provider-strategy k8s --kube-config <base64>',
|
||||
'Build on Kubernetes (short alias)',
|
||||
) as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
const buildParameters = await BuildParameters.create();
|
||||
const baseImage = new ImageTag(buildParameters);
|
||||
|
||||
core.info(`Orchestrating build via ${buildParameters.providerStrategy}...`);
|
||||
core.info(`Target platform: ${buildParameters.targetPlatform}`);
|
||||
core.info(`Unity version: ${buildParameters.editorVersion}`);
|
||||
core.info(`Build GUID: ${buildParameters.buildGuid}`);
|
||||
|
||||
const result = await Orchestrator.run(buildParameters, baseImage.toString());
|
||||
|
||||
core.info(`\nOrchestrated build completed.`);
|
||||
if (result?.BuildResults) {
|
||||
core.info(`Results: ${result.BuildResults}`);
|
||||
} else {
|
||||
core.warning('Build completed but no build results were returned.');
|
||||
}
|
||||
} catch (error: any) {
|
||||
core.setFailed(`Orchestrated build failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default orchestrateCommand;
|
||||
@@ -1,308 +0,0 @@
|
||||
import type { Argv } from 'yargs';
|
||||
|
||||
/**
|
||||
* Shared option groups for CLI commands. Avoids duplicating option
|
||||
* definitions across build, test, and orchestrate commands.
|
||||
*/
|
||||
|
||||
export function withProjectOptions<T>(yargs: Argv<T>) {
|
||||
return yargs
|
||||
.option('target-platform', {
|
||||
alias: 'targetPlatform',
|
||||
type: 'string' as const,
|
||||
description: 'Platform that the build should target',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('unity-version', {
|
||||
alias: 'unityVersion',
|
||||
type: 'string' as const,
|
||||
description: 'Version of Unity to use. Use "auto" to detect from ProjectVersion.txt.',
|
||||
default: 'auto',
|
||||
})
|
||||
.option('project-path', {
|
||||
alias: 'projectPath',
|
||||
type: 'string' as const,
|
||||
description: 'Path to the Unity project',
|
||||
default: '.',
|
||||
})
|
||||
.option('build-name', {
|
||||
alias: 'buildName',
|
||||
type: 'string' as const,
|
||||
description: 'Name of the build',
|
||||
default: '',
|
||||
})
|
||||
.option('builds-path', {
|
||||
alias: 'buildsPath',
|
||||
type: 'string' as const,
|
||||
description: 'Path where the builds should be stored',
|
||||
default: 'build',
|
||||
})
|
||||
.option('build-method', {
|
||||
alias: 'buildMethod',
|
||||
type: 'string' as const,
|
||||
description: 'Path to a Namespace.Class.StaticMethod to run to perform the build',
|
||||
default: '',
|
||||
})
|
||||
.option('custom-parameters', {
|
||||
alias: 'customParameters',
|
||||
type: 'string' as const,
|
||||
description: 'Custom parameters to configure the build',
|
||||
default: '',
|
||||
})
|
||||
.option('custom-image', {
|
||||
alias: 'customImage',
|
||||
type: 'string' as const,
|
||||
description: 'Specific docker image that should be used for building the project',
|
||||
default: '',
|
||||
})
|
||||
.option('git-private-token', {
|
||||
alias: 'gitPrivateToken',
|
||||
type: 'string' as const,
|
||||
description: 'GitHub private token for repository access',
|
||||
default: '',
|
||||
})
|
||||
.option('skip-activation', {
|
||||
alias: 'skipActivation',
|
||||
type: 'string' as const,
|
||||
description: 'Skip Unity activation/deactivation',
|
||||
default: 'false',
|
||||
})
|
||||
.option('unity-licensing-server', {
|
||||
alias: 'unityLicensingServer',
|
||||
type: 'string' as const,
|
||||
description: 'The Unity licensing server address',
|
||||
default: '',
|
||||
})
|
||||
.option('container-registry-repository', {
|
||||
alias: 'containerRegistryRepository',
|
||||
type: 'string' as const,
|
||||
description: 'Container registry and repository to pull image from. Only applicable if customImage is not set.',
|
||||
default: 'unityci/editor',
|
||||
})
|
||||
.option('container-registry-image-version', {
|
||||
alias: 'containerRegistryImageVersion',
|
||||
type: 'string' as const,
|
||||
description: 'Container registry image version. Only applicable if customImage is not set.',
|
||||
default: '3',
|
||||
});
|
||||
}
|
||||
|
||||
export function withDockerOptions<T>(yargs: Argv<T>) {
|
||||
return yargs
|
||||
.option('docker-cpu-limit', {
|
||||
alias: 'dockerCpuLimit',
|
||||
type: 'string' as const,
|
||||
description: 'Number of CPU cores to assign the docker container',
|
||||
default: '',
|
||||
})
|
||||
.option('docker-memory-limit', {
|
||||
alias: 'dockerMemoryLimit',
|
||||
type: 'string' as const,
|
||||
description: 'Amount of memory to assign the docker container (e.g. 512m, 4g)',
|
||||
default: '',
|
||||
})
|
||||
.option('docker-workspace-path', {
|
||||
alias: 'dockerWorkspacePath',
|
||||
type: 'string' as const,
|
||||
description: 'The path to mount the workspace inside the docker container',
|
||||
default: '/github/workspace',
|
||||
})
|
||||
.option('docker-isolation-mode', {
|
||||
alias: 'dockerIsolationMode',
|
||||
type: 'string' as const,
|
||||
description:
|
||||
'Isolation mode to use for the docker container (process, hyperv, or default). Only applicable on Windows.',
|
||||
default: 'default',
|
||||
})
|
||||
.option('run-as-host-user', {
|
||||
alias: 'runAsHostUser',
|
||||
type: 'string' as const,
|
||||
description: 'Whether to run as a user that matches the host system',
|
||||
default: 'false',
|
||||
})
|
||||
.option('chown-files-to', {
|
||||
alias: 'chownFilesTo',
|
||||
type: 'string' as const,
|
||||
description: 'User and optionally group to give ownership of build artifacts',
|
||||
default: '',
|
||||
})
|
||||
.option('ssh-agent', {
|
||||
alias: 'sshAgent',
|
||||
type: 'string' as const,
|
||||
description: 'SSH Agent path to forward to the container',
|
||||
default: '',
|
||||
})
|
||||
.option('ssh-public-keys-directory-path', {
|
||||
alias: 'sshPublicKeysDirectoryPath',
|
||||
type: 'string' as const,
|
||||
description: 'Path to a directory containing SSH public keys to forward to the container',
|
||||
default: '',
|
||||
});
|
||||
}
|
||||
|
||||
export function withAndroidOptions<T>(yargs: Argv<T>) {
|
||||
return yargs
|
||||
.option('android-version-code', {
|
||||
alias: 'androidVersionCode',
|
||||
type: 'string' as const,
|
||||
description: 'The android versionCode',
|
||||
default: '',
|
||||
})
|
||||
.option('android-export-type', {
|
||||
alias: 'androidExportType',
|
||||
type: 'string' as const,
|
||||
description: 'The android export type (androidPackage, androidAppBundle, androidStudioProject)',
|
||||
default: 'androidPackage',
|
||||
})
|
||||
.option('android-keystore-name', {
|
||||
alias: 'androidKeystoreName',
|
||||
type: 'string' as const,
|
||||
description: 'The android keystoreName',
|
||||
default: '',
|
||||
})
|
||||
.option('android-keystore-base64', {
|
||||
alias: 'androidKeystoreBase64',
|
||||
type: 'string' as const,
|
||||
description: 'The base64 contents of the android keystore file',
|
||||
default: '',
|
||||
})
|
||||
.option('android-keystore-pass', {
|
||||
alias: 'androidKeystorePass',
|
||||
type: 'string' as const,
|
||||
description: 'The android keystorePass',
|
||||
default: '',
|
||||
})
|
||||
.option('android-keyalias-name', {
|
||||
alias: 'androidKeyaliasName',
|
||||
type: 'string' as const,
|
||||
description: 'The android keyaliasName',
|
||||
default: '',
|
||||
})
|
||||
.option('android-keyalias-pass', {
|
||||
alias: 'androidKeyaliasPass',
|
||||
type: 'string' as const,
|
||||
description: 'The android keyaliasPass',
|
||||
default: '',
|
||||
})
|
||||
.option('android-target-sdk-version', {
|
||||
alias: 'androidTargetSdkVersion',
|
||||
type: 'string' as const,
|
||||
description: 'The android target API level',
|
||||
default: '',
|
||||
})
|
||||
.option('android-symbol-type', {
|
||||
alias: 'androidSymbolType',
|
||||
type: 'string' as const,
|
||||
description: 'The android symbol type to export (none, public, debugging)',
|
||||
default: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
export function withOrchestratorOptions<T>(yargs: Argv<T>) {
|
||||
return yargs
|
||||
.option('provider-strategy', {
|
||||
alias: 'providerStrategy',
|
||||
type: 'string' as const,
|
||||
description: 'Orchestrator provider: aws, k8s, local-docker, local-system',
|
||||
default: 'aws',
|
||||
})
|
||||
.option('aws-stack-name', {
|
||||
alias: 'awsStackName',
|
||||
type: 'string' as const,
|
||||
description: 'The Cloud Formation stack name (AWS provider)',
|
||||
default: 'game-ci',
|
||||
})
|
||||
.option('kube-config', {
|
||||
alias: 'kubeConfig',
|
||||
type: 'string' as const,
|
||||
description: 'Base64 encoded Kubernetes config (K8s provider)',
|
||||
default: '',
|
||||
})
|
||||
.option('kube-volume', {
|
||||
alias: 'kubeVolume',
|
||||
type: 'string' as const,
|
||||
description: 'Persistent Volume Claim name for Unity build (K8s provider)',
|
||||
default: '',
|
||||
})
|
||||
.option('kube-volume-size', {
|
||||
alias: 'kubeVolumeSize',
|
||||
type: 'string' as const,
|
||||
description: 'Disc space for Kubernetes Persistent Volume',
|
||||
default: '5Gi',
|
||||
})
|
||||
.option('kube-storage-class', {
|
||||
alias: 'kubeStorageClass',
|
||||
type: 'string' as const,
|
||||
description: 'Kubernetes storage class to use for orchestrator jobs. Leave empty to install rook cluster.',
|
||||
default: '',
|
||||
})
|
||||
.option('container-cpu', {
|
||||
alias: 'containerCpu',
|
||||
type: 'string' as const,
|
||||
description: 'CPU allocation for remote build container',
|
||||
default: '1024',
|
||||
})
|
||||
.option('container-memory', {
|
||||
alias: 'containerMemory',
|
||||
type: 'string' as const,
|
||||
description: 'Memory allocation for remote build container',
|
||||
default: '3072',
|
||||
})
|
||||
.option('cache-key', {
|
||||
alias: 'cacheKey',
|
||||
type: 'string' as const,
|
||||
description: 'Cache key to indicate bucket for cache',
|
||||
default: '',
|
||||
})
|
||||
.option('allow-dirty-build', {
|
||||
alias: 'allowDirtyBuild',
|
||||
type: 'boolean' as const,
|
||||
description: 'Allow builds from dirty branches',
|
||||
default: false,
|
||||
})
|
||||
.option('watch-to-end', {
|
||||
alias: 'watchToEnd',
|
||||
type: 'string' as const,
|
||||
description: 'Whether to watch the build to completion',
|
||||
default: 'true',
|
||||
})
|
||||
.option('clone-depth', {
|
||||
alias: 'cloneDepth',
|
||||
type: 'string' as const,
|
||||
description: 'Git clone depth (0 for full clone)',
|
||||
default: '50',
|
||||
})
|
||||
.option('read-input-from-override-list', {
|
||||
alias: 'readInputFromOverrideList',
|
||||
type: 'string' as const,
|
||||
description: 'Comma separated list of input value names to read from the input override command',
|
||||
default: '',
|
||||
})
|
||||
.option('read-input-override-command', {
|
||||
alias: 'readInputOverrideCommand',
|
||||
type: 'string' as const,
|
||||
description: 'Command to execute to pull input from an external source (e.g. cloud provider secret managers)',
|
||||
default: '',
|
||||
})
|
||||
.option('post-build-steps', {
|
||||
alias: 'postBuildSteps',
|
||||
type: 'string' as const,
|
||||
description:
|
||||
'Post build job in yaml format with the keys image, secrets (name, value object array), command string',
|
||||
default: '',
|
||||
})
|
||||
.option('pre-build-steps', {
|
||||
alias: 'preBuildSteps',
|
||||
type: 'string' as const,
|
||||
description:
|
||||
'Pre build job after repository setup but before the build job (yaml format with keys image, secrets, command)',
|
||||
default: '',
|
||||
})
|
||||
.option('custom-job', {
|
||||
alias: 'customJob',
|
||||
type: 'string' as const,
|
||||
description:
|
||||
'Custom job instead of the standard build automation (yaml format with keys image, secrets, command)',
|
||||
default: '',
|
||||
});
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import UnityVersioning from '../../model/unity-versioning';
|
||||
|
||||
const statusCommand: CommandModule = {
|
||||
command: 'status',
|
||||
describe: 'Show project info, environment, and cache status',
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option('project-path', {
|
||||
alias: 'projectPath',
|
||||
type: 'string',
|
||||
description: 'Path to the Unity project',
|
||||
default: '.',
|
||||
})
|
||||
.option('cache-dir', {
|
||||
alias: 'cacheDir',
|
||||
type: 'string',
|
||||
description: 'Path to an additional cache directory to inspect',
|
||||
default: '',
|
||||
});
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
const projectPath = (cliArguments.projectPath as string) || '.';
|
||||
const cacheDirectory = cliArguments.cacheDir as string;
|
||||
|
||||
core.info('game-ci Workspace Status');
|
||||
core.info('========================\n');
|
||||
|
||||
// Project detection
|
||||
const projectVersionPath = path.join(projectPath, 'ProjectSettings', 'ProjectVersion.txt');
|
||||
const hasProject = fs.existsSync(projectVersionPath);
|
||||
|
||||
core.info(`Project Path: ${path.resolve(projectPath)}`);
|
||||
core.info(`Unity Project Found: ${hasProject ? 'Yes' : 'No'}`);
|
||||
|
||||
if (hasProject) {
|
||||
try {
|
||||
const unityVersion = UnityVersioning.determineUnityVersion(projectPath, 'auto');
|
||||
core.info(`Unity Version: ${unityVersion}`);
|
||||
} catch {
|
||||
core.info(`Unity Version: Unable to detect`);
|
||||
}
|
||||
|
||||
// Library cache status
|
||||
const libraryPath = path.join(projectPath, 'Library');
|
||||
if (fs.existsSync(libraryPath)) {
|
||||
const stats = fs.statSync(libraryPath);
|
||||
core.info(`Library Cache: Present (modified ${stats.mtime.toISOString()})`);
|
||||
|
||||
const keyDirectories = ['PackageCache', 'ScriptAssemblies', 'ShaderCache', 'Bee'];
|
||||
for (const directory of keyDirectories) {
|
||||
const directoryPath = path.join(libraryPath, directory);
|
||||
if (fs.existsSync(directoryPath)) {
|
||||
const directoryStats = fs.statSync(directoryPath);
|
||||
core.info(` ${directory}/: exists (modified ${directoryStats.mtime.toISOString()})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
core.info(`Library Cache: Not present (clean build required)`);
|
||||
}
|
||||
|
||||
// Cache archive detection
|
||||
if (cacheDirectory && fs.existsSync(cacheDirectory)) {
|
||||
core.info(`\nCache Archives (${cacheDirectory}):`);
|
||||
const cacheFiles = fs.readdirSync(cacheDirectory).filter((f) => f.endsWith('.tar') || f.endsWith('.tar.lz4'));
|
||||
if (cacheFiles.length > 0) {
|
||||
for (const file of cacheFiles) {
|
||||
const filePath = path.join(cacheDirectory, file);
|
||||
const fileStats = fs.statSync(filePath);
|
||||
const sizeMegabytes = (fileStats.size / (1024 * 1024)).toFixed(1);
|
||||
core.info(` - ${file} (${sizeMegabytes} MB, ${fileStats.mtime.toISOString()})`);
|
||||
}
|
||||
} else {
|
||||
core.info(' No cache archives found.');
|
||||
}
|
||||
}
|
||||
|
||||
// Build output detection
|
||||
const buildsPath = path.join(projectPath, '..', 'build');
|
||||
if (fs.existsSync(buildsPath)) {
|
||||
const builds = fs.readdirSync(buildsPath);
|
||||
if (builds.length > 0) {
|
||||
core.info(`\nBuild Outputs (${buildsPath}):`);
|
||||
for (const build of builds) {
|
||||
const buildPath = path.join(buildsPath, build);
|
||||
const buildStats = fs.statSync(buildPath);
|
||||
core.info(` - ${build} (${buildStats.isDirectory() ? 'dir' : 'file'}, ${buildStats.mtime.toISOString()})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Environment
|
||||
core.info('\nEnvironment:');
|
||||
core.info(` Platform: ${process.platform}`);
|
||||
core.info(` Node.js: ${process.version}`);
|
||||
core.info(` UNITY_SERIAL: ${process.env.UNITY_SERIAL ? 'Set' : 'Not set'}`);
|
||||
core.info(` UNITY_LICENSE: ${process.env.UNITY_LICENSE ? 'Set' : 'Not set'}`);
|
||||
core.info(` UNITY_EMAIL: ${process.env.UNITY_EMAIL ? 'Set' : 'Not set'}`);
|
||||
core.info(` UNITY_PASSWORD: ${process.env.UNITY_PASSWORD ? 'Set' : 'Not set'}`);
|
||||
|
||||
// Docker availability
|
||||
core.info(`\nDocker: Checking...`);
|
||||
try {
|
||||
const { execSync } = await import('node:child_process');
|
||||
const dockerVersion = execSync('docker --version', { encoding: 'utf8' }).trim();
|
||||
core.info(` ${dockerVersion}`);
|
||||
} catch {
|
||||
core.info(` Docker not found or not accessible`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default statusCommand;
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import { BuildParameters, ImageTag } from '../../model';
|
||||
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
|
||||
import Docker from '../../model/docker';
|
||||
import Action from '../../model/action';
|
||||
import PlatformSetup from '../../model/platform-setup';
|
||||
import { withProjectOptions, withDockerOptions } from './shared-options';
|
||||
|
||||
interface TestArguments extends CliArguments {
|
||||
targetPlatform: string;
|
||||
testMode?: string;
|
||||
}
|
||||
|
||||
const testCommand: CommandModule<object, TestArguments> = {
|
||||
command: ['test', 't'],
|
||||
describe: 'Run tests for a Unity project',
|
||||
builder: (yargs) => {
|
||||
let y = withProjectOptions(yargs);
|
||||
y = withDockerOptions(y);
|
||||
|
||||
return y
|
||||
.option('test-mode', {
|
||||
alias: 'testMode',
|
||||
type: 'string',
|
||||
description: 'The mode to run tests in (EditMode, PlayMode, or All)',
|
||||
default: 'All',
|
||||
choices: ['EditMode', 'PlayMode', 'All'],
|
||||
})
|
||||
.option('test-results-path', {
|
||||
alias: 'testResultsPath',
|
||||
type: 'string',
|
||||
description: 'Path where test results XML should be stored',
|
||||
default: 'test-results',
|
||||
})
|
||||
.option('test-category', {
|
||||
alias: 'testCategory',
|
||||
type: 'string',
|
||||
description: 'Only run tests in the given category (semicolon-separated)',
|
||||
default: '',
|
||||
})
|
||||
.option('test-filter', {
|
||||
alias: 'testFilter',
|
||||
type: 'string',
|
||||
description: 'Only run tests that match the filter (semicolon-separated)',
|
||||
default: '',
|
||||
})
|
||||
.option('coverage-options', {
|
||||
alias: 'coverageOptions',
|
||||
type: 'string',
|
||||
description: 'Options for code coverage (e.g. assemblyFilters, pathFilters)',
|
||||
default: '',
|
||||
})
|
||||
.option('enable-code-coverage', {
|
||||
alias: 'enableCodeCoverage',
|
||||
type: 'boolean',
|
||||
description: 'Enable code coverage when running tests',
|
||||
default: false,
|
||||
})
|
||||
.option('versioning', {
|
||||
type: 'string',
|
||||
description: 'The versioning scheme to use',
|
||||
default: 'None',
|
||||
})
|
||||
.option('cache-unity-installation-on-mac', {
|
||||
alias: 'cacheUnityInstallationOnMac',
|
||||
type: 'boolean',
|
||||
description: 'Whether to cache the Unity hub and editor installation on MacOS',
|
||||
default: false,
|
||||
})
|
||||
.option('unity-hub-version-on-mac', {
|
||||
alias: 'unityHubVersionOnMac',
|
||||
type: 'string',
|
||||
description: 'The version of Unity Hub to install on MacOS (e.g. 3.4.0). Defaults to latest available on brew.',
|
||||
default: '',
|
||||
})
|
||||
.example('game-ci test --target-platform StandaloneLinux64', 'Run all tests for Linux platform')
|
||||
.example(
|
||||
'game-ci t --target-platform StandaloneLinux64 --test-mode EditMode',
|
||||
'Run only EditMode tests (short alias)',
|
||||
)
|
||||
.example(
|
||||
'game-ci test --target-platform StandaloneLinux64 --enable-code-coverage',
|
||||
'Run tests with code coverage',
|
||||
) as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
// Map test-specific flags into the input system
|
||||
mapCliArgumentsToInput(cliArguments);
|
||||
|
||||
const buildParameters = await BuildParameters.create();
|
||||
const baseImage = new ImageTag(buildParameters);
|
||||
|
||||
const testMode = cliArguments.testMode || 'All';
|
||||
|
||||
core.info(`Running Unity tests (${testMode})...`);
|
||||
core.info(`Target platform: ${buildParameters.targetPlatform}`);
|
||||
core.info(`Unity version: ${buildParameters.editorVersion}`);
|
||||
core.info(`Project path: ${buildParameters.projectPath}`);
|
||||
|
||||
const actionFolder = Action.actionFolder;
|
||||
await PlatformSetup.setup(buildParameters, actionFolder);
|
||||
|
||||
const exitCode = await Docker.run(baseImage.toString(), {
|
||||
workspace: process.cwd(),
|
||||
actionFolder,
|
||||
...buildParameters,
|
||||
});
|
||||
|
||||
const resultsPath = cliArguments.testResultsPath || 'test-results';
|
||||
core.info(`\nTests completed with exit code: ${exitCode}`);
|
||||
core.info(`Test results: ${resultsPath}`);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Tests failed with exit code ${exitCode}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
core.setFailed(`Tests failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default testCommand;
|
||||
@@ -1,387 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const REPO = 'game-ci/unity-builder';
|
||||
|
||||
interface GitHubRelease {
|
||||
// eslint-disable-next-line camelcase
|
||||
tag_name: string;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface UpdateArguments {
|
||||
force?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches JSON from a URL via HTTPS, following redirects.
|
||||
*/
|
||||
function fetchJson(url: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const get = (targetUrl: string, redirectCount: number) => {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error('Too many redirects'));
|
||||
|
||||
return;
|
||||
}
|
||||
https
|
||||
.get(
|
||||
targetUrl,
|
||||
{
|
||||
headers: { 'User-Agent': 'game-ci-cli', Accept: 'application/json' },
|
||||
},
|
||||
(response) => {
|
||||
if (
|
||||
response.statusCode &&
|
||||
response.statusCode >= 300 &&
|
||||
response.statusCode < 400 &&
|
||||
response.headers.location
|
||||
) {
|
||||
get(response.headers.location, redirectCount + 1);
|
||||
|
||||
return;
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode} from ${targetUrl}`));
|
||||
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
response.on('data', (chunk) => (data += chunk));
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
reject(new Error('Invalid JSON response'));
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
.on('error', reject);
|
||||
};
|
||||
get(url, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a file from a URL, following redirects. Returns the file content as a Buffer.
|
||||
*/
|
||||
function downloadFile(url: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const get = (targetUrl: string, redirectCount: number) => {
|
||||
if (redirectCount > 10) {
|
||||
reject(new Error('Too many redirects'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = targetUrl.startsWith('https') ? https : http;
|
||||
protocol
|
||||
.get(targetUrl, { headers: { 'User-Agent': 'game-ci-cli' } }, (response) => {
|
||||
if (
|
||||
response.statusCode &&
|
||||
response.statusCode >= 300 &&
|
||||
response.statusCode < 400 &&
|
||||
response.headers.location
|
||||
) {
|
||||
get(response.headers.location, redirectCount + 1);
|
||||
|
||||
return;
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode} downloading ${targetUrl}`));
|
||||
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
response.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
})
|
||||
.on('error', reject);
|
||||
};
|
||||
get(url, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current version from package.json or the compiled binary.
|
||||
*/
|
||||
function getCurrentVersion(): string {
|
||||
// Try reading from package.json at various relative locations
|
||||
const candidates = [
|
||||
path.join(__dirname, '..', '..', '..', 'package.json'),
|
||||
path.join(__dirname, '..', '..', 'package.json'),
|
||||
path.join(process.cwd(), 'package.json'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const packageData = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
||||
if (packageData.version) {
|
||||
return packageData.version;
|
||||
}
|
||||
} catch {
|
||||
// Continue to next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the correct asset name for the current platform/architecture.
|
||||
*/
|
||||
function getAssetName(): string {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
let osPart: string;
|
||||
switch (platform) {
|
||||
case 'linux':
|
||||
osPart = 'linux';
|
||||
break;
|
||||
case 'darwin':
|
||||
osPart = 'macos';
|
||||
break;
|
||||
case 'win32':
|
||||
osPart = 'windows';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
let archPart: string;
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
archPart = 'x64';
|
||||
break;
|
||||
case 'arm64':
|
||||
archPart = 'arm64';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported architecture: ${arch}`);
|
||||
}
|
||||
|
||||
const assetBaseName = `game-ci-${osPart}-${archPart}`;
|
||||
|
||||
return osPart === 'windows' ? `${assetBaseName}.exe` : assetBaseName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the path to the currently running executable.
|
||||
* For standalone binaries (pkg), process.execPath points to the binary itself.
|
||||
* For Node.js execution, we return undefined since self-update does not apply.
|
||||
*/
|
||||
function getExecutablePath(): string | undefined {
|
||||
// When running as a pkg binary, process.pkg is defined
|
||||
if ((process as any).pkg) {
|
||||
return process.execPath;
|
||||
}
|
||||
|
||||
// When running via Node.js, check if there is a standalone binary in the typical install location
|
||||
const installDirectory = process.env.GAME_CI_INSTALL || path.join(os.homedir(), '.game-ci', 'bin');
|
||||
const binaryName = process.platform === 'win32' ? 'game-ci.exe' : 'game-ci';
|
||||
const installedPath = path.join(installDirectory, binaryName);
|
||||
|
||||
if (fs.existsSync(installedPath)) {
|
||||
return installedPath;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips leading 'v' from a version string and splits into numeric parts.
|
||||
*/
|
||||
function parseVersionParts(version: string): number[] {
|
||||
return version
|
||||
.replace(/^v/, '')
|
||||
.split('.')
|
||||
.map((part) => Number(part));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two semver strings. Returns:
|
||||
* -1 if a < b
|
||||
* 0 if a == b
|
||||
* 1 if a > b
|
||||
*/
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const partsA = parseVersionParts(a);
|
||||
const partsB = parseVersionParts(b);
|
||||
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const x = partsA[index] || 0;
|
||||
const y = partsB[index] || 0;
|
||||
if (x < y) return -1;
|
||||
if (x > y) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
const updateCommand: CommandModule<object, UpdateArguments> = {
|
||||
command: 'update',
|
||||
describe: 'Update game-ci to the latest version',
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option('force', {
|
||||
alias: 'f',
|
||||
type: 'boolean',
|
||||
description: 'Force update even if already on latest version',
|
||||
default: false,
|
||||
})
|
||||
.option('version', {
|
||||
type: 'string',
|
||||
description: 'Update to a specific version (e.g., v2.0.0)',
|
||||
default: '',
|
||||
})
|
||||
.example('game-ci update', 'Update to the latest version')
|
||||
.example('game-ci update --version v2.1.0', 'Update to a specific version')
|
||||
.example('game-ci update --force', 'Force reinstall of the current version') as any;
|
||||
},
|
||||
handler: async (cliArguments) => {
|
||||
try {
|
||||
const currentVersion = getCurrentVersion();
|
||||
core.info(`Current version: v${currentVersion}`);
|
||||
core.info(`Platform: ${process.platform} ${process.arch}`);
|
||||
core.info('');
|
||||
|
||||
// Fetch release info
|
||||
let release: GitHubRelease;
|
||||
const targetVersion = cliArguments.version as string;
|
||||
|
||||
if (targetVersion) {
|
||||
const tag = targetVersion.startsWith('v') ? targetVersion : `v${targetVersion}`;
|
||||
core.info(`Fetching release ${tag}...`);
|
||||
release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`);
|
||||
} else {
|
||||
core.info('Checking for updates...');
|
||||
release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
|
||||
}
|
||||
|
||||
const latestVersion = release.tag_name;
|
||||
core.info(`Latest version: ${latestVersion}`);
|
||||
core.info('');
|
||||
|
||||
// Compare versions
|
||||
const comparison = compareSemver(currentVersion, latestVersion);
|
||||
if (comparison >= 0 && !cliArguments.force) {
|
||||
core.info('You are already on the latest version. Use --force to reinstall.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison > 0 && !targetVersion) {
|
||||
core.info(`Current version (v${currentVersion}) is newer than latest release (${latestVersion}).`);
|
||||
core.info('Use --force to downgrade, or --version to target a specific release.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the correct asset
|
||||
const assetName = getAssetName();
|
||||
const asset = release.assets.find((a) => a.name === assetName);
|
||||
|
||||
if (!asset) {
|
||||
const available = release.assets.map((a) => a.name).join(', ');
|
||||
throw new Error(
|
||||
`No binary found for ${process.platform}-${process.arch} (looking for ${assetName}).\nAvailable assets: ${available}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sizeMb = (asset.size / (1024 * 1024)).toFixed(1);
|
||||
core.info(`Downloading ${assetName} (${sizeMb} MB)...`);
|
||||
|
||||
// Download the new binary
|
||||
const binaryData = await downloadFile(asset.browser_download_url);
|
||||
|
||||
// Determine where to write the updated binary
|
||||
const executablePath = getExecutablePath();
|
||||
|
||||
if (!executablePath) {
|
||||
core.info('');
|
||||
core.info('game-ci is running via Node.js (not as a standalone binary).');
|
||||
core.info('To update the npm package, run:');
|
||||
core.info(' npm install -g unity-builder@latest');
|
||||
core.info('');
|
||||
core.info('To install the standalone binary instead:');
|
||||
core.info(' curl -fsSL https://raw.githubusercontent.com/game-ci/unity-builder/main/install.sh | sh');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Write the new binary.
|
||||
// On Windows, we cannot overwrite a running executable directly.
|
||||
// Write to a temporary file, then rename.
|
||||
const temporaryPath = `${executablePath}.update`;
|
||||
const backupPath = `${executablePath}.backup`;
|
||||
|
||||
fs.writeFileSync(temporaryPath, binaryData);
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(temporaryPath, 0o755);
|
||||
}
|
||||
|
||||
// Verify the downloaded binary
|
||||
try {
|
||||
const output = execFileSync(temporaryPath, ['version'], { encoding: 'utf8', timeout: 10_000 });
|
||||
core.info(`Verified new binary: ${output.trim().split('\n')[0]}`);
|
||||
} catch (verifyError: any) {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
throw new Error(`Downloaded binary failed verification: ${verifyError.message}`);
|
||||
}
|
||||
|
||||
// Replace the current binary
|
||||
try {
|
||||
// Backup current
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.unlinkSync(backupPath);
|
||||
}
|
||||
fs.renameSync(executablePath, backupPath);
|
||||
fs.renameSync(temporaryPath, executablePath);
|
||||
|
||||
// Clean up backup
|
||||
try {
|
||||
fs.unlinkSync(backupPath);
|
||||
} catch {
|
||||
// On Windows the backup may be locked; that is fine
|
||||
}
|
||||
} catch (replaceError: any) {
|
||||
// Attempt to restore from backup
|
||||
if (fs.existsSync(backupPath) && !fs.existsSync(executablePath)) {
|
||||
fs.renameSync(backupPath, executablePath);
|
||||
}
|
||||
|
||||
// Clean up temporary file
|
||||
if (fs.existsSync(temporaryPath)) {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
}
|
||||
throw new Error(`Failed to replace binary: ${replaceError.message}`);
|
||||
}
|
||||
|
||||
core.info('');
|
||||
core.info(`Successfully updated game-ci to ${latestVersion}`);
|
||||
} catch (error: any) {
|
||||
core.error(`Update failed: ${error.message}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default updateCommand;
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as core from '@actions/core';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const versionCommand: CommandModule = {
|
||||
command: 'version',
|
||||
describe: 'Show version info',
|
||||
builder: {},
|
||||
handler: async () => {
|
||||
try {
|
||||
// Read version from package.json
|
||||
let packageJsonPath = path.join(__dirname, '..', '..', '..', 'package.json');
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
|
||||
}
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
packageJsonPath = path.join(process.cwd(), 'package.json');
|
||||
}
|
||||
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const packageData = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
core.info(`game-ci (unity-builder) v${packageData.version}`);
|
||||
core.info(`Node.js ${process.version}`);
|
||||
core.info(`Platform: ${process.platform} ${process.arch}`);
|
||||
} else {
|
||||
core.info('game-ci (unity-builder)');
|
||||
core.info('Version information unavailable');
|
||||
}
|
||||
} catch (error: any) {
|
||||
core.info('game-ci (unity-builder)');
|
||||
core.error(`Could not read version: ${error.message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default versionCommand;
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Cli } from '../model/cli/cli';
|
||||
import GitHub from '../model/github';
|
||||
|
||||
/**
|
||||
* Maps CLI arguments (kebab-case flags) to the Input/OrchestratorOptions
|
||||
* interface used by the action. This bridges the gap between user-friendly
|
||||
* CLI flags and the camelCase environment/input system unity-builder expects.
|
||||
*
|
||||
* The existing Input class already queries Cli.options, environment variables,
|
||||
* and GitHub Action inputs in priority order. We populate Cli.options so that
|
||||
* the rest of the codebase works unchanged.
|
||||
*/
|
||||
export interface CliArguments {
|
||||
targetPlatform?: string;
|
||||
unityVersion?: string;
|
||||
projectPath?: string;
|
||||
buildProfile?: string;
|
||||
buildName?: string;
|
||||
buildsPath?: string;
|
||||
buildMethod?: string;
|
||||
customParameters?: string;
|
||||
versioning?: string;
|
||||
version?: string;
|
||||
customImage?: string;
|
||||
manualExit?: boolean;
|
||||
enableGpu?: boolean;
|
||||
|
||||
androidVersionCode?: string;
|
||||
androidExportType?: string;
|
||||
androidKeystoreName?: string;
|
||||
androidKeystoreBase64?: string;
|
||||
androidKeystorePass?: string;
|
||||
androidKeyaliasName?: string;
|
||||
androidKeyaliasPass?: string;
|
||||
androidTargetSdkVersion?: string;
|
||||
androidSymbolType?: string;
|
||||
|
||||
dockerCpuLimit?: string;
|
||||
dockerMemoryLimit?: string;
|
||||
dockerIsolationMode?: string;
|
||||
dockerWorkspacePath?: string;
|
||||
containerRegistryRepository?: string;
|
||||
containerRegistryImageVersion?: string;
|
||||
runAsHostUser?: string;
|
||||
chownFilesTo?: string;
|
||||
|
||||
sshAgent?: string;
|
||||
sshPublicKeysDirectoryPath?: string;
|
||||
gitPrivateToken?: string;
|
||||
|
||||
providerStrategy?: string;
|
||||
awsStackName?: string;
|
||||
kubeConfig?: string;
|
||||
kubeVolume?: string;
|
||||
kubeVolumeSize?: string;
|
||||
kubeStorageClass?: string;
|
||||
containerCpu?: string;
|
||||
containerMemory?: string;
|
||||
cacheKey?: string;
|
||||
watchToEnd?: string;
|
||||
allowDirtyBuild?: boolean;
|
||||
skipActivation?: string;
|
||||
cloneDepth?: string;
|
||||
|
||||
readInputFromOverrideList?: string;
|
||||
readInputOverrideCommand?: string;
|
||||
postBuildSteps?: string;
|
||||
preBuildSteps?: string;
|
||||
customJob?: string;
|
||||
|
||||
unityLicensingServer?: string;
|
||||
|
||||
cacheUnityInstallationOnMac?: boolean;
|
||||
unityHubVersionOnMac?: string;
|
||||
|
||||
testMode?: string;
|
||||
testResultsPath?: string;
|
||||
testCategory?: string;
|
||||
testFilter?: string;
|
||||
coverageOptions?: string;
|
||||
enableCodeCoverage?: boolean;
|
||||
|
||||
mode?: string;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts kebab-case CLI flags to camelCase keys matching the Input class
|
||||
* property names, then injects them into Cli.options so the existing
|
||||
* Input.getInput() / OrchestratorOptions.getInput() chain picks them up.
|
||||
*/
|
||||
export function mapCliArgumentsToInput(cliArguments: CliArguments): void {
|
||||
// Disable GitHub Actions input reading when in CLI mode
|
||||
GitHub.githubInputEnabled = false;
|
||||
|
||||
// The existing Cli.options mechanism is used by Input.getInput() to query
|
||||
// CLI-provided values. We set it directly.
|
||||
const mapped: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(cliArguments)) {
|
||||
if (value !== undefined && key !== '_' && key !== '$0') {
|
||||
mapped[key] = typeof value === 'boolean' ? String(value) : value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure mode is set so Cli.isCliMode returns true
|
||||
if (!mapped['mode']) {
|
||||
mapped['mode'] = 'cli';
|
||||
}
|
||||
|
||||
Cli.options = mapped;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as core from '@actions/core';
|
||||
import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output } from './model';
|
||||
import { Action, BuildParameters, Cache, CloudRunner, Docker, ImageTag, Output } from './model';
|
||||
import { Cli } from './model/cli/cli';
|
||||
import MacBuilder from './model/mac-builder';
|
||||
import PlatformSetup from './model/platform-setup';
|
||||
@@ -33,7 +33,7 @@ async function runMain() {
|
||||
...buildParameters,
|
||||
});
|
||||
} else {
|
||||
await Orchestrator.run(buildParameters, baseImage.toString());
|
||||
await CloudRunner.run(buildParameters, baseImage.toString());
|
||||
exitCode = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
// Integration test for exercising real GitHub check creation and updates.
|
||||
import Orchestrator from '../model/orchestrator/orchestrator';
|
||||
import UnityVersioning from '../model/unity-versioning';
|
||||
import GitHub from '../model/github';
|
||||
import { TIMEOUT_INFINITE, createParameters } from '../test-utils/orchestrator-test-helpers';
|
||||
|
||||
const runIntegration = process.env.RUN_GITHUB_INTEGRATION_TESTS === 'true';
|
||||
const describeOrSkip = runIntegration ? describe : describe.skip;
|
||||
|
||||
describeOrSkip('Orchestrator Github Checks Integration', () => {
|
||||
it(
|
||||
'creates and updates a real GitHub check',
|
||||
async () => {
|
||||
const buildParameter = await createParameters({
|
||||
versioning: 'None',
|
||||
projectPath: 'test-project',
|
||||
unityVersion: UnityVersioning.read('test-project'),
|
||||
asyncOrchestrator: `true`,
|
||||
githubChecks: `true`,
|
||||
});
|
||||
await Orchestrator.setup(buildParameter);
|
||||
const checkId = await GitHub.createGitHubCheck(`integration create`);
|
||||
expect(checkId).not.toEqual('');
|
||||
await GitHub.updateGitHubCheck(`1 ${new Date().toISOString()}`, `integration`);
|
||||
await GitHub.updateGitHubCheck(`2 ${new Date().toISOString()}`, `integration`, `success`, `completed`);
|
||||
},
|
||||
TIMEOUT_INFINITE,
|
||||
);
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
import { fetch as undiciFetch, Headers, Request, Response } from 'undici';
|
||||
|
||||
Object.assign(globalThis, { fetch: undiciFetch, Headers, Request, Response });
|
||||
@@ -1,7 +1,7 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import AndroidVersioning from './android-versioning';
|
||||
import OrchestratorConstants from './orchestrator/options/orchestrator-constants';
|
||||
import OrchestratorBuildGuid from './orchestrator/options/orchestrator-guid';
|
||||
import CloudRunnerConstants from './cloud-runner/options/cloud-runner-constants';
|
||||
import CloudRunnerBuildGuid from './cloud-runner/options/cloud-runner-guid';
|
||||
import Input from './input';
|
||||
import Platform from './platform';
|
||||
import UnityVersioning from './unity-versioning';
|
||||
@@ -10,8 +10,8 @@ import { GitRepoReader } from './input-readers/git-repo';
|
||||
import { GithubCliReader } from './input-readers/github-cli';
|
||||
import { Cli } from './cli/cli';
|
||||
import GitHub from './github';
|
||||
import OrchestratorOptions from './orchestrator/options/orchestrator-options';
|
||||
import Orchestrator from './orchestrator/orchestrator';
|
||||
import CloudRunnerOptions from './cloud-runner/options/cloud-runner-options';
|
||||
import CloudRunner from './cloud-runner/cloud-runner';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
class BuildParameters {
|
||||
@@ -56,18 +56,9 @@ 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 storageProvider!: string;
|
||||
public rcloneRemote!: string;
|
||||
public kubeConfig!: string;
|
||||
public containerMemory!: string;
|
||||
public containerCpu!: string;
|
||||
public containerNamespace!: string;
|
||||
public kubeVolumeSize!: string;
|
||||
public kubeVolume!: string;
|
||||
public kubeStorageClass!: string;
|
||||
@@ -84,13 +75,11 @@ class BuildParameters {
|
||||
public runNumber!: string;
|
||||
public branch!: string;
|
||||
public githubRepo!: string;
|
||||
public orchestratorRepoName!: string;
|
||||
public cloneDepth!: number;
|
||||
public gitSha!: string;
|
||||
public logId!: string;
|
||||
public buildGuid!: string;
|
||||
public orchestratorBranch!: string;
|
||||
public orchestratorDebug!: boolean | undefined;
|
||||
public cloudRunnerBranch!: string;
|
||||
public cloudRunnerDebug!: boolean | undefined;
|
||||
public buildPlatform!: string | undefined;
|
||||
public isCliMode!: boolean;
|
||||
public maxRetainedWorkspaces!: number;
|
||||
@@ -108,7 +97,7 @@ class BuildParameters {
|
||||
public dockerWorkspacePath!: string;
|
||||
|
||||
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
|
||||
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
|
||||
return buildParameters.maxRetainedWorkspaces > 0 && CloudRunner.lockedWorkspace !== ``;
|
||||
}
|
||||
|
||||
static async create(): Promise<BuildParameters> {
|
||||
@@ -193,52 +182,41 @@ class BuildParameters {
|
||||
dockerIsolationMode: Input.dockerIsolationMode,
|
||||
containerRegistryRepository: Input.containerRegistryRepository,
|
||||
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
||||
providerStrategy: OrchestratorOptions.providerStrategy,
|
||||
buildPlatform: OrchestratorOptions.buildPlatform,
|
||||
kubeConfig: OrchestratorOptions.kubeConfig,
|
||||
containerMemory: OrchestratorOptions.containerMemory,
|
||||
containerCpu: OrchestratorOptions.containerCpu,
|
||||
containerNamespace: OrchestratorOptions.containerNamespace,
|
||||
kubeVolumeSize: OrchestratorOptions.kubeVolumeSize,
|
||||
kubeVolume: OrchestratorOptions.kubeVolume,
|
||||
postBuildContainerHooks: OrchestratorOptions.postBuildContainerHooks,
|
||||
preBuildContainerHooks: OrchestratorOptions.preBuildContainerHooks,
|
||||
customJob: OrchestratorOptions.customJob,
|
||||
providerStrategy: CloudRunnerOptions.providerStrategy,
|
||||
buildPlatform: CloudRunnerOptions.buildPlatform,
|
||||
kubeConfig: CloudRunnerOptions.kubeConfig,
|
||||
containerMemory: CloudRunnerOptions.containerMemory,
|
||||
containerCpu: CloudRunnerOptions.containerCpu,
|
||||
kubeVolumeSize: CloudRunnerOptions.kubeVolumeSize,
|
||||
kubeVolume: CloudRunnerOptions.kubeVolume,
|
||||
postBuildContainerHooks: CloudRunnerOptions.postBuildContainerHooks,
|
||||
preBuildContainerHooks: CloudRunnerOptions.preBuildContainerHooks,
|
||||
customJob: CloudRunnerOptions.customJob,
|
||||
runNumber: Input.runNumber,
|
||||
branch: Input.branch.replace('/head', '') || (await GitRepoReader.GetBranch()),
|
||||
orchestratorBranch: OrchestratorOptions.orchestratorBranch.split('/').reverse()[0],
|
||||
orchestratorDebug: OrchestratorOptions.orchestratorDebug,
|
||||
githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || OrchestratorOptions.orchestratorRepoName,
|
||||
orchestratorRepoName: OrchestratorOptions.orchestratorRepoName,
|
||||
cloneDepth: Number.parseInt(OrchestratorOptions.cloneDepth),
|
||||
cloudRunnerBranch: CloudRunnerOptions.cloudRunnerBranch.split('/').reverse()[0],
|
||||
cloudRunnerDebug: CloudRunnerOptions.cloudRunnerDebug,
|
||||
githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || 'game-ci/unity-builder',
|
||||
isCliMode: Cli.isCliMode,
|
||||
awsStackName: OrchestratorOptions.awsStackName,
|
||||
awsEndpoint: OrchestratorOptions.awsEndpoint,
|
||||
awsCloudFormationEndpoint: OrchestratorOptions.awsCloudFormationEndpoint,
|
||||
awsEcsEndpoint: OrchestratorOptions.awsEcsEndpoint,
|
||||
awsKinesisEndpoint: OrchestratorOptions.awsKinesisEndpoint,
|
||||
awsCloudWatchLogsEndpoint: OrchestratorOptions.awsCloudWatchLogsEndpoint,
|
||||
awsS3Endpoint: OrchestratorOptions.awsS3Endpoint,
|
||||
storageProvider: OrchestratorOptions.storageProvider,
|
||||
rcloneRemote: OrchestratorOptions.rcloneRemote,
|
||||
awsStackName: CloudRunnerOptions.awsStackName,
|
||||
gitSha: Input.gitSha,
|
||||
logId: customAlphabet(OrchestratorConstants.alphabet, 9)(),
|
||||
buildGuid: OrchestratorBuildGuid.generateGuid(Input.runNumber, Input.targetPlatform),
|
||||
commandHooks: OrchestratorOptions.commandHooks,
|
||||
inputPullCommand: OrchestratorOptions.inputPullCommand,
|
||||
pullInputList: OrchestratorOptions.pullInputList,
|
||||
kubeStorageClass: OrchestratorOptions.kubeStorageClass,
|
||||
cacheKey: OrchestratorOptions.cacheKey,
|
||||
maxRetainedWorkspaces: Number.parseInt(OrchestratorOptions.maxRetainedWorkspaces),
|
||||
useLargePackages: OrchestratorOptions.useLargePackages,
|
||||
useCompressionStrategy: OrchestratorOptions.useCompressionStrategy,
|
||||
garbageMaxAge: OrchestratorOptions.garbageMaxAge,
|
||||
githubChecks: OrchestratorOptions.githubChecks,
|
||||
asyncWorkflow: OrchestratorOptions.asyncOrchestrator,
|
||||
githubCheckId: OrchestratorOptions.githubCheckId,
|
||||
finalHooks: OrchestratorOptions.finalHooks,
|
||||
skipLfs: OrchestratorOptions.skipLfs,
|
||||
skipCache: OrchestratorOptions.skipCache,
|
||||
logId: customAlphabet(CloudRunnerConstants.alphabet, 9)(),
|
||||
buildGuid: CloudRunnerBuildGuid.generateGuid(Input.runNumber, Input.targetPlatform),
|
||||
commandHooks: CloudRunnerOptions.commandHooks,
|
||||
inputPullCommand: CloudRunnerOptions.inputPullCommand,
|
||||
pullInputList: CloudRunnerOptions.pullInputList,
|
||||
kubeStorageClass: CloudRunnerOptions.kubeStorageClass,
|
||||
cacheKey: CloudRunnerOptions.cacheKey,
|
||||
maxRetainedWorkspaces: Number.parseInt(CloudRunnerOptions.maxRetainedWorkspaces),
|
||||
useLargePackages: CloudRunnerOptions.useLargePackages,
|
||||
useCompressionStrategy: CloudRunnerOptions.useCompressionStrategy,
|
||||
garbageMaxAge: CloudRunnerOptions.garbageMaxAge,
|
||||
githubChecks: CloudRunnerOptions.githubChecks,
|
||||
asyncWorkflow: CloudRunnerOptions.asyncCloudRunner,
|
||||
githubCheckId: CloudRunnerOptions.githubCheckId,
|
||||
finalHooks: CloudRunnerOptions.finalHooks,
|
||||
skipLfs: CloudRunnerOptions.skipLfs,
|
||||
skipCache: CloudRunnerOptions.skipCache,
|
||||
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
|
||||
unityHubVersionOnMac: Input.unityHubVersionOnMac,
|
||||
dockerWorkspacePath: Input.dockerWorkspacePath,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Command } from 'commander-ts';
|
||||
import { BuildParameters, Orchestrator, ImageTag, Input } from '..';
|
||||
import { BuildParameters, CloudRunner, ImageTag, Input } from '..';
|
||||
import * as core from '@actions/core';
|
||||
import { ActionYamlReader } from '../input-readers/action-yaml';
|
||||
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';
|
||||
import OrchestratorQueryOverride from '../orchestrator/options/orchestrator-query-override';
|
||||
import CloudRunnerLogger from '../cloud-runner/services/core/cloud-runner-logger';
|
||||
import CloudRunnerQueryOverride from '../cloud-runner/options/cloud-runner-query-override';
|
||||
import { CliFunction, CliFunctionsRepository } from './cli-functions-repository';
|
||||
import { Caching } from '../orchestrator/remote-client/caching';
|
||||
import { LfsHashing } from '../orchestrator/services/utility/lfs-hashing';
|
||||
import { RemoteClient } from '../orchestrator/remote-client';
|
||||
import OrchestratorOptionsReader from '../orchestrator/options/orchestrator-options-reader';
|
||||
import { Caching } from '../cloud-runner/remote-client/caching';
|
||||
import { LfsHashing } from '../cloud-runner/services/utility/lfs-hashing';
|
||||
import { RemoteClient } from '../cloud-runner/remote-client';
|
||||
import CloudRunnerOptionsReader from '../cloud-runner/options/cloud-runner-options-reader';
|
||||
import GitHub from '../github';
|
||||
import { OptionValues } from 'commander';
|
||||
import { InputKey } from '../input';
|
||||
@@ -36,7 +36,7 @@ export class Cli {
|
||||
const program = new Command();
|
||||
program.version('0.0.1');
|
||||
|
||||
const properties = OrchestratorOptionsReader.GetProperties();
|
||||
const properties = CloudRunnerOptionsReader.GetProperties();
|
||||
const actionYamlReader: ActionYamlReader = new ActionYamlReader();
|
||||
for (const element of properties) {
|
||||
program.option(`--${element} <${element}>`, actionYamlReader.GetActionYamlValue(element));
|
||||
@@ -62,23 +62,23 @@ export class Cli {
|
||||
static async RunCli(): Promise<void> {
|
||||
GitHub.githubInputEnabled = false;
|
||||
if (Cli.options!['populateOverride'] === `true`) {
|
||||
await OrchestratorQueryOverride.PopulateQueryOverrideInput();
|
||||
await CloudRunnerQueryOverride.PopulateQueryOverrideInput();
|
||||
}
|
||||
if (Cli.options!['logInput']) {
|
||||
Cli.logInput();
|
||||
}
|
||||
const results = CliFunctionsRepository.GetCliFunctions(Cli.options?.mode);
|
||||
OrchestratorLogger.log(`Entrypoint: ${results.key}`);
|
||||
CloudRunnerLogger.log(`Entrypoint: ${results.key}`);
|
||||
Cli.options!.versioning = 'None';
|
||||
|
||||
Orchestrator.buildParameters = await BuildParameters.create();
|
||||
Orchestrator.buildParameters.buildGuid = process.env.BUILD_GUID || ``;
|
||||
OrchestratorLogger.log(`Build Params:
|
||||
${JSON.stringify(Orchestrator.buildParameters, undefined, 4)}
|
||||
CloudRunner.buildParameters = await BuildParameters.create();
|
||||
CloudRunner.buildParameters.buildGuid = process.env.BUILD_GUID || ``;
|
||||
CloudRunnerLogger.log(`Build Params:
|
||||
${JSON.stringify(CloudRunner.buildParameters, undefined, 4)}
|
||||
`);
|
||||
Orchestrator.lockedWorkspace = process.env.LOCKED_WORKSPACE || ``;
|
||||
OrchestratorLogger.log(`Locked Workspace: ${Orchestrator.lockedWorkspace}`);
|
||||
await Orchestrator.setup(Orchestrator.buildParameters);
|
||||
CloudRunner.lockedWorkspace = process.env.LOCKED_WORKSPACE || ``;
|
||||
CloudRunnerLogger.log(`Locked Workspace: ${CloudRunner.lockedWorkspace}`);
|
||||
await CloudRunner.setup(CloudRunner.buildParameters);
|
||||
|
||||
return await results.target[results.propertyKey](Cli.options);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export class Cli {
|
||||
private static logInput() {
|
||||
core.info(`\n`);
|
||||
core.info(`INPUT:`);
|
||||
const properties = OrchestratorOptionsReader.GetProperties();
|
||||
const properties = CloudRunnerOptionsReader.GetProperties();
|
||||
for (const element of properties) {
|
||||
if (
|
||||
element in Input &&
|
||||
@@ -104,28 +104,28 @@ export class Cli {
|
||||
core.info(`\n`);
|
||||
}
|
||||
|
||||
@CliFunction(`cli-build`, `runs a orchestrator build`)
|
||||
@CliFunction(`cli-build`, `runs a cloud runner build`)
|
||||
public static async CLIBuild(): Promise<string> {
|
||||
const buildParameter = await BuildParameters.create();
|
||||
const baseImage = new ImageTag(buildParameter);
|
||||
|
||||
return (await Orchestrator.run(buildParameter, baseImage.toString())).BuildResults;
|
||||
return (await CloudRunner.run(buildParameter, baseImage.toString())).BuildResults;
|
||||
}
|
||||
|
||||
@CliFunction(`async-workflow`, `runs a orchestrator build`)
|
||||
@CliFunction(`async-workflow`, `runs a cloud runner build`)
|
||||
public static async asyncronousWorkflow(): Promise<string> {
|
||||
const buildParameter = await BuildParameters.create();
|
||||
const baseImage = new ImageTag(buildParameter);
|
||||
await Orchestrator.setup(buildParameter);
|
||||
await CloudRunner.setup(buildParameter);
|
||||
|
||||
return (await Orchestrator.run(buildParameter, baseImage.toString())).BuildResults;
|
||||
return (await CloudRunner.run(buildParameter, baseImage.toString())).BuildResults;
|
||||
}
|
||||
|
||||
@CliFunction(`checks-update`, `runs a orchestrator build`)
|
||||
@CliFunction(`checks-update`, `runs a cloud runner build`)
|
||||
public static async checksUpdate() {
|
||||
const buildParameter = await BuildParameters.create();
|
||||
|
||||
await Orchestrator.setup(buildParameter);
|
||||
await CloudRunner.setup(buildParameter);
|
||||
const input = JSON.parse(process.env.CHECKS_UPDATE || ``);
|
||||
core.info(`Checks Update ${process.env.CHECKS_UPDATE}`);
|
||||
if (input.mode === `create`) {
|
||||
@@ -139,37 +139,37 @@ export class Cli {
|
||||
public static async GarbageCollect(): Promise<string> {
|
||||
const buildParameter = await BuildParameters.create();
|
||||
|
||||
await Orchestrator.setup(buildParameter);
|
||||
await CloudRunner.setup(buildParameter);
|
||||
|
||||
return await Orchestrator.Provider.garbageCollect(``, false, 0, false, false);
|
||||
return await CloudRunner.Provider.garbageCollect(``, false, 0, false, false);
|
||||
}
|
||||
|
||||
@CliFunction(`list-resources`, `lists active resources`)
|
||||
public static async ListResources(): Promise<string[]> {
|
||||
const buildParameter = await BuildParameters.create();
|
||||
|
||||
await Orchestrator.setup(buildParameter);
|
||||
const result = await Orchestrator.Provider.listResources();
|
||||
OrchestratorLogger.log(JSON.stringify(result, undefined, 4));
|
||||
await CloudRunner.setup(buildParameter);
|
||||
const result = await CloudRunner.Provider.listResources();
|
||||
CloudRunnerLogger.log(JSON.stringify(result, undefined, 4));
|
||||
|
||||
return result.map((x) => x.Name);
|
||||
}
|
||||
|
||||
@CliFunction(`list-workflow`, `lists running workflows`)
|
||||
public static async ListWorkflow(): Promise<string[]> {
|
||||
@CliFunction(`list-worfklow`, `lists running workflows`)
|
||||
public static async ListWorfklow(): Promise<string[]> {
|
||||
const buildParameter = await BuildParameters.create();
|
||||
|
||||
await Orchestrator.setup(buildParameter);
|
||||
await CloudRunner.setup(buildParameter);
|
||||
|
||||
return (await Orchestrator.Provider.listWorkflow()).map((x) => x.Name);
|
||||
return (await CloudRunner.Provider.listWorkflow()).map((x) => x.Name);
|
||||
}
|
||||
|
||||
@CliFunction(`watch`, `follows logs of a running workflow`)
|
||||
public static async Watch(): Promise<string> {
|
||||
const buildParameter = await BuildParameters.create();
|
||||
|
||||
await Orchestrator.setup(buildParameter);
|
||||
await CloudRunner.setup(buildParameter);
|
||||
|
||||
return await Orchestrator.Provider.watchWorkflow();
|
||||
return await CloudRunner.Provider.watchWorkflow();
|
||||
}
|
||||
}
|
||||
|
||||
187
src/model/cloud-runner/cloud-runner.ts
Normal file
187
src/model/cloud-runner/cloud-runner.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import AwsBuildPlatform from './providers/aws';
|
||||
import { BuildParameters, Input } from '..';
|
||||
import Kubernetes from './providers/k8s';
|
||||
import CloudRunnerLogger from './services/core/cloud-runner-logger';
|
||||
import { CloudRunnerStepParameters } from './options/cloud-runner-step-parameters';
|
||||
import { WorkflowCompositionRoot } from './workflows/workflow-composition-root';
|
||||
import { CloudRunnerError } from './error/cloud-runner-error';
|
||||
import { TaskParameterSerializer } from './services/core/task-parameter-serializer';
|
||||
import * as core from '@actions/core';
|
||||
import CloudRunnerSecret from './options/cloud-runner-secret';
|
||||
import { ProviderInterface } from './providers/provider-interface';
|
||||
import CloudRunnerEnvironmentVariable from './options/cloud-runner-environment-variable';
|
||||
import TestCloudRunner from './providers/test';
|
||||
import LocalCloudRunner from './providers/local';
|
||||
import LocalDockerCloudRunner from './providers/docker';
|
||||
import GitHub from '../github';
|
||||
import SharedWorkspaceLocking from './services/core/shared-workspace-locking';
|
||||
import { FollowLogStreamService } from './services/core/follow-log-stream-service';
|
||||
import CloudRunnerResult from './services/core/cloud-runner-result';
|
||||
|
||||
class CloudRunner {
|
||||
public static Provider: ProviderInterface;
|
||||
public static buildParameters: BuildParameters;
|
||||
private static defaultSecrets: CloudRunnerSecret[];
|
||||
private static cloudRunnerEnvironmentVariables: CloudRunnerEnvironmentVariable[];
|
||||
static lockedWorkspace: string = ``;
|
||||
public static readonly retainedWorkspacePrefix: string = `retained-workspace`;
|
||||
public static get isCloudRunnerEnvironment() {
|
||||
return process.env[`GITHUB_ACTIONS`] !== `true`;
|
||||
}
|
||||
public static get isCloudRunnerAsyncEnvironment() {
|
||||
return process.env[`ASYNC_WORKFLOW`] === `true`;
|
||||
}
|
||||
public static async setup(buildParameters: BuildParameters) {
|
||||
CloudRunnerLogger.setup();
|
||||
CloudRunnerLogger.log(`Setting up cloud runner`);
|
||||
CloudRunner.buildParameters = buildParameters;
|
||||
if (CloudRunner.buildParameters.githubCheckId === ``) {
|
||||
CloudRunner.buildParameters.githubCheckId = await GitHub.createGitHubCheck(CloudRunner.buildParameters.buildGuid);
|
||||
}
|
||||
CloudRunner.setupSelectedBuildPlatform();
|
||||
CloudRunner.defaultSecrets = TaskParameterSerializer.readDefaultSecrets();
|
||||
CloudRunner.cloudRunnerEnvironmentVariables =
|
||||
TaskParameterSerializer.createCloudRunnerEnvironmentVariables(buildParameters);
|
||||
if (GitHub.githubInputEnabled) {
|
||||
const buildParameterPropertyNames = Object.getOwnPropertyNames(buildParameters);
|
||||
for (const element of CloudRunner.cloudRunnerEnvironmentVariables) {
|
||||
// CloudRunnerLogger.log(`Cloud Runner output ${Input.ToEnvVarFormat(element.name)} = ${element.value}`);
|
||||
core.setOutput(Input.ToEnvVarFormat(element.name), element.value);
|
||||
}
|
||||
for (const element of buildParameterPropertyNames) {
|
||||
// CloudRunnerLogger.log(`Cloud Runner output ${Input.ToEnvVarFormat(element)} = ${buildParameters[element]}`);
|
||||
core.setOutput(Input.ToEnvVarFormat(element), buildParameters[element]);
|
||||
}
|
||||
core.setOutput(
|
||||
Input.ToEnvVarFormat(`buildArtifact`),
|
||||
`build-${CloudRunner.buildParameters.buildGuid}.tar${
|
||||
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
FollowLogStreamService.Reset();
|
||||
}
|
||||
|
||||
private static setupSelectedBuildPlatform() {
|
||||
CloudRunnerLogger.log(`Cloud Runner platform selected ${CloudRunner.buildParameters.providerStrategy}`);
|
||||
switch (CloudRunner.buildParameters.providerStrategy) {
|
||||
case 'k8s':
|
||||
CloudRunner.Provider = new Kubernetes(CloudRunner.buildParameters);
|
||||
break;
|
||||
case 'aws':
|
||||
CloudRunner.Provider = new AwsBuildPlatform(CloudRunner.buildParameters);
|
||||
break;
|
||||
case 'test':
|
||||
CloudRunner.Provider = new TestCloudRunner();
|
||||
break;
|
||||
case 'local-docker':
|
||||
CloudRunner.Provider = new LocalDockerCloudRunner();
|
||||
break;
|
||||
case 'local-system':
|
||||
CloudRunner.Provider = new LocalCloudRunner();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static async run(buildParameters: BuildParameters, baseImage: string) {
|
||||
if (baseImage.includes(`undefined`)) {
|
||||
throw new Error(`baseImage is undefined`);
|
||||
}
|
||||
await CloudRunner.setup(buildParameters);
|
||||
await CloudRunner.Provider.setupWorkflow(
|
||||
CloudRunner.buildParameters.buildGuid,
|
||||
CloudRunner.buildParameters,
|
||||
CloudRunner.buildParameters.branch,
|
||||
CloudRunner.defaultSecrets,
|
||||
);
|
||||
try {
|
||||
if (buildParameters.maxRetainedWorkspaces > 0) {
|
||||
CloudRunner.lockedWorkspace = SharedWorkspaceLocking.NewWorkspaceName();
|
||||
|
||||
const result = await SharedWorkspaceLocking.GetLockedWorkspace(
|
||||
CloudRunner.lockedWorkspace,
|
||||
CloudRunner.buildParameters.buildGuid,
|
||||
CloudRunner.buildParameters,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
CloudRunnerLogger.logLine(`Using retained workspace ${CloudRunner.lockedWorkspace}`);
|
||||
CloudRunner.cloudRunnerEnvironmentVariables = [
|
||||
...CloudRunner.cloudRunnerEnvironmentVariables,
|
||||
{ name: `LOCKED_WORKSPACE`, value: CloudRunner.lockedWorkspace },
|
||||
];
|
||||
} else {
|
||||
CloudRunnerLogger.log(`Max retained workspaces reached ${buildParameters.maxRetainedWorkspaces}`);
|
||||
buildParameters.maxRetainedWorkspaces = 0;
|
||||
CloudRunner.lockedWorkspace = ``;
|
||||
}
|
||||
}
|
||||
await CloudRunner.updateStatusWithBuildParameters();
|
||||
const output = await new WorkflowCompositionRoot().run(
|
||||
new CloudRunnerStepParameters(
|
||||
baseImage,
|
||||
CloudRunner.cloudRunnerEnvironmentVariables,
|
||||
CloudRunner.defaultSecrets,
|
||||
),
|
||||
);
|
||||
await CloudRunner.Provider.cleanupWorkflow(
|
||||
CloudRunner.buildParameters,
|
||||
CloudRunner.buildParameters.branch,
|
||||
CloudRunner.defaultSecrets,
|
||||
);
|
||||
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
|
||||
if (buildParameters.asyncWorkflow && this.isCloudRunnerEnvironment && this.isCloudRunnerAsyncEnvironment) {
|
||||
await GitHub.updateGitHubCheck(CloudRunner.buildParameters.buildGuid, `success`, `success`, `completed`);
|
||||
}
|
||||
|
||||
if (BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) {
|
||||
const workspace = CloudRunner.lockedWorkspace || ``;
|
||||
await SharedWorkspaceLocking.ReleaseWorkspace(
|
||||
workspace,
|
||||
CloudRunner.buildParameters.buildGuid,
|
||||
CloudRunner.buildParameters,
|
||||
);
|
||||
const isLocked = await SharedWorkspaceLocking.IsWorkspaceLocked(workspace, CloudRunner.buildParameters);
|
||||
if (isLocked) {
|
||||
throw new Error(
|
||||
`still locked after releasing ${await SharedWorkspaceLocking.GetAllLocksForWorkspace(
|
||||
workspace,
|
||||
buildParameters,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
CloudRunner.lockedWorkspace = ``;
|
||||
}
|
||||
|
||||
await GitHub.triggerWorkflowOnComplete(CloudRunner.buildParameters.finalHooks);
|
||||
|
||||
if (buildParameters.constantGarbageCollection) {
|
||||
CloudRunner.Provider.garbageCollect(``, true, buildParameters.garbageMaxAge, true, true);
|
||||
}
|
||||
|
||||
return new CloudRunnerResult(buildParameters, output, true, true, false);
|
||||
} catch (error: any) {
|
||||
CloudRunnerLogger.log(JSON.stringify(error, undefined, 4));
|
||||
await GitHub.updateGitHubCheck(
|
||||
CloudRunner.buildParameters.buildGuid,
|
||||
`Failed - Error ${error?.message || error}`,
|
||||
`failure`,
|
||||
`completed`,
|
||||
);
|
||||
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
|
||||
await CloudRunnerError.handleException(error, CloudRunner.buildParameters, CloudRunner.defaultSecrets);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async updateStatusWithBuildParameters() {
|
||||
const content = { ...CloudRunner.buildParameters };
|
||||
content.gitPrivateToken = ``;
|
||||
content.unitySerial = ``;
|
||||
content.unityEmail = ``;
|
||||
content.unityPassword = ``;
|
||||
const jsonContent = JSON.stringify(content, undefined, 4);
|
||||
await GitHub.updateGitHubCheck(jsonContent, CloudRunner.buildParameters.buildGuid);
|
||||
}
|
||||
}
|
||||
export default CloudRunner;
|
||||
15
src/model/cloud-runner/error/cloud-runner-error.ts
Normal file
15
src/model/cloud-runner/error/cloud-runner-error.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import CloudRunnerLogger from '../services/core/cloud-runner-logger';
|
||||
import * as core from '@actions/core';
|
||||
import CloudRunner from '../cloud-runner';
|
||||
import CloudRunnerSecret from '../options/cloud-runner-secret';
|
||||
import BuildParameters from '../../build-parameters';
|
||||
|
||||
export class CloudRunnerError {
|
||||
public static async handleException(error: unknown, buildParameters: BuildParameters, secrets: CloudRunnerSecret[]) {
|
||||
CloudRunnerLogger.error(JSON.stringify(error, undefined, 4));
|
||||
core.setFailed('Cloud Runner failed');
|
||||
if (CloudRunner.Provider !== undefined) {
|
||||
await CloudRunner.Provider.cleanupWorkflow(buildParameters, buildParameters.branch, secrets);
|
||||
}
|
||||
}
|
||||
}
|
||||
4
src/model/cloud-runner/options/cloud-runner-constants.ts
Normal file
4
src/model/cloud-runner/options/cloud-runner-constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
class CloudRunnerConstants {
|
||||
static alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
}
|
||||
export default CloudRunnerConstants;
|
||||
@@ -0,0 +1,5 @@
|
||||
class CloudRunnerEnvironmentVariable {
|
||||
public name!: string;
|
||||
public value!: string;
|
||||
}
|
||||
export default CloudRunnerEnvironmentVariable;
|
||||
90
src/model/cloud-runner/options/cloud-runner-folders.ts
Normal file
90
src/model/cloud-runner/options/cloud-runner-folders.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import path from 'node:path';
|
||||
import CloudRunnerOptions from './cloud-runner-options';
|
||||
import CloudRunner from '../cloud-runner';
|
||||
import BuildParameters from '../../build-parameters';
|
||||
|
||||
export class CloudRunnerFolders {
|
||||
public static readonly repositoryFolder = 'repo';
|
||||
|
||||
public static ToLinuxFolder(folder: string) {
|
||||
return folder.replace(/\\/g, `/`);
|
||||
}
|
||||
|
||||
// Only the following paths that do not start a path.join with another "Full" suffixed property need to start with an absolute /
|
||||
|
||||
public static get uniqueCloudRunnerJobFolderAbsolute(): string {
|
||||
return CloudRunner.buildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(CloudRunner.buildParameters)
|
||||
? path.join(`/`, CloudRunnerFolders.buildVolumeFolder, CloudRunner.lockedWorkspace)
|
||||
: path.join(`/`, CloudRunnerFolders.buildVolumeFolder, CloudRunner.buildParameters.buildGuid);
|
||||
}
|
||||
|
||||
public static get cacheFolderForAllFull(): string {
|
||||
return path.join('/', CloudRunnerFolders.buildVolumeFolder, CloudRunnerFolders.cacheFolder);
|
||||
}
|
||||
|
||||
public static get cacheFolderForCacheKeyFull(): string {
|
||||
return path.join(
|
||||
'/',
|
||||
CloudRunnerFolders.buildVolumeFolder,
|
||||
CloudRunnerFolders.cacheFolder,
|
||||
CloudRunner.buildParameters.cacheKey,
|
||||
);
|
||||
}
|
||||
|
||||
public static get builderPathAbsolute(): string {
|
||||
return path.join(
|
||||
CloudRunnerOptions.useSharedBuilder
|
||||
? `/${CloudRunnerFolders.buildVolumeFolder}`
|
||||
: CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute,
|
||||
`builder`,
|
||||
);
|
||||
}
|
||||
|
||||
public static get repoPathAbsolute(): string {
|
||||
return path.join(CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute, CloudRunnerFolders.repositoryFolder);
|
||||
}
|
||||
|
||||
public static get projectPathAbsolute(): string {
|
||||
return path.join(CloudRunnerFolders.repoPathAbsolute, CloudRunner.buildParameters.projectPath);
|
||||
}
|
||||
|
||||
public static get libraryFolderAbsolute(): string {
|
||||
return path.join(CloudRunnerFolders.projectPathAbsolute, `Library`);
|
||||
}
|
||||
|
||||
public static get projectBuildFolderAbsolute(): string {
|
||||
return path.join(CloudRunnerFolders.repoPathAbsolute, CloudRunner.buildParameters.buildPath);
|
||||
}
|
||||
|
||||
public static get lfsFolderAbsolute(): string {
|
||||
return path.join(CloudRunnerFolders.repoPathAbsolute, `.git`, `lfs`);
|
||||
}
|
||||
|
||||
public static get purgeRemoteCaching(): boolean {
|
||||
return process.env.PURGE_REMOTE_BUILDER_CACHE !== undefined;
|
||||
}
|
||||
|
||||
public static get lfsCacheFolderFull() {
|
||||
return path.join(CloudRunnerFolders.cacheFolderForCacheKeyFull, `lfs`);
|
||||
}
|
||||
|
||||
public static get libraryCacheFolderFull() {
|
||||
return path.join(CloudRunnerFolders.cacheFolderForCacheKeyFull, `Library`);
|
||||
}
|
||||
|
||||
public static get unityBuilderRepoUrl(): string {
|
||||
return `https://${CloudRunner.buildParameters.gitPrivateToken}@github.com/game-ci/unity-builder.git`;
|
||||
}
|
||||
|
||||
public static get targetBuildRepoUrl(): string {
|
||||
return `https://${CloudRunner.buildParameters.gitPrivateToken}@github.com/${CloudRunner.buildParameters.githubRepo}.git`;
|
||||
}
|
||||
|
||||
public static get buildVolumeFolder() {
|
||||
return 'data';
|
||||
}
|
||||
|
||||
public static get cacheFolder() {
|
||||
return 'cache';
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import OrchestratorConstants from './orchestrator-constants';
|
||||
import CloudRunnerConstants from './cloud-runner-constants';
|
||||
|
||||
class OrchestratorNamespace {
|
||||
class CloudRunnerNamespace {
|
||||
static generateGuid(runNumber: string | number, platform: string) {
|
||||
const nanoid = customAlphabet(OrchestratorConstants.alphabet, 4);
|
||||
const nanoid = customAlphabet(CloudRunnerConstants.alphabet, 4);
|
||||
|
||||
return `${runNumber}-${platform.toLowerCase().replace('standalone', '')}-${nanoid()}`;
|
||||
}
|
||||
}
|
||||
export default OrchestratorNamespace;
|
||||
export default CloudRunnerNamespace;
|
||||
@@ -0,0 +1,10 @@
|
||||
import Input from '../../input';
|
||||
import CloudRunnerOptions from './cloud-runner-options';
|
||||
|
||||
class CloudRunnerOptionsReader {
|
||||
static GetProperties() {
|
||||
return [...Object.getOwnPropertyNames(Input), ...Object.getOwnPropertyNames(CloudRunnerOptions)];
|
||||
}
|
||||
}
|
||||
|
||||
export default CloudRunnerOptionsReader;
|
||||
283
src/model/cloud-runner/options/cloud-runner-options.ts
Normal file
283
src/model/cloud-runner/options/cloud-runner-options.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Cli } from '../../cli/cli';
|
||||
import CloudRunnerQueryOverride from './cloud-runner-query-override';
|
||||
import GitHub from '../../github';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
class CloudRunnerOptions {
|
||||
// ### ### ###
|
||||
// Input Handling
|
||||
// ### ### ###
|
||||
public static getInput(query: string): string | undefined {
|
||||
if (GitHub.githubInputEnabled) {
|
||||
const coreInput = core.getInput(query);
|
||||
if (coreInput && coreInput !== '') {
|
||||
return coreInput;
|
||||
}
|
||||
}
|
||||
const alternativeQuery = CloudRunnerOptions.ToEnvVarFormat(query);
|
||||
|
||||
// Query input sources
|
||||
if (Cli.query(query, alternativeQuery)) {
|
||||
return Cli.query(query, alternativeQuery);
|
||||
}
|
||||
|
||||
if (CloudRunnerQueryOverride.query(query, alternativeQuery)) {
|
||||
return CloudRunnerQueryOverride.query(query, alternativeQuery);
|
||||
}
|
||||
|
||||
if (process.env[query] !== undefined) {
|
||||
return process.env[query];
|
||||
}
|
||||
|
||||
if (alternativeQuery !== query && process.env[alternativeQuery] !== undefined) {
|
||||
return process.env[alternativeQuery];
|
||||
}
|
||||
}
|
||||
|
||||
public static ToEnvVarFormat(input: string): string {
|
||||
if (input.toUpperCase() === input) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return input
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/ /g, '_');
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// Provider parameters
|
||||
// ### ### ###
|
||||
|
||||
static get region(): string {
|
||||
return CloudRunnerOptions.getInput('region') || 'eu-west-2';
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// GitHub parameters
|
||||
// ### ### ###
|
||||
static get githubChecks(): boolean {
|
||||
const value = CloudRunnerOptions.getInput('githubChecks');
|
||||
|
||||
return value === `true` || false;
|
||||
}
|
||||
static get githubCheckId(): string {
|
||||
return CloudRunnerOptions.getInput('githubCheckId') || ``;
|
||||
}
|
||||
|
||||
static get githubOwner(): string {
|
||||
return CloudRunnerOptions.getInput('githubOwner') || CloudRunnerOptions.githubRepo?.split(`/`)[0] || '';
|
||||
}
|
||||
|
||||
static get githubRepoName(): string {
|
||||
return CloudRunnerOptions.getInput('githubRepoName') || CloudRunnerOptions.githubRepo?.split(`/`)[1] || '';
|
||||
}
|
||||
|
||||
static get finalHooks(): string[] {
|
||||
return CloudRunnerOptions.getInput('finalHooks')?.split(',') || [];
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// Git syncronization parameters
|
||||
// ### ### ###
|
||||
|
||||
static get githubRepo(): string | undefined {
|
||||
return CloudRunnerOptions.getInput('GITHUB_REPOSITORY') || CloudRunnerOptions.getInput('GITHUB_REPO') || undefined;
|
||||
}
|
||||
static get branch(): string {
|
||||
if (CloudRunnerOptions.getInput(`GITHUB_REF`)) {
|
||||
return (
|
||||
CloudRunnerOptions.getInput(`GITHUB_REF`)?.replace('refs/', '').replace(`head/`, '').replace(`heads/`, '') || ``
|
||||
);
|
||||
} else if (CloudRunnerOptions.getInput('branch')) {
|
||||
return CloudRunnerOptions.getInput('branch') || ``;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// Cloud Runner parameters
|
||||
// ### ### ###
|
||||
|
||||
static get buildPlatform(): string {
|
||||
const input = CloudRunnerOptions.getInput('buildPlatform');
|
||||
if (input && input !== '') {
|
||||
return input;
|
||||
}
|
||||
if (CloudRunnerOptions.providerStrategy !== 'local') {
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
return process.platform;
|
||||
}
|
||||
|
||||
static get cloudRunnerBranch(): string {
|
||||
return CloudRunnerOptions.getInput('cloudRunnerBranch') || 'main';
|
||||
}
|
||||
|
||||
static get providerStrategy(): string {
|
||||
const provider =
|
||||
CloudRunnerOptions.getInput('cloudRunnerCluster') || CloudRunnerOptions.getInput('providerStrategy');
|
||||
if (Cli.isCliMode) {
|
||||
return provider || 'aws';
|
||||
}
|
||||
|
||||
return provider || 'local';
|
||||
}
|
||||
|
||||
static get containerCpu(): string {
|
||||
return CloudRunnerOptions.getInput('containerCpu') || `1024`;
|
||||
}
|
||||
|
||||
static get containerMemory(): string {
|
||||
return CloudRunnerOptions.getInput('containerMemory') || `3072`;
|
||||
}
|
||||
|
||||
static get customJob(): string {
|
||||
return CloudRunnerOptions.getInput('customJob') || '';
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// Custom commands from files parameters
|
||||
// ### ### ###
|
||||
|
||||
static get containerHookFiles(): string[] {
|
||||
return CloudRunnerOptions.getInput('containerHookFiles')?.split(`,`) || [];
|
||||
}
|
||||
|
||||
static get commandHookFiles(): string[] {
|
||||
return CloudRunnerOptions.getInput('commandHookFiles')?.split(`,`) || [];
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// Custom commands from yaml parameters
|
||||
// ### ### ###
|
||||
|
||||
static get commandHooks(): string {
|
||||
return CloudRunnerOptions.getInput('commandHooks') || '';
|
||||
}
|
||||
|
||||
static get postBuildContainerHooks(): string {
|
||||
return CloudRunnerOptions.getInput('postBuildContainerHooks') || '';
|
||||
}
|
||||
|
||||
static get preBuildContainerHooks(): string {
|
||||
return CloudRunnerOptions.getInput('preBuildContainerHooks') || '';
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// Input override handling
|
||||
// ### ### ###
|
||||
|
||||
static get pullInputList(): string[] {
|
||||
return CloudRunnerOptions.getInput('pullInputList')?.split(`,`) || [];
|
||||
}
|
||||
|
||||
static get inputPullCommand(): string {
|
||||
const value = CloudRunnerOptions.getInput('inputPullCommand');
|
||||
|
||||
if (value === 'gcp-secret-manager') {
|
||||
return 'gcloud secrets versions access 1 --secret="{0}"';
|
||||
} else if (value === 'aws-secret-manager') {
|
||||
return 'aws secretsmanager get-secret-value --secret-id {0}';
|
||||
}
|
||||
|
||||
return value || '';
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// Aws
|
||||
// ### ### ###
|
||||
|
||||
static get awsStackName() {
|
||||
return CloudRunnerOptions.getInput('awsStackName') || 'game-ci';
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// K8s
|
||||
// ### ### ###
|
||||
|
||||
static get kubeConfig(): string {
|
||||
return CloudRunnerOptions.getInput('kubeConfig') || '';
|
||||
}
|
||||
|
||||
static get kubeVolume(): string {
|
||||
return CloudRunnerOptions.getInput('kubeVolume') || '';
|
||||
}
|
||||
|
||||
static get kubeVolumeSize(): string {
|
||||
return CloudRunnerOptions.getInput('kubeVolumeSize') || '25Gi';
|
||||
}
|
||||
|
||||
static get kubeStorageClass(): string {
|
||||
return CloudRunnerOptions.getInput('kubeStorageClass') || '';
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// Caching
|
||||
// ### ### ###
|
||||
|
||||
static get cacheKey(): string {
|
||||
return CloudRunnerOptions.getInput('cacheKey') || CloudRunnerOptions.branch;
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// Utility Parameters
|
||||
// ### ### ###
|
||||
|
||||
static get cloudRunnerDebug(): boolean {
|
||||
return (
|
||||
CloudRunnerOptions.getInput(`cloudRunnerTests`) === `true` ||
|
||||
CloudRunnerOptions.getInput(`cloudRunnerDebug`) === `true` ||
|
||||
CloudRunnerOptions.getInput(`cloudRunnerDebugTree`) === `true` ||
|
||||
CloudRunnerOptions.getInput(`cloudRunnerDebugEnv`) === `true` ||
|
||||
false
|
||||
);
|
||||
}
|
||||
static get skipLfs(): boolean {
|
||||
return CloudRunnerOptions.getInput(`skipLfs`) === `true`;
|
||||
}
|
||||
static get skipCache(): boolean {
|
||||
return CloudRunnerOptions.getInput(`skipCache`) === `true`;
|
||||
}
|
||||
|
||||
public static get asyncCloudRunner(): boolean {
|
||||
return CloudRunnerOptions.getInput('asyncCloudRunner') === 'true';
|
||||
}
|
||||
|
||||
public static get useLargePackages(): boolean {
|
||||
return CloudRunnerOptions.getInput(`useLargePackages`) === `true`;
|
||||
}
|
||||
|
||||
public static get useSharedBuilder(): boolean {
|
||||
return CloudRunnerOptions.getInput(`useSharedBuilder`) === `true`;
|
||||
}
|
||||
|
||||
public static get useCompressionStrategy(): boolean {
|
||||
return CloudRunnerOptions.getInput(`useCompressionStrategy`) === `true`;
|
||||
}
|
||||
|
||||
public static get useCleanupCron(): boolean {
|
||||
return (CloudRunnerOptions.getInput(`useCleanupCron`) || 'true') === 'true';
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// Retained Workspace
|
||||
// ### ### ###
|
||||
|
||||
public static get maxRetainedWorkspaces(): string {
|
||||
return CloudRunnerOptions.getInput(`maxRetainedWorkspaces`) || `0`;
|
||||
}
|
||||
|
||||
// ### ### ###
|
||||
// Garbage Collection
|
||||
// ### ### ###
|
||||
|
||||
static get garbageMaxAge(): number {
|
||||
return Number(CloudRunnerOptions.getInput(`garbageMaxAge`)) || 24;
|
||||
}
|
||||
}
|
||||
|
||||
export default CloudRunnerOptions;
|
||||
@@ -0,0 +1,67 @@
|
||||
import Input from '../../input';
|
||||
import { GenericInputReader } from '../../input-readers/generic-input-reader';
|
||||
import CloudRunnerOptions from './cloud-runner-options';
|
||||
|
||||
const formatFunction = (value: string, arguments_: any[]) => {
|
||||
for (const element of arguments_) {
|
||||
value = value.replace(`{${element.key}}`, element.value);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
class CloudRunnerQueryOverride {
|
||||
static queryOverrides: { [key: string]: string } | undefined;
|
||||
|
||||
// TODO accept premade secret sources or custom secret source definition yamls
|
||||
|
||||
public static query(key: string, alternativeKey: string) {
|
||||
if (CloudRunnerQueryOverride.queryOverrides && CloudRunnerQueryOverride.queryOverrides[key] !== undefined) {
|
||||
return CloudRunnerQueryOverride.queryOverrides[key];
|
||||
}
|
||||
if (
|
||||
CloudRunnerQueryOverride.queryOverrides &&
|
||||
alternativeKey &&
|
||||
CloudRunnerQueryOverride.queryOverrides[alternativeKey] !== undefined
|
||||
) {
|
||||
return CloudRunnerQueryOverride.queryOverrides[alternativeKey];
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private static shouldUseOverride(query: string) {
|
||||
if (CloudRunnerOptions.inputPullCommand !== '') {
|
||||
if (CloudRunnerOptions.pullInputList.length > 0) {
|
||||
const doesInclude =
|
||||
CloudRunnerOptions.pullInputList.includes(query) ||
|
||||
CloudRunnerOptions.pullInputList.includes(Input.ToEnvVarFormat(query));
|
||||
|
||||
return doesInclude ? true : false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async queryOverride(query: string) {
|
||||
if (!this.shouldUseOverride(query)) {
|
||||
throw new Error(`Should not be trying to run override query on ${query}`);
|
||||
}
|
||||
|
||||
return await GenericInputReader.Run(
|
||||
formatFunction(CloudRunnerOptions.inputPullCommand, [{ key: 0, value: query }]),
|
||||
);
|
||||
}
|
||||
|
||||
public static async PopulateQueryOverrideInput() {
|
||||
const queries = CloudRunnerOptions.pullInputList;
|
||||
CloudRunnerQueryOverride.queryOverrides = {};
|
||||
for (const element of queries) {
|
||||
if (CloudRunnerQueryOverride.shouldUseOverride(element)) {
|
||||
CloudRunnerQueryOverride.queryOverrides[element] = await CloudRunnerQueryOverride.queryOverride(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export default CloudRunnerQueryOverride;
|
||||
@@ -1,6 +1,6 @@
|
||||
class OrchestratorSecret {
|
||||
class CloudRunnerSecret {
|
||||
public ParameterKey!: string;
|
||||
public EnvironmentVariable!: string;
|
||||
public ParameterValue!: string;
|
||||
}
|
||||
export default OrchestratorSecret;
|
||||
export default CloudRunnerSecret;
|
||||
3
src/model/cloud-runner/options/cloud-runner-statics.ts
Normal file
3
src/model/cloud-runner/options/cloud-runner-statics.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class CloudRunnerStatics {
|
||||
public static readonly logPrefix = `Cloud-Runner`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import CloudRunnerEnvironmentVariable from './cloud-runner-environment-variable';
|
||||
import CloudRunnerSecret from './cloud-runner-secret';
|
||||
|
||||
export class CloudRunnerStepParameters {
|
||||
public image: string;
|
||||
public environment: CloudRunnerEnvironmentVariable[];
|
||||
public secrets: CloudRunnerSecret[];
|
||||
constructor(image: string, environmentVariables: CloudRunnerEnvironmentVariable[], secrets: CloudRunnerSecret[]) {
|
||||
this.image = image;
|
||||
this.environment = environmentVariables;
|
||||
this.secrets = secrets;
|
||||
}
|
||||
}
|
||||
105
src/model/cloud-runner/providers/aws/aws-base-stack.ts
Normal file
105
src/model/cloud-runner/providers/aws/aws-base-stack.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import * as core from '@actions/core';
|
||||
import * as SDK from 'aws-sdk';
|
||||
import { BaseStackFormation } from './cloud-formations/base-stack-formation';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export class AWSBaseStack {
|
||||
constructor(baseStackName: string) {
|
||||
this.baseStackName = baseStackName;
|
||||
}
|
||||
private baseStackName: string;
|
||||
|
||||
async setupBaseStack(CF: SDK.CloudFormation) {
|
||||
const baseStackName = this.baseStackName;
|
||||
|
||||
const baseStack = BaseStackFormation.formation;
|
||||
|
||||
// Cloud Formation Input
|
||||
const describeStackInput: SDK.CloudFormation.DescribeStacksInput = {
|
||||
StackName: baseStackName,
|
||||
};
|
||||
const parametersWithoutHash: SDK.CloudFormation.Parameter[] = [
|
||||
{ ParameterKey: 'EnvironmentName', ParameterValue: baseStackName },
|
||||
];
|
||||
const parametersHash = crypto
|
||||
.createHash('md5')
|
||||
.update(baseStack + JSON.stringify(parametersWithoutHash))
|
||||
.digest('hex');
|
||||
const parameters: SDK.CloudFormation.Parameter[] = [
|
||||
...parametersWithoutHash,
|
||||
...[{ ParameterKey: 'Version', ParameterValue: parametersHash }],
|
||||
];
|
||||
const updateInput: SDK.CloudFormation.UpdateStackInput = {
|
||||
StackName: baseStackName,
|
||||
TemplateBody: baseStack,
|
||||
Parameters: parameters,
|
||||
Capabilities: ['CAPABILITY_IAM'],
|
||||
};
|
||||
const createStackInput: SDK.CloudFormation.CreateStackInput = {
|
||||
StackName: baseStackName,
|
||||
TemplateBody: baseStack,
|
||||
Parameters: parameters,
|
||||
Capabilities: ['CAPABILITY_IAM'],
|
||||
};
|
||||
|
||||
const stacks = await CF.listStacks({
|
||||
StackStatusFilter: ['UPDATE_COMPLETE', 'CREATE_COMPLETE', 'ROLLBACK_COMPLETE'],
|
||||
}).promise();
|
||||
const stackNames = stacks.StackSummaries?.map((x) => x.StackName) || [];
|
||||
const stackExists: Boolean = stackNames.includes(baseStackName) || false;
|
||||
const describeStack = async () => {
|
||||
return await CF.describeStacks(describeStackInput).promise();
|
||||
};
|
||||
try {
|
||||
if (!stackExists) {
|
||||
CloudRunnerLogger.log(`${baseStackName} stack does not exist (${JSON.stringify(stackNames)})`);
|
||||
await CF.createStack(createStackInput).promise();
|
||||
CloudRunnerLogger.log(`created stack (version: ${parametersHash})`);
|
||||
}
|
||||
const CFState = await describeStack();
|
||||
let stack = CFState.Stacks?.[0];
|
||||
if (!stack) {
|
||||
throw new Error(`Base stack doesn't exist, even after creation, stackExists check: ${stackExists}`);
|
||||
}
|
||||
const stackVersion = stack.Parameters?.find((x) => x.ParameterKey === 'Version')?.ParameterValue;
|
||||
|
||||
if (stack.StackStatus === 'CREATE_IN_PROGRESS') {
|
||||
await CF.waitFor('stackCreateComplete', describeStackInput).promise();
|
||||
}
|
||||
|
||||
if (stackExists) {
|
||||
CloudRunnerLogger.log(`Base stack exists (version: ${stackVersion}, local version: ${parametersHash})`);
|
||||
if (parametersHash !== stackVersion) {
|
||||
CloudRunnerLogger.log(`Attempting update of base stack`);
|
||||
try {
|
||||
await CF.updateStack(updateInput).promise();
|
||||
} catch (error: any) {
|
||||
if (error['message'].includes('No updates are to be performed')) {
|
||||
CloudRunnerLogger.log(`No updates are to be performed`);
|
||||
} else {
|
||||
CloudRunnerLogger.log(`Update Failed (Stack name: ${baseStackName})`);
|
||||
CloudRunnerLogger.log(error['message']);
|
||||
}
|
||||
CloudRunnerLogger.log(`Continuing...`);
|
||||
}
|
||||
} else {
|
||||
CloudRunnerLogger.log(`No update required`);
|
||||
}
|
||||
stack = (await describeStack()).Stacks?.[0];
|
||||
if (!stack) {
|
||||
throw new Error(
|
||||
`Base stack doesn't exist, even after updating and creation, stackExists check: ${stackExists}`,
|
||||
);
|
||||
}
|
||||
if (stack.StackStatus === 'UPDATE_IN_PROGRESS') {
|
||||
await CF.waitFor('stackUpdateComplete', describeStackInput).promise();
|
||||
}
|
||||
}
|
||||
CloudRunnerLogger.log('base stack is now ready');
|
||||
} catch (error) {
|
||||
core.error(JSON.stringify(await describeStack(), undefined, 4));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ export class AWSCloudFormationTemplates {
|
||||
|
||||
public static getSecretDefinitionTemplate(p1: string, p2: string) {
|
||||
return `
|
||||
Secrets:
|
||||
- Name: '${p1}'
|
||||
ValueFrom: !Ref ${p2}Secret
|
||||
`;
|
||||
16
src/model/cloud-runner/providers/aws/aws-error.ts
Normal file
16
src/model/cloud-runner/providers/aws/aws-error.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import * as SDK from 'aws-sdk';
|
||||
import * as core from '@actions/core';
|
||||
import CloudRunner from '../../cloud-runner';
|
||||
|
||||
export class AWSError {
|
||||
static async handleStackCreationFailure(error: any, CF: SDK.CloudFormation, taskDefStackName: string) {
|
||||
CloudRunnerLogger.log('aws error: ');
|
||||
core.error(JSON.stringify(error, undefined, 4));
|
||||
if (CloudRunner.buildParameters.cloudRunnerDebug) {
|
||||
CloudRunnerLogger.log('Getting events and resources for task stack');
|
||||
const events = (await CF.describeStackEvents({ StackName: taskDefStackName }).promise()).StackEvents;
|
||||
CloudRunnerLogger.log(JSON.stringify(events, undefined, 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,14 @@
|
||||
import {
|
||||
CloudFormation,
|
||||
CreateStackCommand,
|
||||
// eslint-disable-next-line import/named
|
||||
CreateStackCommandInput,
|
||||
DescribeStackResourcesCommand,
|
||||
DescribeStacksCommand,
|
||||
ListStacksCommand,
|
||||
waitUntilStackCreateComplete,
|
||||
} from '@aws-sdk/client-cloudformation';
|
||||
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def';
|
||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||
import * as SDK from 'aws-sdk';
|
||||
import CloudRunnerAWSTaskDef from './cloud-runner-aws-task-def';
|
||||
import CloudRunnerSecret from '../../options/cloud-runner-secret';
|
||||
import { AWSCloudFormationTemplates } from './aws-cloud-formation-templates';
|
||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import { AWSError } from './aws-error';
|
||||
import Orchestrator from '../../orchestrator';
|
||||
import CloudRunner from '../../cloud-runner';
|
||||
import { CleanupCronFormation } from './cloud-formations/cleanup-cron-formation';
|
||||
import OrchestratorOptions from '../../options/orchestrator-options';
|
||||
import CloudRunnerOptions from '../../options/cloud-runner-options';
|
||||
import { TaskDefinitionFormation } from './cloud-formations/task-definition-formation';
|
||||
|
||||
const DEFAULT_STACK_WAIT_TIME_SECONDS = 600;
|
||||
|
||||
function getStackWaitTime(): number {
|
||||
const overrideValue = Number(process.env.ORCHESTRATOR_AWS_STACK_WAIT_TIME ?? '');
|
||||
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
|
||||
return overrideValue;
|
||||
}
|
||||
|
||||
return DEFAULT_STACK_WAIT_TIME_SECONDS;
|
||||
}
|
||||
|
||||
export class AWSJobStack {
|
||||
private baseStackName: string;
|
||||
constructor(baseStackName: string) {
|
||||
@@ -36,30 +16,30 @@ export class AWSJobStack {
|
||||
}
|
||||
|
||||
public async setupCloudFormations(
|
||||
CF: CloudFormation,
|
||||
CF: SDK.CloudFormation,
|
||||
buildGuid: string,
|
||||
image: string,
|
||||
entrypoint: string[],
|
||||
commands: string,
|
||||
mountdir: string,
|
||||
workingdir: string,
|
||||
secrets: OrchestratorSecret[],
|
||||
): Promise<OrchestratorAWSTaskDef> {
|
||||
secrets: CloudRunnerSecret[],
|
||||
): Promise<CloudRunnerAWSTaskDef> {
|
||||
const taskDefStackName = `${this.baseStackName}-${buildGuid}`;
|
||||
let taskDefCloudFormation = AWSCloudFormationTemplates.readTaskCloudFormationTemplate();
|
||||
taskDefCloudFormation = taskDefCloudFormation.replace(
|
||||
`ContainerCpu:
|
||||
Default: 1024`,
|
||||
`ContainerCpu:
|
||||
Default: ${Number.parseInt(Orchestrator.buildParameters.containerCpu)}`,
|
||||
Default: ${Number.parseInt(CloudRunner.buildParameters.containerCpu)}`,
|
||||
);
|
||||
taskDefCloudFormation = taskDefCloudFormation.replace(
|
||||
`ContainerMemory:
|
||||
Default: 2048`,
|
||||
`ContainerMemory:
|
||||
Default: ${Number.parseInt(Orchestrator.buildParameters.containerMemory)}`,
|
||||
Default: ${Number.parseInt(CloudRunner.buildParameters.containerMemory)}`,
|
||||
);
|
||||
if (!OrchestratorOptions.asyncOrchestrator) {
|
||||
if (!CloudRunnerOptions.asyncCloudRunner) {
|
||||
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
|
||||
taskDefCloudFormation,
|
||||
'# template resources logstream',
|
||||
@@ -133,13 +113,13 @@ export class AWSJobStack {
|
||||
},
|
||||
...secretsMappedToCloudFormationParameters,
|
||||
];
|
||||
OrchestratorLogger.log(
|
||||
`Starting AWS job with memory: ${Orchestrator.buildParameters.containerMemory} cpu: ${Orchestrator.buildParameters.containerCpu}`,
|
||||
CloudRunnerLogger.log(
|
||||
`Starting AWS job with memory: ${CloudRunner.buildParameters.containerMemory} cpu: ${CloudRunner.buildParameters.containerCpu}`,
|
||||
);
|
||||
let previousStackExists = true;
|
||||
while (previousStackExists) {
|
||||
previousStackExists = false;
|
||||
const stacks = await CF.send(new ListStacksCommand({}));
|
||||
const stacks = await CF.listStacks().promise();
|
||||
if (!stacks.StackSummaries) {
|
||||
throw new Error('Faild to get stacks');
|
||||
}
|
||||
@@ -147,31 +127,22 @@ export class AWSJobStack {
|
||||
const element = stacks.StackSummaries[index];
|
||||
if (element.StackName === taskDefStackName && element.StackStatus !== 'DELETE_COMPLETE') {
|
||||
previousStackExists = true;
|
||||
OrchestratorLogger.log(`Previous stack still exists: ${JSON.stringify(element)}`);
|
||||
CloudRunnerLogger.log(`Previous stack still exists: ${JSON.stringify(element)}`);
|
||||
await new Promise((promise) => setTimeout(promise, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
const createStackInput: CreateStackCommandInput = {
|
||||
const createStackInput: SDK.CloudFormation.CreateStackInput = {
|
||||
StackName: taskDefStackName,
|
||||
TemplateBody: taskDefCloudFormation,
|
||||
Capabilities: ['CAPABILITY_IAM'],
|
||||
Parameters: parameters,
|
||||
};
|
||||
try {
|
||||
const stackWaitTimeSeconds = getStackWaitTime();
|
||||
OrchestratorLogger.log(
|
||||
`Creating job aws formation ${taskDefStackName} (waiting up to ${stackWaitTimeSeconds}s for completion)`,
|
||||
);
|
||||
await CF.send(new CreateStackCommand(createStackInput));
|
||||
await waitUntilStackCreateComplete(
|
||||
{
|
||||
client: CF,
|
||||
maxWaitTime: stackWaitTimeSeconds,
|
||||
},
|
||||
{ StackName: taskDefStackName },
|
||||
);
|
||||
const describeStack = await CF.send(new DescribeStacksCommand({ StackName: taskDefStackName }));
|
||||
CloudRunnerLogger.log(`Creating job aws formation ${taskDefStackName}`);
|
||||
await CF.createStack(createStackInput).promise();
|
||||
await CF.waitFor('stackCreateComplete', { StackName: taskDefStackName }).promise();
|
||||
const describeStack = await CF.describeStacks({ StackName: taskDefStackName }).promise();
|
||||
for (const parameter of parameters) {
|
||||
if (!describeStack.Stacks?.[0].Parameters?.some((x) => x.ParameterKey === parameter.ParameterKey)) {
|
||||
throw new Error(`Parameter ${parameter.ParameterKey} not found in stack`);
|
||||
@@ -182,7 +153,7 @@ export class AWSJobStack {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const createCleanupStackInput: CreateStackCommandInput = {
|
||||
const createCleanupStackInput: SDK.CloudFormation.CreateStackInput = {
|
||||
StackName: `${taskDefStackName}-cleanup`,
|
||||
TemplateBody: CleanupCronFormation.formation,
|
||||
Capabilities: ['CAPABILITY_IAM'],
|
||||
@@ -201,7 +172,7 @@ export class AWSJobStack {
|
||||
},
|
||||
{
|
||||
ParameterKey: 'BUILDGUID',
|
||||
ParameterValue: Orchestrator.buildParameters.buildGuid,
|
||||
ParameterValue: CloudRunner.buildParameters.buildGuid,
|
||||
},
|
||||
{
|
||||
ParameterKey: 'EnvironmentName',
|
||||
@@ -209,10 +180,10 @@ export class AWSJobStack {
|
||||
},
|
||||
],
|
||||
};
|
||||
if (OrchestratorOptions.useCleanupCron) {
|
||||
if (CloudRunnerOptions.useCleanupCron) {
|
||||
try {
|
||||
OrchestratorLogger.log(`Creating job cleanup formation`);
|
||||
await CF.send(new CreateStackCommand(createCleanupStackInput));
|
||||
CloudRunnerLogger.log(`Creating job cleanup formation`);
|
||||
await CF.createStack(createCleanupStackInput).promise();
|
||||
|
||||
// await CF.waitFor('stackCreateComplete', { StackName: createCleanupStackInput.StackName }).promise();
|
||||
} catch (error) {
|
||||
@@ -222,15 +193,12 @@ export class AWSJobStack {
|
||||
}
|
||||
|
||||
const taskDefResources = (
|
||||
await CF.send(
|
||||
new DescribeStackResourcesCommand({
|
||||
StackName: taskDefStackName,
|
||||
}),
|
||||
)
|
||||
await CF.describeStackResources({
|
||||
StackName: taskDefStackName,
|
||||
}).promise()
|
||||
).StackResources;
|
||||
|
||||
const baseResources = (await CF.send(new DescribeStackResourcesCommand({ StackName: this.baseStackName })))
|
||||
.StackResources;
|
||||
const baseResources = (await CF.describeStackResources({ StackName: this.baseStackName }).promise()).StackResources;
|
||||
|
||||
return {
|
||||
taskDefStackName,
|
||||
250
src/model/cloud-runner/providers/aws/aws-task-runner.ts
Normal file
250
src/model/cloud-runner/providers/aws/aws-task-runner.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import * as AWS from 'aws-sdk';
|
||||
import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
|
||||
import * as core from '@actions/core';
|
||||
import CloudRunnerAWSTaskDef from './cloud-runner-aws-task-def';
|
||||
import * as zlib from 'node:zlib';
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import { Input } from '../../..';
|
||||
import CloudRunner from '../../cloud-runner';
|
||||
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';
|
||||
|
||||
class AWSTaskRunner {
|
||||
public static ECS: AWS.ECS;
|
||||
public static Kinesis: AWS.Kinesis;
|
||||
private static readonly encodedUnderscore = `$252F`;
|
||||
static async runTask(
|
||||
taskDef: CloudRunnerAWSTaskDef,
|
||||
environment: CloudRunnerEnvironmentVariable[],
|
||||
commands: string,
|
||||
): Promise<{ output: string; shouldCleanup: boolean }> {
|
||||
const cluster = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ECSCluster')?.PhysicalResourceId || '';
|
||||
const taskDefinition =
|
||||
taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'TaskDefinition')?.PhysicalResourceId || '';
|
||||
const SubnetOne =
|
||||
taskDef.baseResources?.find((x) => x.LogicalResourceId === 'PublicSubnetOne')?.PhysicalResourceId || '';
|
||||
const SubnetTwo =
|
||||
taskDef.baseResources?.find((x) => x.LogicalResourceId === 'PublicSubnetTwo')?.PhysicalResourceId || '';
|
||||
const ContainerSecurityGroup =
|
||||
taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ContainerSecurityGroup')?.PhysicalResourceId || '';
|
||||
const streamName =
|
||||
taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'KinesisStream')?.PhysicalResourceId || '';
|
||||
|
||||
const runParameters = {
|
||||
cluster,
|
||||
taskDefinition,
|
||||
platformVersion: '1.4.0',
|
||||
overrides: {
|
||||
containerOverrides: [
|
||||
{
|
||||
name: taskDef.taskDefStackName,
|
||||
environment,
|
||||
command: ['-c', CommandHookService.ApplyHooksToCommands(commands, CloudRunner.buildParameters)],
|
||||
},
|
||||
],
|
||||
},
|
||||
launchType: 'FARGATE',
|
||||
networkConfiguration: {
|
||||
awsvpcConfiguration: {
|
||||
subnets: [SubnetOne, SubnetTwo],
|
||||
assignPublicIp: 'ENABLED',
|
||||
securityGroups: [ContainerSecurityGroup],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (JSON.stringify(runParameters.overrides.containerOverrides).length > 8192) {
|
||||
CloudRunnerLogger.log(JSON.stringify(runParameters.overrides.containerOverrides, undefined, 4));
|
||||
throw new Error(`Container Overrides length must be at most 8192`);
|
||||
}
|
||||
|
||||
const task = await AWSTaskRunner.ECS.runTask(runParameters).promise();
|
||||
const taskArn = task.tasks?.[0].taskArn || '';
|
||||
CloudRunnerLogger.log('Cloud runner job is starting');
|
||||
await AWSTaskRunner.waitUntilTaskRunning(taskArn, cluster);
|
||||
CloudRunnerLogger.log(
|
||||
`Cloud runner job status is running ${(await AWSTaskRunner.describeTasks(cluster, taskArn))?.lastStatus} Async:${
|
||||
CloudRunnerOptions.asyncCloudRunner
|
||||
}`,
|
||||
);
|
||||
if (CloudRunnerOptions.asyncCloudRunner) {
|
||||
const shouldCleanup: boolean = false;
|
||||
const output: string = '';
|
||||
CloudRunnerLogger.log(`Watch Cloud Runner To End: false`);
|
||||
|
||||
return { output, shouldCleanup };
|
||||
}
|
||||
|
||||
CloudRunnerLogger.log(`Streaming...`);
|
||||
const { output, shouldCleanup } = await this.streamLogsUntilTaskStops(cluster, taskArn, streamName);
|
||||
let exitCode;
|
||||
let containerState;
|
||||
let taskData;
|
||||
while (exitCode === undefined) {
|
||||
await new Promise((resolve) => resolve(10000));
|
||||
taskData = await AWSTaskRunner.describeTasks(cluster, taskArn);
|
||||
containerState = taskData.containers?.[0];
|
||||
exitCode = containerState?.exitCode;
|
||||
}
|
||||
CloudRunnerLogger.log(`Container State: ${JSON.stringify(containerState, undefined, 4)}`);
|
||||
if (exitCode === undefined) {
|
||||
CloudRunnerLogger.logWarning(`Undefined exitcode for container`);
|
||||
}
|
||||
const wasSuccessful = exitCode === 0;
|
||||
if (wasSuccessful) {
|
||||
CloudRunnerLogger.log(`Cloud runner job has finished successfully`);
|
||||
|
||||
return { output, shouldCleanup };
|
||||
}
|
||||
|
||||
if (taskData?.stoppedReason === 'Essential container in task exited' && exitCode === 1) {
|
||||
throw new Error('Container exited with code 1');
|
||||
}
|
||||
|
||||
throw new Error(`Task failed`);
|
||||
}
|
||||
|
||||
private static async waitUntilTaskRunning(taskArn: string, cluster: string) {
|
||||
try {
|
||||
await AWSTaskRunner.ECS.waitFor('tasksRunning', { tasks: [taskArn], cluster }).promise();
|
||||
} catch (error_) {
|
||||
const error = error_ as Error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
CloudRunnerLogger.log(
|
||||
`Cloud runner job has ended ${
|
||||
(await AWSTaskRunner.describeTasks(cluster, taskArn)).containers?.[0].lastStatus
|
||||
}`,
|
||||
);
|
||||
|
||||
core.setFailed(error);
|
||||
core.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async describeTasks(clusterName: string, taskArn: string) {
|
||||
const tasks = await AWSTaskRunner.ECS.describeTasks({
|
||||
cluster: clusterName,
|
||||
tasks: [taskArn],
|
||||
}).promise();
|
||||
if (tasks.tasks?.[0]) {
|
||||
return tasks.tasks?.[0];
|
||||
} else {
|
||||
throw new Error('No task found');
|
||||
}
|
||||
}
|
||||
|
||||
static async streamLogsUntilTaskStops(clusterName: string, taskArn: string, kinesisStreamName: string) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
CloudRunnerLogger.log(`Streaming...`);
|
||||
const stream = await AWSTaskRunner.getLogStream(kinesisStreamName);
|
||||
let iterator = await AWSTaskRunner.getLogIterator(stream);
|
||||
|
||||
const logBaseUrl = `https://${Input.region}.console.aws.amazon.com/cloudwatch/home?region=${Input.region}#logsV2:log-groups/log-group/${CloudRunner.buildParameters.awsStackName}${AWSTaskRunner.encodedUnderscore}${CloudRunner.buildParameters.awsStackName}-${CloudRunner.buildParameters.buildGuid}`;
|
||||
CloudRunnerLogger.log(`You view the log stream on AWS Cloud Watch: ${logBaseUrl}`);
|
||||
await GitHub.updateGitHubCheck(`You view the log stream on AWS Cloud Watch: ${logBaseUrl}`, ``);
|
||||
let shouldReadLogs = true;
|
||||
let shouldCleanup = true;
|
||||
let timestamp: number = 0;
|
||||
let output = '';
|
||||
while (shouldReadLogs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const taskData = await AWSTaskRunner.describeTasks(clusterName, taskArn);
|
||||
({ timestamp, shouldReadLogs } = AWSTaskRunner.checkStreamingShouldContinue(taskData, timestamp, shouldReadLogs));
|
||||
({ iterator, shouldReadLogs, output, shouldCleanup } = await AWSTaskRunner.handleLogStreamIteration(
|
||||
iterator,
|
||||
shouldReadLogs,
|
||||
output,
|
||||
shouldCleanup,
|
||||
));
|
||||
}
|
||||
|
||||
return { output, shouldCleanup };
|
||||
}
|
||||
|
||||
private static async handleLogStreamIteration(
|
||||
iterator: string,
|
||||
shouldReadLogs: boolean,
|
||||
output: string,
|
||||
shouldCleanup: boolean,
|
||||
) {
|
||||
const records = await AWSTaskRunner.Kinesis.getRecords({
|
||||
ShardIterator: iterator,
|
||||
}).promise();
|
||||
iterator = records.NextShardIterator || '';
|
||||
({ shouldReadLogs, output, shouldCleanup } = AWSTaskRunner.logRecords(
|
||||
records,
|
||||
iterator,
|
||||
shouldReadLogs,
|
||||
output,
|
||||
shouldCleanup,
|
||||
));
|
||||
|
||||
return { iterator, shouldReadLogs, output, shouldCleanup };
|
||||
}
|
||||
|
||||
private static checkStreamingShouldContinue(taskData: AWS.ECS.Task, timestamp: number, shouldReadLogs: boolean) {
|
||||
if (taskData?.lastStatus === 'UNKNOWN') {
|
||||
CloudRunnerLogger.log('## Cloud runner job unknwon');
|
||||
}
|
||||
if (taskData?.lastStatus !== 'RUNNING') {
|
||||
if (timestamp === 0) {
|
||||
CloudRunnerLogger.log('## Cloud runner job stopped, streaming end of logs');
|
||||
timestamp = Date.now();
|
||||
}
|
||||
if (timestamp !== 0 && Date.now() - timestamp > 30000) {
|
||||
CloudRunnerLogger.log('## Cloud runner status is not RUNNING for 30 seconds, last query for logs');
|
||||
shouldReadLogs = false;
|
||||
}
|
||||
CloudRunnerLogger.log(`## Status of job: ${taskData.lastStatus}`);
|
||||
}
|
||||
|
||||
return { timestamp, shouldReadLogs };
|
||||
}
|
||||
|
||||
private static logRecords(
|
||||
records: AWS.Kinesis.GetRecordsOutput,
|
||||
iterator: string,
|
||||
shouldReadLogs: boolean,
|
||||
output: string,
|
||||
shouldCleanup: boolean,
|
||||
) {
|
||||
if (records.Records.length > 0 && iterator) {
|
||||
for (const record of records.Records) {
|
||||
const json = JSON.parse(zlib.gunzipSync(Buffer.from(record.Data as string, 'base64')).toString('utf8'));
|
||||
if (json.messageType === 'DATA_MESSAGE') {
|
||||
for (const logEvent of json.logEvents) {
|
||||
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
|
||||
logEvent.message,
|
||||
shouldReadLogs,
|
||||
shouldCleanup,
|
||||
output,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { shouldReadLogs, output, shouldCleanup };
|
||||
}
|
||||
|
||||
private static async getLogStream(kinesisStreamName: string) {
|
||||
return await AWSTaskRunner.Kinesis.describeStream({
|
||||
StreamName: kinesisStreamName,
|
||||
}).promise();
|
||||
}
|
||||
|
||||
private static async getLogIterator(stream: AWS.Kinesis.DescribeStreamOutput) {
|
||||
return (
|
||||
(
|
||||
await AWSTaskRunner.Kinesis.getShardIterator({
|
||||
ShardIteratorType: 'TRIM_HORIZON',
|
||||
StreamName: stream.StreamDescription.StreamName,
|
||||
ShardId: stream.StreamDescription.Shards[0].ShardId,
|
||||
}).promise()
|
||||
).ShardIterator || ''
|
||||
);
|
||||
}
|
||||
}
|
||||
export default AWSTaskRunner;
|
||||
@@ -1,7 +1,7 @@
|
||||
import Orchestrator from '../../../orchestrator';
|
||||
import CloudRunner from '../../../cloud-runner';
|
||||
|
||||
export class TaskDefinitionFormation {
|
||||
public static readonly description: string = `Game CI Orchestrator Task Stack`;
|
||||
public static readonly description: string = `Game CI Cloud Runner Task Stack`;
|
||||
public static get formation(): string {
|
||||
return `AWSTemplateFormatVersion: 2010-09-09
|
||||
Description: ${TaskDefinitionFormation.description}
|
||||
@@ -29,11 +29,11 @@ Parameters:
|
||||
Default: 80
|
||||
Description: What port number the application inside the docker container is binding to
|
||||
ContainerCpu:
|
||||
Default: ${Orchestrator.buildParameters.containerCpu}
|
||||
Default: ${CloudRunner.buildParameters.containerCpu}
|
||||
Type: Number
|
||||
Description: How much CPU to give the container. 1024 is 1 CPU
|
||||
ContainerMemory:
|
||||
Default: ${Orchestrator.buildParameters.containerMemory}
|
||||
Default: ${CloudRunner.buildParameters.containerMemory}
|
||||
Type: Number
|
||||
Description: How much memory in megabytes to give the container
|
||||
BUILDGUID:
|
||||
@@ -127,7 +127,8 @@ Resources:
|
||||
- SourceVolume: efs-data
|
||||
ContainerPath: !Ref EFSMountDirectory
|
||||
ReadOnly: false
|
||||
# template secrets p3 - container def
|
||||
Secrets:
|
||||
# template secrets p3 - container def
|
||||
LogConfiguration:
|
||||
LogDriver: awslogs
|
||||
Options:
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as AWS from 'aws-sdk';
|
||||
|
||||
class CloudRunnerAWSTaskDef {
|
||||
public taskDefStackName!: string;
|
||||
public taskDefCloudFormation!: string;
|
||||
public taskDefResources: AWS.CloudFormation.StackResources | undefined;
|
||||
public baseResources: AWS.CloudFormation.StackResources | undefined;
|
||||
}
|
||||
export default CloudRunnerAWSTaskDef;
|
||||
@@ -1,11 +1,11 @@
|
||||
import { CloudFormation, DeleteStackCommand, waitUntilStackDeleteComplete } from '@aws-sdk/client-cloudformation';
|
||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
||||
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def';
|
||||
import * as SDK from 'aws-sdk';
|
||||
import CloudRunnerSecret from '../../options/cloud-runner-secret';
|
||||
import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
|
||||
import CloudRunnerAWSTaskDef from './cloud-runner-aws-task-def';
|
||||
import AwsTaskRunner from './aws-task-runner';
|
||||
import { ProviderInterface } from '../provider-interface';
|
||||
import BuildParameters from '../../../build-parameters';
|
||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import { AWSJobStack as AwsJobStack } from './aws-job-stack';
|
||||
import { AWSBaseStack as AwsBaseStack } from './aws-base-stack';
|
||||
import { Input } from '../../..';
|
||||
@@ -13,20 +13,7 @@ import { GarbageCollectionService } from './services/garbage-collection-service'
|
||||
import { ProviderResource } from '../provider-resource';
|
||||
import { ProviderWorkflow } from '../provider-workflow';
|
||||
import { TaskService } from './services/task-service';
|
||||
import OrchestratorOptions from '../../options/orchestrator-options';
|
||||
import { AwsClientFactory } from './aws-client-factory';
|
||||
import ResourceTracking from '../../services/core/resource-tracking';
|
||||
|
||||
const DEFAULT_STACK_WAIT_TIME_SECONDS = 600;
|
||||
|
||||
function getStackWaitTime(): number {
|
||||
const overrideValue = Number(process.env.ORCHESTRATOR_AWS_STACK_WAIT_TIME ?? '');
|
||||
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
|
||||
return overrideValue;
|
||||
}
|
||||
|
||||
return DEFAULT_STACK_WAIT_TIME_SECONDS;
|
||||
}
|
||||
import CloudRunnerOptions from '../../options/cloud-runner-options';
|
||||
|
||||
class AWSBuildEnvironment implements ProviderInterface {
|
||||
private baseStackName: string;
|
||||
@@ -88,7 +75,7 @@ class AWSBuildEnvironment implements ProviderInterface {
|
||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
||||
) {
|
||||
process.env.AWS_REGION = Input.region;
|
||||
const CF = AwsClientFactory.getCloudFormation();
|
||||
const CF = new SDK.CloudFormation();
|
||||
await new AwsBaseStack(this.baseStackName).setupBaseStack(CF);
|
||||
}
|
||||
|
||||
@@ -98,16 +85,15 @@ class AWSBuildEnvironment implements ProviderInterface {
|
||||
commands: string,
|
||||
mountdir: string,
|
||||
workingdir: string,
|
||||
environment: OrchestratorEnvironmentVariable[],
|
||||
secrets: OrchestratorSecret[],
|
||||
environment: CloudRunnerEnvironmentVariable[],
|
||||
secrets: CloudRunnerSecret[],
|
||||
): Promise<string> {
|
||||
process.env.AWS_REGION = Input.region;
|
||||
ResourceTracking.logAllocationSummary('aws workflow');
|
||||
await ResourceTracking.logDiskUsageSnapshot('aws workflow (host)');
|
||||
AwsClientFactory.getECS();
|
||||
const CF = AwsClientFactory.getCloudFormation();
|
||||
AwsClientFactory.getKinesis();
|
||||
OrchestratorLogger.log(`AWS Region: ${CF.config.region}`);
|
||||
const ECS = new SDK.ECS();
|
||||
const CF = new SDK.CloudFormation();
|
||||
AwsTaskRunner.ECS = ECS;
|
||||
AwsTaskRunner.Kinesis = new SDK.Kinesis();
|
||||
CloudRunnerLogger.log(`AWS Region: ${CF.config.region}`);
|
||||
const entrypoint = ['/bin/sh'];
|
||||
const startTimeMs = Date.now();
|
||||
const taskDef = await new AwsJobStack(this.baseStackName).setupCloudFormations(
|
||||
@@ -124,53 +110,44 @@ class AWSBuildEnvironment implements ProviderInterface {
|
||||
let postRunTaskTimeMs;
|
||||
try {
|
||||
const postSetupStacksTimeMs = Date.now();
|
||||
OrchestratorLogger.log(`Setup job time: ${Math.floor((postSetupStacksTimeMs - startTimeMs) / 1000)}s`);
|
||||
CloudRunnerLogger.log(`Setup job time: ${Math.floor((postSetupStacksTimeMs - startTimeMs) / 1000)}s`);
|
||||
const { output, shouldCleanup } = await AwsTaskRunner.runTask(taskDef, environment, commands);
|
||||
postRunTaskTimeMs = Date.now();
|
||||
OrchestratorLogger.log(`Run job time: ${Math.floor((postRunTaskTimeMs - postSetupStacksTimeMs) / 1000)}s`);
|
||||
CloudRunnerLogger.log(`Run job time: ${Math.floor((postRunTaskTimeMs - postSetupStacksTimeMs) / 1000)}s`);
|
||||
if (shouldCleanup) {
|
||||
await this.cleanupResources(CF, taskDef);
|
||||
}
|
||||
const postCleanupTimeMs = Date.now();
|
||||
if (postRunTaskTimeMs !== undefined)
|
||||
OrchestratorLogger.log(`Cleanup job time: ${Math.floor((postCleanupTimeMs - postRunTaskTimeMs) / 1000)}s`);
|
||||
CloudRunnerLogger.log(`Cleanup job time: ${Math.floor((postCleanupTimeMs - postRunTaskTimeMs) / 1000)}s`);
|
||||
|
||||
return output;
|
||||
} catch (error) {
|
||||
OrchestratorLogger.log(`error running task ${error}`);
|
||||
CloudRunnerLogger.log(`error running task ${error}`);
|
||||
await this.cleanupResources(CF, taskDef);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupResources(CF: CloudFormation, taskDef: OrchestratorAWSTaskDef) {
|
||||
const stackWaitTimeSeconds = getStackWaitTime();
|
||||
OrchestratorLogger.log(`Cleanup starting (waiting up to ${stackWaitTimeSeconds}s for stack deletion)`);
|
||||
await CF.send(new DeleteStackCommand({ StackName: taskDef.taskDefStackName }));
|
||||
if (OrchestratorOptions.useCleanupCron) {
|
||||
await CF.send(new DeleteStackCommand({ StackName: `${taskDef.taskDefStackName}-cleanup` }));
|
||||
async cleanupResources(CF: SDK.CloudFormation, taskDef: CloudRunnerAWSTaskDef) {
|
||||
CloudRunnerLogger.log('Cleanup starting');
|
||||
await CF.deleteStack({
|
||||
StackName: taskDef.taskDefStackName,
|
||||
}).promise();
|
||||
if (CloudRunnerOptions.useCleanupCron) {
|
||||
await CF.deleteStack({
|
||||
StackName: `${taskDef.taskDefStackName}-cleanup`,
|
||||
}).promise();
|
||||
}
|
||||
|
||||
await waitUntilStackDeleteComplete(
|
||||
{
|
||||
client: CF,
|
||||
maxWaitTime: stackWaitTimeSeconds,
|
||||
},
|
||||
{
|
||||
StackName: taskDef.taskDefStackName,
|
||||
},
|
||||
);
|
||||
await waitUntilStackDeleteComplete(
|
||||
{
|
||||
client: CF,
|
||||
maxWaitTime: stackWaitTimeSeconds,
|
||||
},
|
||||
{
|
||||
StackName: `${taskDef.taskDefStackName}-cleanup`,
|
||||
},
|
||||
);
|
||||
OrchestratorLogger.log(`Deleted Stack: ${taskDef.taskDefStackName}`);
|
||||
OrchestratorLogger.log('Cleanup complete');
|
||||
await CF.waitFor('stackDeleteComplete', {
|
||||
StackName: taskDef.taskDefStackName,
|
||||
}).promise();
|
||||
await CF.waitFor('stackDeleteComplete', {
|
||||
StackName: `${taskDef.taskDefStackName}-cleanup`,
|
||||
}).promise();
|
||||
CloudRunnerLogger.log(`Deleted Stack: ${taskDef.taskDefStackName}`);
|
||||
CloudRunnerLogger.log('Cleanup complete');
|
||||
}
|
||||
}
|
||||
export default AWSBuildEnvironment;
|
||||
@@ -1,10 +1,7 @@
|
||||
import { DeleteStackCommand, DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation';
|
||||
import { DeleteLogGroupCommand } from '@aws-sdk/client-cloudwatch-logs';
|
||||
import { StopTaskCommand } from '@aws-sdk/client-ecs';
|
||||
import AWS from 'aws-sdk';
|
||||
import Input from '../../../../input';
|
||||
import OrchestratorLogger from '../../../services/core/orchestrator-logger';
|
||||
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) {
|
||||
@@ -15,9 +12,9 @@ export class GarbageCollectionService {
|
||||
|
||||
public static async cleanup(deleteResources = false, OneDayOlderOnly: boolean = false) {
|
||||
process.env.AWS_REGION = Input.region;
|
||||
const CF = AwsClientFactory.getCloudFormation();
|
||||
const ecs = AwsClientFactory.getECS();
|
||||
const cwl = AwsClientFactory.getCloudWatchLogs();
|
||||
const CF = new AWS.CloudFormation();
|
||||
const ecs = new AWS.ECS();
|
||||
const cwl = new AWS.CloudWatchLogs();
|
||||
const taskDefinitionsInUse = new Array();
|
||||
const tasks = await TaskService.getTasks();
|
||||
|
||||
@@ -25,35 +22,33 @@ export class GarbageCollectionService {
|
||||
const { taskElement, element } = task;
|
||||
taskDefinitionsInUse.push(taskElement.taskDefinitionArn);
|
||||
if (deleteResources && (!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(taskElement.createdAt!))) {
|
||||
OrchestratorLogger.log(`Stopping task ${taskElement.containers?.[0].name}`);
|
||||
await ecs.send(new StopTaskCommand({ task: taskElement.taskArn || '', cluster: element }));
|
||||
CloudRunnerLogger.log(`Stopping task ${taskElement.containers?.[0].name}`);
|
||||
await ecs.stopTask({ task: taskElement.taskArn || '', cluster: element }).promise();
|
||||
}
|
||||
}
|
||||
|
||||
const jobStacks = await TaskService.getCloudFormationJobStacks();
|
||||
for (const element of jobStacks) {
|
||||
if (
|
||||
(await CF.send(new DescribeStackResourcesCommand({ StackName: element.StackName }))).StackResources?.some(
|
||||
(await CF.describeStackResources({ StackName: element.StackName }).promise()).StackResources?.some(
|
||||
(x) => x.ResourceType === 'AWS::ECS::TaskDefinition' && taskDefinitionsInUse.includes(x.PhysicalResourceId),
|
||||
)
|
||||
) {
|
||||
OrchestratorLogger.log(`Skipping ${element.StackName} - active task was running not deleting`);
|
||||
CloudRunnerLogger.log(`Skipping ${element.StackName} - active task was running not deleting`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
deleteResources &&
|
||||
(!OneDayOlderOnly || (element.CreationTime && GarbageCollectionService.isOlderThan1day(element.CreationTime)))
|
||||
) {
|
||||
if (deleteResources && (!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(element.CreationTime))) {
|
||||
if (element.StackName === 'game-ci' || element.TemplateDescription === 'Game-CI base stack') {
|
||||
OrchestratorLogger.log(`Skipping ${element.StackName} ignore list`);
|
||||
CloudRunnerLogger.log(`Skipping ${element.StackName} ignore list`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OrchestratorLogger.log(`Deleting ${element.StackName}`);
|
||||
await CF.send(new DeleteStackCommand({ StackName: element.StackName }));
|
||||
CloudRunnerLogger.log(`Deleting ${element.StackName}`);
|
||||
const deleteStackInput: AWS.CloudFormation.DeleteStackInput = { StackName: element.StackName };
|
||||
await CF.deleteStack(deleteStackInput).promise();
|
||||
}
|
||||
}
|
||||
const logGroups = await TaskService.getLogGroups();
|
||||
@@ -62,14 +57,14 @@ export class GarbageCollectionService {
|
||||
deleteResources &&
|
||||
(!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(new Date(element.creationTime!)))
|
||||
) {
|
||||
OrchestratorLogger.log(`Deleting ${element.logGroupName}`);
|
||||
await cwl.send(new DeleteLogGroupCommand({ logGroupName: element.logGroupName || '' }));
|
||||
CloudRunnerLogger.log(`Deleting ${element.logGroupName}`);
|
||||
await cwl.deleteLogGroup({ logGroupName: element.logGroupName || '' }).promise();
|
||||
}
|
||||
}
|
||||
|
||||
const locks = await TaskService.getLocks();
|
||||
for (const element of locks) {
|
||||
OrchestratorLogger.log(`Lock: ${element.Key}`);
|
||||
CloudRunnerLogger.log(`Lock: ${element.Key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
170
src/model/cloud-runner/providers/aws/services/task-service.ts
Normal file
170
src/model/cloud-runner/providers/aws/services/task-service.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import AWS from 'aws-sdk';
|
||||
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 { ListObjectsRequest } from 'aws-sdk/clients/s3';
|
||||
import CloudRunner from '../../../cloud-runner';
|
||||
import { StackSummaries } from 'aws-sdk/clients/cloudformation';
|
||||
import { LogGroups } from 'aws-sdk/clients/cloudwatchlogs';
|
||||
|
||||
export class TaskService {
|
||||
static async watch() {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { output, shouldCleanup } = await AwsTaskRunner.streamLogsUntilTaskStops(
|
||||
process.env.cluster || ``,
|
||||
process.env.taskArn || ``,
|
||||
process.env.streamName || ``,
|
||||
);
|
||||
|
||||
return output;
|
||||
}
|
||||
public static async getCloudFormationJobStacks() {
|
||||
const result: StackSummaries = [];
|
||||
CloudRunnerLogger.log(``);
|
||||
CloudRunnerLogger.log(`List Cloud Formation Stacks`);
|
||||
process.env.AWS_REGION = Input.region;
|
||||
const CF = new AWS.CloudFormation();
|
||||
const stacks =
|
||||
(await CF.listStacks().promise()).StackSummaries?.filter(
|
||||
(_x) =>
|
||||
_x.StackStatus !== 'DELETE_COMPLETE' && _x.TemplateDescription !== BaseStackFormation.baseStackDecription,
|
||||
) || [];
|
||||
CloudRunnerLogger.log(``);
|
||||
CloudRunnerLogger.log(`Cloud Formation Stacks ${stacks.length}`);
|
||||
for (const element of stacks) {
|
||||
const ageDate: Date = new Date(Date.now() - element.CreationTime.getTime());
|
||||
|
||||
CloudRunnerLogger.log(
|
||||
`Task Stack ${element.StackName} - Age D${Math.floor(
|
||||
ageDate.getHours() / 24,
|
||||
)} H${ageDate.getHours()} M${ageDate.getMinutes()}`,
|
||||
);
|
||||
result.push(element);
|
||||
}
|
||||
const baseStacks =
|
||||
(await CF.listStacks().promise()).StackSummaries?.filter(
|
||||
(_x) =>
|
||||
_x.StackStatus !== 'DELETE_COMPLETE' && _x.TemplateDescription === BaseStackFormation.baseStackDecription,
|
||||
) || [];
|
||||
CloudRunnerLogger.log(``);
|
||||
CloudRunnerLogger.log(`Base Stacks ${baseStacks.length}`);
|
||||
for (const element of baseStacks) {
|
||||
const ageDate: Date = new Date(Date.now() - element.CreationTime.getTime());
|
||||
|
||||
CloudRunnerLogger.log(
|
||||
`Task Stack ${element.StackName} - Age D${Math.floor(
|
||||
ageDate.getHours() / 24,
|
||||
)} H${ageDate.getHours()} M${ageDate.getMinutes()}`,
|
||||
);
|
||||
result.push(element);
|
||||
}
|
||||
CloudRunnerLogger.log(``);
|
||||
|
||||
return result;
|
||||
}
|
||||
public static async getTasks() {
|
||||
const result: { taskElement: AWS.ECS.Task; element: string }[] = [];
|
||||
CloudRunnerLogger.log(``);
|
||||
CloudRunnerLogger.log(`List Tasks`);
|
||||
process.env.AWS_REGION = Input.region;
|
||||
const ecs = new AWS.ECS();
|
||||
const clusters = (await ecs.listClusters().promise()).clusterArns || [];
|
||||
CloudRunnerLogger.log(`Task Clusters ${clusters.length}`);
|
||||
for (const element of clusters) {
|
||||
const input: AWS.ECS.ListTasksRequest = {
|
||||
cluster: element,
|
||||
};
|
||||
|
||||
const list = (await ecs.listTasks(input).promise()).taskArns || [];
|
||||
if (list.length > 0) {
|
||||
const describeInput: AWS.ECS.DescribeTasksRequest = { tasks: list, cluster: element };
|
||||
const describeList = (await ecs.describeTasks(describeInput).promise()).tasks || [];
|
||||
if (describeList.length === 0) {
|
||||
CloudRunnerLogger.log(`No Tasks`);
|
||||
continue;
|
||||
}
|
||||
CloudRunnerLogger.log(`Tasks ${describeList.length}`);
|
||||
for (const taskElement of describeList) {
|
||||
if (taskElement === undefined) {
|
||||
continue;
|
||||
}
|
||||
taskElement.overrides = {};
|
||||
taskElement.attachments = [];
|
||||
if (taskElement.createdAt === undefined) {
|
||||
CloudRunnerLogger.log(`Skipping ${taskElement.taskDefinitionArn} no createdAt date`);
|
||||
continue;
|
||||
}
|
||||
result.push({ taskElement, element });
|
||||
}
|
||||
}
|
||||
}
|
||||
CloudRunnerLogger.log(``);
|
||||
|
||||
return result;
|
||||
}
|
||||
public static async awsDescribeJob(job: string) {
|
||||
process.env.AWS_REGION = Input.region;
|
||||
const CF = new AWS.CloudFormation();
|
||||
const stack = (await CF.listStacks().promise()).StackSummaries?.find((_x) => _x.StackName === job) || undefined;
|
||||
const stackInfo = (await CF.describeStackResources({ StackName: job }).promise()) || undefined;
|
||||
const stackInfo2 = (await CF.describeStacks({ StackName: job }).promise()) || undefined;
|
||||
if (stack === undefined) {
|
||||
throw new Error('stack not defined');
|
||||
}
|
||||
const ageDate: Date = new Date(Date.now() - stack.CreationTime.getTime());
|
||||
const message = `
|
||||
Task Stack ${stack.StackName}
|
||||
Age D${Math.floor(ageDate.getHours() / 24)} H${ageDate.getHours()} M${ageDate.getMinutes()}
|
||||
${JSON.stringify(stack, undefined, 4)}
|
||||
${JSON.stringify(stackInfo, undefined, 4)}
|
||||
${JSON.stringify(stackInfo2, undefined, 4)}
|
||||
`;
|
||||
CloudRunnerLogger.log(message);
|
||||
|
||||
return message;
|
||||
}
|
||||
public static async getLogGroups() {
|
||||
const result: LogGroups = [];
|
||||
process.env.AWS_REGION = Input.region;
|
||||
const ecs = new AWS.CloudWatchLogs();
|
||||
let logStreamInput: AWS.CloudWatchLogs.DescribeLogGroupsRequest = {
|
||||
/* logGroupNamePrefix: 'game-ci' */
|
||||
};
|
||||
let logGroupsDescribe = await ecs.describeLogGroups(logStreamInput).promise();
|
||||
const logGroups = logGroupsDescribe.logGroups || [];
|
||||
while (logGroupsDescribe.nextToken) {
|
||||
logStreamInput = { /* logGroupNamePrefix: 'game-ci',*/ nextToken: logGroupsDescribe.nextToken };
|
||||
logGroupsDescribe = await ecs.describeLogGroups(logStreamInput).promise();
|
||||
logGroups.push(...(logGroupsDescribe?.logGroups || []));
|
||||
}
|
||||
|
||||
CloudRunnerLogger.log(`Log Groups ${logGroups.length}`);
|
||||
for (const element of logGroups) {
|
||||
if (element.creationTime === undefined) {
|
||||
CloudRunnerLogger.log(`Skipping ${element.logGroupName} no createdAt date`);
|
||||
continue;
|
||||
}
|
||||
const ageDate: Date = new Date(Date.now() - element.creationTime);
|
||||
|
||||
CloudRunnerLogger.log(
|
||||
`Task Stack ${element.logGroupName} - Age D${Math.floor(
|
||||
ageDate.getHours() / 24,
|
||||
)} H${ageDate.getHours()} M${ageDate.getMinutes()}`,
|
||||
);
|
||||
result.push(element);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
public static async getLocks() {
|
||||
process.env.AWS_REGION = Input.region;
|
||||
const s3 = new AWS.S3();
|
||||
const listRequest: ListObjectsRequest = {
|
||||
Bucket: CloudRunner.buildParameters.awsStackName,
|
||||
};
|
||||
const results = await s3.listObjects(listRequest).promise();
|
||||
|
||||
return results.Contents || [];
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import BuildParameters from '../../../build-parameters';
|
||||
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||
import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import { ProviderInterface } from '../provider-interface';
|
||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||
import CloudRunnerSecret from '../../options/cloud-runner-secret';
|
||||
import Docker from '../../../docker';
|
||||
import { Action } from '../../..';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import Orchestrator from '../../orchestrator';
|
||||
import CloudRunner from '../../cloud-runner';
|
||||
import { ProviderResource } from '../provider-resource';
|
||||
import { ProviderWorkflow } from '../provider-workflow';
|
||||
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
|
||||
import { CloudRunnerSystem } from '../../services/core/cloud-runner-system';
|
||||
import * as fs from 'node:fs';
|
||||
import { CommandHookService } from '../../services/hooks/command-hook-service';
|
||||
import { StringKeyValuePair } from '../../../shared-types';
|
||||
|
||||
class LocalDockerOrchestrator implements ProviderInterface {
|
||||
class LocalDockerCloudRunner implements ProviderInterface {
|
||||
public buildParameters!: BuildParameters;
|
||||
|
||||
listResources(): Promise<ProviderResource[]> {
|
||||
@@ -50,15 +50,15 @@ class LocalDockerOrchestrator implements ProviderInterface {
|
||||
const { workspace } = Action;
|
||||
if (
|
||||
fs.existsSync(
|
||||
`${workspace}/orchestrator-cache/cache/build/build-${buildParameters.buildGuid}.tar${
|
||||
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
||||
`${workspace}/cloud-runner-cache/cache/build/build-${buildParameters.buildGuid}.tar${
|
||||
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
||||
}`,
|
||||
)
|
||||
) {
|
||||
await OrchestratorSystem.Run(`ls ${workspace}/orchestrator-cache/cache/build/`);
|
||||
await OrchestratorSystem.Run(
|
||||
`rm -r ${workspace}/orchestrator-cache/cache/build/build-${buildParameters.buildGuid}.tar${
|
||||
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
||||
await CloudRunnerSystem.Run(`ls ${workspace}/cloud-runner-cache/cache/build/`);
|
||||
await CloudRunnerSystem.Run(
|
||||
`rm -r ${workspace}/cloud-runner-cache/cache/build/build-${buildParameters.buildGuid}.tar${
|
||||
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
@@ -80,47 +80,22 @@ class LocalDockerOrchestrator implements ProviderInterface {
|
||||
commands: string,
|
||||
mountdir: string,
|
||||
workingdir: string,
|
||||
environment: OrchestratorEnvironmentVariable[],
|
||||
secrets: OrchestratorSecret[],
|
||||
environment: CloudRunnerEnvironmentVariable[],
|
||||
secrets: CloudRunnerSecret[],
|
||||
): Promise<string> {
|
||||
OrchestratorLogger.log(buildGuid);
|
||||
OrchestratorLogger.log(commands);
|
||||
CloudRunnerLogger.log(buildGuid);
|
||||
CloudRunnerLogger.log(commands);
|
||||
|
||||
const { workspace, actionFolder } = Action;
|
||||
const content: StringKeyValuePair[] = [];
|
||||
for (const x of secrets) {
|
||||
content.push({ name: x.EnvironmentVariable, value: x.ParameterValue });
|
||||
}
|
||||
|
||||
// Replace localhost with host.docker.internal for LocalStack endpoints (similar to K8s)
|
||||
// This allows Docker containers to access LocalStack running on the host
|
||||
const endpointEnvironmentNames = new Set([
|
||||
'AWS_S3_ENDPOINT',
|
||||
'AWS_ENDPOINT',
|
||||
'AWS_CLOUD_FORMATION_ENDPOINT',
|
||||
'AWS_ECS_ENDPOINT',
|
||||
'AWS_KINESIS_ENDPOINT',
|
||||
'AWS_CLOUD_WATCH_LOGS_ENDPOINT',
|
||||
'INPUT_AWSS3ENDPOINT',
|
||||
'INPUT_AWSENDPOINT',
|
||||
]);
|
||||
for (const x of environment) {
|
||||
let value = x.value;
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
endpointEnvironmentNames.has(x.name) &&
|
||||
(value.startsWith('http://localhost') || value.startsWith('http://127.0.0.1'))
|
||||
) {
|
||||
// Replace localhost with host.docker.internal so containers can access host services
|
||||
value = value
|
||||
.replace('http://localhost', 'http://host.docker.internal')
|
||||
.replace('http://127.0.0.1', 'http://host.docker.internal');
|
||||
OrchestratorLogger.log(`Replaced localhost with host.docker.internal for ${x.name}: ${value}`);
|
||||
}
|
||||
content.push({ name: x.name, value });
|
||||
content.push({ name: x.name, value: x.value });
|
||||
}
|
||||
|
||||
// if (this.buildParameters?.orchestratorIntegrationTests) {
|
||||
// if (this.buildParameters?.cloudRunnerIntegrationTests) {
|
||||
// core.info(JSON.stringify(content, undefined, 4));
|
||||
// core.info(JSON.stringify(secrets, undefined, 4));
|
||||
// core.info(JSON.stringify(environment, undefined, 4));
|
||||
@@ -137,33 +112,25 @@ class LocalDockerOrchestrator implements ProviderInterface {
|
||||
|
||||
// core.info(JSON.stringify({ workspace, actionFolder, ...this.buildParameters, ...content }, undefined, 4));
|
||||
const entrypointFilePath = `start.sh`;
|
||||
|
||||
// Use #!/bin/sh for POSIX compatibility (Alpine-based images like rclone/rclone don't have bash)
|
||||
const fileContents = `#!/bin/sh
|
||||
const fileContents = `#!/bin/bash
|
||||
set -e
|
||||
|
||||
mkdir -p /github/workspace/orchestrator-cache
|
||||
mkdir -p /github/workspace/cloud-runner-cache
|
||||
mkdir -p /data/cache
|
||||
cp -a /github/workspace/orchestrator-cache/. ${sharedFolder}
|
||||
cp -a /github/workspace/cloud-runner-cache/. ${sharedFolder}
|
||||
${CommandHookService.ApplyHooksToCommands(commands, this.buildParameters)}
|
||||
# Only copy cache directory, exclude retained workspaces to avoid running out of disk space
|
||||
if [ -d "${sharedFolder}cache" ]; then
|
||||
cp -a ${sharedFolder}cache/. /github/workspace/orchestrator-cache/cache/ || true
|
||||
fi
|
||||
# Copy test files from /data/ root to workspace for test assertions
|
||||
# This allows tests to write files to /data/ and have them available in the workspace
|
||||
find ${sharedFolder} -maxdepth 1 -type f -name "test-*" -exec cp -a {} /github/workspace/orchestrator-cache/ \\; || true
|
||||
cp -a ${sharedFolder}. /github/workspace/cloud-runner-cache/
|
||||
`;
|
||||
writeFileSync(`${workspace}/${entrypointFilePath}`, fileContents, {
|
||||
flag: 'w',
|
||||
});
|
||||
|
||||
if (Orchestrator.buildParameters.orchestratorDebug) {
|
||||
OrchestratorLogger.log(`Running local-docker: \n ${fileContents}`);
|
||||
if (CloudRunner.buildParameters.cloudRunnerDebug) {
|
||||
CloudRunnerLogger.log(`Running local-docker: \n ${fileContents}`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(`${workspace}/orchestrator-cache`)) {
|
||||
await OrchestratorSystem.Run(`ls ${workspace}/orchestrator-cache && du -sh ${workspace}/orchestrator-cache`);
|
||||
if (fs.existsSync(`${workspace}/cloud-runner-cache`)) {
|
||||
await CloudRunnerSystem.Run(`ls ${workspace}/cloud-runner-cache && du -sh ${workspace}/cloud-runner-cache`);
|
||||
}
|
||||
const exitCode = await Docker.run(
|
||||
image,
|
||||
@@ -193,4 +160,4 @@ find ${sharedFolder} -maxdepth 1 -type f -name "test-*" -exec cp -a {} /github/w
|
||||
return myOutput;
|
||||
}
|
||||
}
|
||||
export default LocalDockerOrchestrator;
|
||||
export default LocalDockerCloudRunner;
|
||||
@@ -2,22 +2,21 @@ import * as k8s from '@kubernetes/client-node';
|
||||
import { BuildParameters } from '../../..';
|
||||
import * as core from '@actions/core';
|
||||
import { ProviderInterface } from '../provider-interface';
|
||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||
import CloudRunnerSecret from '../../options/cloud-runner-secret';
|
||||
import KubernetesStorage from './kubernetes-storage';
|
||||
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
||||
import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
|
||||
import KubernetesTaskRunner from './kubernetes-task-runner';
|
||||
import KubernetesSecret from './kubernetes-secret';
|
||||
import KubernetesJobSpecFactory from './kubernetes-job-spec-factory';
|
||||
import KubernetesServiceAccount from './kubernetes-service-account';
|
||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import { CoreV1Api } from '@kubernetes/client-node';
|
||||
import Orchestrator from '../../orchestrator';
|
||||
import CloudRunner from '../../cloud-runner';
|
||||
import { ProviderResource } from '../provider-resource';
|
||||
import { ProviderWorkflow } from '../provider-workflow';
|
||||
import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
|
||||
import { KubernetesRole } from './kubernetes-role';
|
||||
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
|
||||
import ResourceTracking from '../../services/core/resource-tracking';
|
||||
import { CloudRunnerSystem } from '../../services/core/cloud-runner-system';
|
||||
|
||||
class Kubernetes implements ProviderInterface {
|
||||
public static Instance: Kubernetes;
|
||||
@@ -38,6 +37,7 @@ class Kubernetes implements ProviderInterface {
|
||||
public serviceAccountName: string = '';
|
||||
public ip: string = '';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
constructor(buildParameters: BuildParameters) {
|
||||
Kubernetes.Instance = this;
|
||||
this.kubeConfig = new k8s.KubeConfig();
|
||||
@@ -46,8 +46,8 @@ class Kubernetes implements ProviderInterface {
|
||||
this.kubeClientApps = this.kubeConfig.makeApiClient(k8s.AppsV1Api);
|
||||
this.kubeClientBatch = this.kubeConfig.makeApiClient(k8s.BatchV1Api);
|
||||
this.rbacAuthorizationV1Api = this.kubeConfig.makeApiClient(k8s.RbacAuthorizationV1Api);
|
||||
this.namespace = buildParameters.containerNamespace ? buildParameters.containerNamespace : 'default';
|
||||
OrchestratorLogger.log('Loaded default Kubernetes configuration for this environment');
|
||||
this.namespace = 'default';
|
||||
CloudRunnerLogger.log('Loaded default Kubernetes configuration for this environment');
|
||||
}
|
||||
|
||||
async PushLogUpdate(logs: string) {
|
||||
@@ -63,7 +63,7 @@ class Kubernetes implements ProviderInterface {
|
||||
|
||||
// logs to base64
|
||||
logs = Buffer.from(logs).toString('base64');
|
||||
const response = await OrchestratorSystem.Run(`curl -X POST -d "${logs}" ${url}`, false, true);
|
||||
const response = await CloudRunnerSystem.Run(`curl -X POST -d "${logs}" ${url}`, false, true);
|
||||
RemoteClientLogger.log(`Pushed logs to ${url} ${response}`);
|
||||
}
|
||||
|
||||
@@ -133,19 +133,16 @@ class Kubernetes implements ProviderInterface {
|
||||
commands: string,
|
||||
mountdir: string,
|
||||
workingdir: string,
|
||||
environment: OrchestratorEnvironmentVariable[],
|
||||
secrets: OrchestratorSecret[],
|
||||
environment: CloudRunnerEnvironmentVariable[],
|
||||
secrets: CloudRunnerSecret[],
|
||||
): Promise<string> {
|
||||
try {
|
||||
OrchestratorLogger.log('Orchestrator K8s workflow!');
|
||||
ResourceTracking.logAllocationSummary('k8s workflow');
|
||||
await ResourceTracking.logDiskUsageSnapshot('k8s workflow (host)');
|
||||
await ResourceTracking.logK3dNodeDiskUsage('k8s workflow (before job)');
|
||||
CloudRunnerLogger.log('Cloud Runner K8s workflow!');
|
||||
|
||||
// Setup
|
||||
const id =
|
||||
BuildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(this.buildParameters)
|
||||
? Orchestrator.lockedWorkspace
|
||||
? CloudRunner.lockedWorkspace
|
||||
: this.buildParameters.buildGuid;
|
||||
this.pvcName = `unity-builder-pvc-${id}`;
|
||||
await KubernetesStorage.createPersistentVolumeClaim(
|
||||
@@ -159,134 +156,14 @@ class Kubernetes implements ProviderInterface {
|
||||
this.jobName = `unity-builder-job-${this.buildGuid}`;
|
||||
this.containerName = `main`;
|
||||
await KubernetesSecret.createSecret(secrets, this.secretName, this.namespace, this.kubeClient);
|
||||
|
||||
// For tests, clean up old images before creating job to free space for image pull
|
||||
// IMPORTANT: Preserve the Unity image to avoid re-pulling it
|
||||
if (process.env['orchestratorTests'] === 'true') {
|
||||
try {
|
||||
OrchestratorLogger.log('Cleaning up old images in k3d node before pulling new image...');
|
||||
const { OrchestratorSystem: OrchestratorSystemModule } = await import(
|
||||
'../../services/core/orchestrator-system'
|
||||
);
|
||||
|
||||
// Aggressive cleanup: remove stopped containers and non-Unity images
|
||||
// IMPORTANT: Preserve Unity images (unityci/editor) to avoid re-pulling the 3.9GB image
|
||||
const K3D_NODE_CONTAINERS = ['k3d-unity-builder-agent-0', 'k3d-unity-builder-server-0'];
|
||||
const cleanupCommands: string[] = [];
|
||||
|
||||
for (const NODE of K3D_NODE_CONTAINERS) {
|
||||
// Remove all stopped containers (this frees runtime space but keeps images)
|
||||
cleanupCommands.push(
|
||||
`docker exec ${NODE} sh -c "crictl rm --all 2>/dev/null || true" || true`,
|
||||
`docker exec ${NODE} sh -c "for img in $(crictl images -q 2>/dev/null); do repo=$(crictl inspecti $img --format '{{.repo}}' 2>/dev/null || echo ''); if echo "$repo" | grep -qvE 'unityci/editor|unity'; then crictl rmi $img 2>/dev/null || true; fi; done" || true`,
|
||||
`docker exec ${NODE} sh -c "crictl rmi --prune 2>/dev/null || true" || true`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const cmd of cleanupCommands) {
|
||||
try {
|
||||
await OrchestratorSystemModule.Run(cmd, true, true);
|
||||
} catch (cmdError) {
|
||||
// Ignore individual command failures - cleanup is best effort
|
||||
OrchestratorLogger.log(`Cleanup command failed (non-fatal): ${cmdError}`);
|
||||
}
|
||||
}
|
||||
OrchestratorLogger.log('Cleanup completed (containers and non-Unity images removed, Unity images preserved)');
|
||||
} catch (cleanupError) {
|
||||
OrchestratorLogger.logWarning(`Failed to cleanup images before job creation: ${cleanupError}`);
|
||||
|
||||
// Continue anyway - image might already be cached
|
||||
}
|
||||
}
|
||||
|
||||
let output = '';
|
||||
try {
|
||||
// Before creating the job, verify we have the Unity image cached on the agent node
|
||||
// If not cached, try to ensure it's available to avoid disk pressure during pull
|
||||
if (process.env['orchestratorTests'] === 'true' && image.includes('unityci/editor')) {
|
||||
try {
|
||||
const { OrchestratorSystem: OrchestratorSystemModule2 } = await import(
|
||||
'../../services/core/orchestrator-system'
|
||||
);
|
||||
|
||||
// Check if image is cached on agent node (where pods run)
|
||||
const agentImageCheck = await OrchestratorSystemModule2.Run(
|
||||
`docker exec k3d-unity-builder-agent-0 sh -c "crictl images | grep -q unityci/editor && echo 'cached' || echo 'not_cached'" || echo 'not_cached'`,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
if (agentImageCheck.includes('not_cached')) {
|
||||
// Check if image is on server node
|
||||
const serverImageCheck = await OrchestratorSystemModule2.Run(
|
||||
`docker exec k3d-unity-builder-server-0 sh -c "crictl images | grep -q unityci/editor && echo 'cached' || echo 'not_cached'" || echo 'not_cached'`,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
// Check available disk space on agent node
|
||||
const diskInfo = await OrchestratorSystemModule2.Run(
|
||||
'docker exec k3d-unity-builder-agent-0 sh -c "df -h /var/lib/rancher/k3s 2>/dev/null | tail -1 || df -h / 2>/dev/null | tail -1 || echo unknown" || echo unknown',
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
OrchestratorLogger.logWarning(
|
||||
`Unity image not cached on agent node (where pods run). Server node: ${
|
||||
serverImageCheck.includes('cached') ? 'has image' : 'no image'
|
||||
}. Disk info: ${diskInfo.trim()}. Pod will attempt to pull image (3.9GB) which may fail due to disk pressure.`,
|
||||
);
|
||||
|
||||
// If image is on server but not agent, log a warning
|
||||
// NOTE: We don't attempt to pull here because:
|
||||
// 1. Pulling a 3.9GB image can take several minutes and block the test
|
||||
// 2. If there's not enough disk space, the pull will hang indefinitely
|
||||
// 3. The pod will attempt to pull during scheduling anyway
|
||||
// 4. If the pull fails, Kubernetes will provide proper error messages
|
||||
if (serverImageCheck.includes('cached')) {
|
||||
OrchestratorLogger.logWarning(
|
||||
'Unity image exists on server node but not agent node. Pod will attempt to pull during scheduling. If pull fails due to disk pressure, ensure cleanup runs before this test.',
|
||||
);
|
||||
} else {
|
||||
// Image not on either node - check if we have enough space to pull
|
||||
// Extract available space from disk info
|
||||
const availableSpaceMatch = diskInfo.match(/(\d+(?:\.\d+)?)\s*([gkm]?i?b)/i);
|
||||
if (availableSpaceMatch) {
|
||||
const availableValue = Number.parseFloat(availableSpaceMatch[1]);
|
||||
const availableUnit = availableSpaceMatch[2].toUpperCase();
|
||||
let availableGB = availableValue;
|
||||
|
||||
if (availableUnit.includes('M')) {
|
||||
availableGB = availableValue / 1024;
|
||||
} else if (availableUnit.includes('K')) {
|
||||
availableGB = availableValue / (1024 * 1024);
|
||||
}
|
||||
|
||||
// Unity image is ~3.9GB, need at least 4.5GB to be safe
|
||||
if (availableGB < 4.5) {
|
||||
OrchestratorLogger.logWarning(
|
||||
`CRITICAL: Unity image not cached and only ${availableGB.toFixed(
|
||||
2,
|
||||
)}GB available. Image pull (3.9GB) will likely fail. Consider running cleanup or ensuring pre-pull step succeeds.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
OrchestratorLogger.log('Unity image is cached on agent node - pod should start without pulling');
|
||||
}
|
||||
} catch (checkError) {
|
||||
// Ignore check errors - continue with job creation
|
||||
OrchestratorLogger.logWarning(`Failed to verify Unity image cache: ${checkError}`);
|
||||
}
|
||||
}
|
||||
|
||||
OrchestratorLogger.log('Job does not exist');
|
||||
CloudRunnerLogger.log('Job does not exist');
|
||||
await this.createJob(commands, image, mountdir, workingdir, environment, secrets);
|
||||
OrchestratorLogger.log('Watching pod until running');
|
||||
CloudRunnerLogger.log('Watching pod until running');
|
||||
await KubernetesTaskRunner.watchUntilPodRunning(this.kubeClient, this.podName, this.namespace);
|
||||
|
||||
OrchestratorLogger.log('Pod is running');
|
||||
CloudRunnerLogger.log('Pod is running');
|
||||
output += await KubernetesTaskRunner.runTask(
|
||||
this.kubeConfig,
|
||||
this.kubeClient,
|
||||
@@ -296,9 +173,9 @@ class Kubernetes implements ProviderInterface {
|
||||
this.namespace,
|
||||
);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.log(`error running k8s workflow ${error}`);
|
||||
CloudRunnerLogger.log(`error running k8s workflow ${error}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
OrchestratorLogger.log(
|
||||
CloudRunnerLogger.log(
|
||||
JSON.stringify(
|
||||
(await this.kubeClient.listNamespacedEvent(this.namespace)).body.items
|
||||
.map((x) => {
|
||||
@@ -321,7 +198,7 @@ class Kubernetes implements ProviderInterface {
|
||||
|
||||
return output;
|
||||
} catch (error) {
|
||||
OrchestratorLogger.log('Running job failed');
|
||||
CloudRunnerLogger.log('Running job failed');
|
||||
core.error(JSON.stringify(error, undefined, 4));
|
||||
|
||||
// await this.cleanupTaskResources();
|
||||
@@ -334,8 +211,8 @@ class Kubernetes implements ProviderInterface {
|
||||
image: string,
|
||||
mountdir: string,
|
||||
workingdir: string,
|
||||
environment: OrchestratorEnvironmentVariable[],
|
||||
secrets: OrchestratorSecret[],
|
||||
environment: CloudRunnerEnvironmentVariable[],
|
||||
secrets: CloudRunnerSecret[],
|
||||
) {
|
||||
await this.createNamespacedJob(commands, image, mountdir, workingdir, environment, secrets);
|
||||
const find = await Kubernetes.findPodFromJob(this.kubeClient, this.jobName, this.namespace);
|
||||
@@ -359,8 +236,8 @@ class Kubernetes implements ProviderInterface {
|
||||
image: string,
|
||||
mountdir: string,
|
||||
workingdir: string,
|
||||
environment: OrchestratorEnvironmentVariable[],
|
||||
secrets: OrchestratorSecret[],
|
||||
environment: CloudRunnerEnvironmentVariable[],
|
||||
secrets: CloudRunnerSecret[],
|
||||
) {
|
||||
for (let index = 0; index < 3; index++) {
|
||||
try {
|
||||
@@ -385,13 +262,13 @@ class Kubernetes implements ProviderInterface {
|
||||
// await KubernetesRole.createRole(this.serviceAccountName, this.namespace, this.rbacAuthorizationV1Api);
|
||||
|
||||
const result = await this.kubeClientBatch.createNamespacedJob(this.namespace, jobSpec);
|
||||
OrchestratorLogger.log(`Build job created`);
|
||||
CloudRunnerLogger.log(`Build job created`);
|
||||
await new Promise((promise) => setTimeout(promise, 5000));
|
||||
OrchestratorLogger.log('Job created');
|
||||
CloudRunnerLogger.log('Job created');
|
||||
|
||||
return result.body.metadata?.name;
|
||||
} catch (error) {
|
||||
OrchestratorLogger.log(`Error occured creating job: ${error}`);
|
||||
CloudRunnerLogger.log(`Error occured creating job: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -403,26 +280,26 @@ class Kubernetes implements ProviderInterface {
|
||||
}
|
||||
|
||||
async cleanupTaskResources() {
|
||||
OrchestratorLogger.log('cleaning up');
|
||||
CloudRunnerLogger.log('cleaning up');
|
||||
try {
|
||||
await this.kubeClientBatch.deleteNamespacedJob(this.jobName, this.namespace);
|
||||
await this.kubeClient.deleteNamespacedPod(this.podName, this.namespace);
|
||||
await KubernetesRole.deleteRole(this.serviceAccountName, this.namespace, this.rbacAuthorizationV1Api);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.log(`Failed to cleanup`);
|
||||
CloudRunnerLogger.log(`Failed to cleanup`);
|
||||
if (error.response.body.reason !== `NotFound`) {
|
||||
OrchestratorLogger.log(`Wasn't a not found error: ${error.response.body.reason}`);
|
||||
CloudRunnerLogger.log(`Wasn't a not found error: ${error.response.body.reason}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.kubeClient.deleteNamespacedSecret(this.secretName, this.namespace);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.log(`Failed to cleanup secret`);
|
||||
OrchestratorLogger.log(error.response.body.reason);
|
||||
CloudRunnerLogger.log(`Failed to cleanup secret`);
|
||||
CloudRunnerLogger.log(error.response.body.reason);
|
||||
}
|
||||
OrchestratorLogger.log('cleaned up Secret, Job and Pod');
|
||||
OrchestratorLogger.log('cleaning up finished');
|
||||
CloudRunnerLogger.log('cleaned up Secret, Job and Pod');
|
||||
CloudRunnerLogger.log('cleaning up finished');
|
||||
}
|
||||
|
||||
async cleanupWorkflow(
|
||||
@@ -435,14 +312,14 @@ class Kubernetes implements ProviderInterface {
|
||||
if (BuildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) {
|
||||
return;
|
||||
}
|
||||
OrchestratorLogger.log(`deleting PVC`);
|
||||
CloudRunnerLogger.log(`deleting PVC`);
|
||||
|
||||
try {
|
||||
await this.kubeClient.deleteNamespacedPersistentVolumeClaim(this.pvcName, this.namespace);
|
||||
await this.kubeClient.deleteNamespacedServiceAccount(this.serviceAccountName, this.namespace);
|
||||
OrchestratorLogger.log('cleaned up PVC and Service Account');
|
||||
CloudRunnerLogger.log('cleaned up PVC and Service Account');
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.log(`Cleanup failed ${JSON.stringify(error, undefined, 4)}`);
|
||||
CloudRunnerLogger.log(`Cleanup failed ${JSON.stringify(error, undefined, 4)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { V1EnvVar, V1EnvVarSource, V1SecretKeySelector } from '@kubernetes/client-node';
|
||||
import BuildParameters from '../../../build-parameters';
|
||||
import { CommandHookService } from '../../services/hooks/command-hook-service';
|
||||
import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
|
||||
import CloudRunnerSecret from '../../options/cloud-runner-secret';
|
||||
import CloudRunner from '../../cloud-runner';
|
||||
|
||||
class KubernetesJobSpecFactory {
|
||||
static getJobSpec(
|
||||
command: string,
|
||||
image: string,
|
||||
mountdir: string,
|
||||
workingDirectory: string,
|
||||
environment: CloudRunnerEnvironmentVariable[],
|
||||
secrets: CloudRunnerSecret[],
|
||||
buildGuid: string,
|
||||
buildParameters: BuildParameters,
|
||||
secretName: string,
|
||||
pvcName: string,
|
||||
jobName: string,
|
||||
k8s: any,
|
||||
containerName: string,
|
||||
ip: string = '',
|
||||
) {
|
||||
const job = new k8s.V1Job();
|
||||
job.apiVersion = 'batch/v1';
|
||||
job.kind = 'Job';
|
||||
job.metadata = {
|
||||
name: jobName,
|
||||
labels: {
|
||||
app: 'unity-builder',
|
||||
buildGuid,
|
||||
},
|
||||
};
|
||||
job.spec = {
|
||||
ttlSecondsAfterFinished: 9999,
|
||||
backoffLimit: 0,
|
||||
template: {
|
||||
spec: {
|
||||
volumes: [
|
||||
{
|
||||
name: 'build-mount',
|
||||
persistentVolumeClaim: {
|
||||
claimName: pvcName,
|
||||
},
|
||||
},
|
||||
],
|
||||
containers: [
|
||||
{
|
||||
ttlSecondsAfterFinished: 9999,
|
||||
name: containerName,
|
||||
image,
|
||||
command: ['/bin/sh'],
|
||||
args: [
|
||||
'-c',
|
||||
`${CommandHookService.ApplyHooksToCommands(`${command}\nsleep 2m`, CloudRunner.buildParameters)}`,
|
||||
],
|
||||
|
||||
workingDir: `${workingDirectory}`,
|
||||
resources: {
|
||||
requests: {
|
||||
memory: `${Number.parseInt(buildParameters.containerMemory) / 1024}G` || '750M',
|
||||
cpu: Number.parseInt(buildParameters.containerCpu) / 1024 || '1',
|
||||
},
|
||||
},
|
||||
env: [
|
||||
...environment.map((x) => {
|
||||
const environmentVariable = new V1EnvVar();
|
||||
environmentVariable.name = x.name;
|
||||
environmentVariable.value = x.value;
|
||||
|
||||
return environmentVariable;
|
||||
}),
|
||||
...secrets.map((x) => {
|
||||
const secret = new V1EnvVarSource();
|
||||
secret.secretKeyRef = new V1SecretKeySelector();
|
||||
secret.secretKeyRef.key = x.ParameterKey;
|
||||
secret.secretKeyRef.name = secretName;
|
||||
const environmentVariable = new V1EnvVar();
|
||||
environmentVariable.name = x.EnvironmentVariable;
|
||||
environmentVariable.valueFrom = secret;
|
||||
|
||||
return environmentVariable;
|
||||
}),
|
||||
{ name: 'LOG_SERVICE_IP', value: ip },
|
||||
],
|
||||
volumeMounts: [
|
||||
{
|
||||
name: 'build-mount',
|
||||
mountPath: `${mountdir}`,
|
||||
},
|
||||
],
|
||||
lifecycle: {
|
||||
preStop: {
|
||||
exec: {
|
||||
command: [
|
||||
`wait 60s;
|
||||
cd /data/builder/action/steps;
|
||||
chmod +x /return_license.sh;
|
||||
/return_license.sh;`,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
restartPolicy: 'Never',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env['CLOUD_RUNNER_MINIKUBE']) {
|
||||
job.spec.template.spec.volumes[0] = {
|
||||
name: 'build-mount',
|
||||
hostPath: {
|
||||
path: `/data`,
|
||||
type: `Directory`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
job.spec.template.spec.containers[0].resources.requests[`ephemeral-storage`] = '10Gi';
|
||||
|
||||
return job;
|
||||
}
|
||||
}
|
||||
export default KubernetesJobSpecFactory;
|
||||
23
src/model/cloud-runner/providers/k8s/kubernetes-pods.ts
Normal file
23
src/model/cloud-runner/providers/k8s/kubernetes-pods.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import { CoreV1Api } from '@kubernetes/client-node';
|
||||
class KubernetesPods {
|
||||
public static async IsPodRunning(podName: string, namespace: string, kubeClient: CoreV1Api) {
|
||||
const pods = (await kubeClient.listNamespacedPod(namespace)).body.items.filter((x) => podName === x.metadata?.name);
|
||||
const running = pods.length > 0 && (pods[0].status?.phase === `Running` || pods[0].status?.phase === `Pending`);
|
||||
const phase = pods[0]?.status?.phase || 'undefined status';
|
||||
CloudRunnerLogger.log(`Getting pod status: ${phase}`);
|
||||
if (phase === `Failed`) {
|
||||
throw new Error(`K8s pod failed`);
|
||||
}
|
||||
|
||||
return running;
|
||||
}
|
||||
public static async GetPodStatus(podName: string, namespace: string, kubeClient: CoreV1Api) {
|
||||
const pods = (await kubeClient.listNamespacedPod(namespace)).body.items.find((x) => podName === x.metadata?.name);
|
||||
const phase = pods?.status?.phase || 'undefined status';
|
||||
|
||||
return phase;
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesPods;
|
||||
@@ -1,12 +1,12 @@
|
||||
import { CoreV1Api } from '@kubernetes/client-node';
|
||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||
import CloudRunnerSecret from '../../options/cloud-runner-secret';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import * as base64 from 'base-64';
|
||||
|
||||
class KubernetesSecret {
|
||||
static async createSecret(
|
||||
secrets: OrchestratorSecret[],
|
||||
secrets: CloudRunnerSecret[],
|
||||
secretName: string,
|
||||
namespace: string,
|
||||
kubeClient: CoreV1Api,
|
||||
@@ -23,20 +23,20 @@ class KubernetesSecret {
|
||||
for (const buildSecret of secrets) {
|
||||
secret.data[buildSecret.ParameterKey] = base64.encode(buildSecret.ParameterValue);
|
||||
}
|
||||
OrchestratorLogger.log(`Creating secret: ${secretName}`);
|
||||
CloudRunnerLogger.log(`Creating secret: ${secretName}`);
|
||||
const existingSecrets = await kubeClient.listNamespacedSecret(namespace);
|
||||
const mappedSecrets = existingSecrets.body.items.map((x) => {
|
||||
return x.metadata?.name || `no name`;
|
||||
});
|
||||
|
||||
OrchestratorLogger.log(
|
||||
CloudRunnerLogger.log(
|
||||
`ExistsAlready: ${mappedSecrets.includes(secretName)} SecretsCount: ${mappedSecrets.length}`,
|
||||
);
|
||||
await new Promise((promise) => setTimeout(promise, 15000));
|
||||
await kubeClient.createNamespacedSecret(namespace, secret);
|
||||
OrchestratorLogger.log('Created secret');
|
||||
CloudRunnerLogger.log('Created secret');
|
||||
} catch (error) {
|
||||
OrchestratorLogger.log(`Created secret failed ${error}`);
|
||||
CloudRunnerLogger.log(`Created secret failed ${error}`);
|
||||
throw new Error(`Failed to create kubernetes secret`);
|
||||
}
|
||||
}
|
||||
116
src/model/cloud-runner/providers/k8s/kubernetes-storage.ts
Normal file
116
src/model/cloud-runner/providers/k8s/kubernetes-storage.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { waitUntil } from 'async-wait-until';
|
||||
import * as core from '@actions/core';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
import BuildParameters from '../../../build-parameters';
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import { IncomingMessage } from 'node:http';
|
||||
import GitHub from '../../../github';
|
||||
|
||||
class KubernetesStorage {
|
||||
public static async createPersistentVolumeClaim(
|
||||
buildParameters: BuildParameters,
|
||||
pvcName: string,
|
||||
kubeClient: k8s.CoreV1Api,
|
||||
namespace: string,
|
||||
) {
|
||||
if (buildParameters.kubeVolume !== ``) {
|
||||
CloudRunnerLogger.log(`Kube Volume was input was set ${buildParameters.kubeVolume} overriding ${pvcName}`);
|
||||
pvcName = buildParameters.kubeVolume;
|
||||
|
||||
return;
|
||||
}
|
||||
const allPvc = (await kubeClient.listNamespacedPersistentVolumeClaim(namespace)).body.items;
|
||||
const pvcList = allPvc.map((x) => x.metadata?.name);
|
||||
CloudRunnerLogger.log(`Current PVCs in namespace ${namespace}`);
|
||||
CloudRunnerLogger.log(JSON.stringify(pvcList, undefined, 4));
|
||||
if (pvcList.includes(pvcName)) {
|
||||
CloudRunnerLogger.log(`pvc ${pvcName} already exists`);
|
||||
if (GitHub.githubInputEnabled) {
|
||||
core.setOutput('volume', pvcName);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
CloudRunnerLogger.log(`Creating PVC ${pvcName} (does not exist)`);
|
||||
const result = await KubernetesStorage.createPVC(pvcName, buildParameters, kubeClient, namespace);
|
||||
await KubernetesStorage.handleResult(result, kubeClient, namespace, pvcName);
|
||||
}
|
||||
|
||||
public static async getPVCPhase(kubeClient: k8s.CoreV1Api, name: string, namespace: string) {
|
||||
try {
|
||||
return (await kubeClient.readNamespacedPersistentVolumeClaim(name, namespace)).body.status?.phase;
|
||||
} catch (error) {
|
||||
core.error('Failed to get PVC phase');
|
||||
core.error(JSON.stringify(error, undefined, 4));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async watchUntilPVCNotPending(kubeClient: k8s.CoreV1Api, name: string, namespace: string) {
|
||||
try {
|
||||
CloudRunnerLogger.log(`watch Until PVC Not Pending ${name} ${namespace}`);
|
||||
CloudRunnerLogger.log(`${await this.getPVCPhase(kubeClient, name, namespace)}`);
|
||||
await waitUntil(
|
||||
async () => {
|
||||
return (await this.getPVCPhase(kubeClient, name, namespace)) === 'Pending';
|
||||
},
|
||||
{
|
||||
timeout: 750000,
|
||||
intervalBetweenAttempts: 15000,
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
core.error('Failed to watch PVC');
|
||||
core.error(error.toString());
|
||||
core.error(
|
||||
`PVC Body: ${JSON.stringify(
|
||||
(await kubeClient.readNamespacedPersistentVolumeClaim(name, namespace)).body,
|
||||
undefined,
|
||||
4,
|
||||
)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async createPVC(
|
||||
pvcName: string,
|
||||
buildParameters: BuildParameters,
|
||||
kubeClient: k8s.CoreV1Api,
|
||||
namespace: string,
|
||||
) {
|
||||
const pvc = new k8s.V1PersistentVolumeClaim();
|
||||
pvc.apiVersion = 'v1';
|
||||
pvc.kind = 'PersistentVolumeClaim';
|
||||
pvc.metadata = {
|
||||
name: pvcName,
|
||||
};
|
||||
pvc.spec = {
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
storageClassName: buildParameters.kubeStorageClass === '' ? 'standard' : buildParameters.kubeStorageClass,
|
||||
resources: {
|
||||
requests: {
|
||||
storage: buildParameters.kubeVolumeSize,
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await kubeClient.createNamespacedPersistentVolumeClaim(namespace, pvc);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async handleResult(
|
||||
result: { response: IncomingMessage; body: k8s.V1PersistentVolumeClaim },
|
||||
kubeClient: k8s.CoreV1Api,
|
||||
namespace: string,
|
||||
pvcName: string,
|
||||
) {
|
||||
const name = result.body.metadata?.name || '';
|
||||
CloudRunnerLogger.log(`PVC ${name} created`);
|
||||
await this.watchUntilPVCNotPending(kubeClient, name, namespace);
|
||||
CloudRunnerLogger.log(`PVC ${name} is ready and not pending`);
|
||||
core.setOutput('volume', pvcName);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesStorage;
|
||||
119
src/model/cloud-runner/providers/k8s/kubernetes-task-runner.ts
Normal file
119
src/model/cloud-runner/providers/k8s/kubernetes-task-runner.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import { waitUntil } from 'async-wait-until';
|
||||
import { CloudRunnerSystem } from '../../services/core/cloud-runner-system';
|
||||
import CloudRunner from '../../cloud-runner';
|
||||
import KubernetesPods from './kubernetes-pods';
|
||||
import { FollowLogStreamService } from '../../services/core/follow-log-stream-service';
|
||||
|
||||
class KubernetesTaskRunner {
|
||||
static readonly maxRetry: number = 3;
|
||||
static lastReceivedMessage: string = ``;
|
||||
|
||||
static async runTask(
|
||||
kubeConfig: KubeConfig,
|
||||
kubeClient: CoreV1Api,
|
||||
jobName: string,
|
||||
podName: string,
|
||||
containerName: string,
|
||||
namespace: string,
|
||||
) {
|
||||
let output = '';
|
||||
let shouldReadLogs = true;
|
||||
let shouldCleanup = true;
|
||||
let retriesAfterFinish = 0;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
CloudRunnerLogger.log(
|
||||
`Streaming logs from pod: ${podName} container: ${containerName} namespace: ${namespace} ${CloudRunner.buildParameters.kubeVolumeSize}/${CloudRunner.buildParameters.containerCpu}/${CloudRunner.buildParameters.containerMemory}`,
|
||||
);
|
||||
let extraFlags = ``;
|
||||
extraFlags += (await KubernetesPods.IsPodRunning(podName, namespace, kubeClient))
|
||||
? ` -f -c ${containerName}`
|
||||
: ` --previous`;
|
||||
|
||||
const callback = (outputChunk: string) => {
|
||||
output += outputChunk;
|
||||
|
||||
// split output chunk and handle per line
|
||||
for (const chunk of outputChunk.split(`\n`)) {
|
||||
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
|
||||
chunk,
|
||||
shouldReadLogs,
|
||||
shouldCleanup,
|
||||
output,
|
||||
));
|
||||
}
|
||||
};
|
||||
try {
|
||||
await CloudRunnerSystem.Run(`kubectl logs ${podName}${extraFlags}`, false, true, callback);
|
||||
} catch (error: any) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
const continueStreaming = await KubernetesPods.IsPodRunning(podName, namespace, kubeClient);
|
||||
CloudRunnerLogger.log(`K8s logging error ${error} ${continueStreaming}`);
|
||||
if (continueStreaming) {
|
||||
continue;
|
||||
}
|
||||
if (retriesAfterFinish < KubernetesTaskRunner.maxRetry) {
|
||||
retriesAfterFinish++;
|
||||
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (FollowLogStreamService.DidReceiveEndOfTransmission) {
|
||||
CloudRunnerLogger.log('end of log stream');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static async watchUntilPodRunning(kubeClient: CoreV1Api, podName: string, namespace: string) {
|
||||
let waitComplete: boolean = false;
|
||||
let message = ``;
|
||||
CloudRunnerLogger.log(`Watching ${podName} ${namespace}`);
|
||||
await waitUntil(
|
||||
async () => {
|
||||
const status = await kubeClient.readNamespacedPodStatus(podName, namespace);
|
||||
const phase = status?.body.status?.phase;
|
||||
waitComplete = phase !== 'Pending';
|
||||
message = `Phase:${status.body.status?.phase} \n Reason:${
|
||||
status.body.status?.conditions?.[0].reason || ''
|
||||
} \n Message:${status.body.status?.conditions?.[0].message || ''}`;
|
||||
|
||||
// CloudRunnerLogger.log(
|
||||
// JSON.stringify(
|
||||
// (await kubeClient.listNamespacedEvent(namespace)).body.items
|
||||
// .map((x) => {
|
||||
// return {
|
||||
// message: x.message || ``,
|
||||
// name: x.metadata.name || ``,
|
||||
// reason: x.reason || ``,
|
||||
// };
|
||||
// })
|
||||
// .filter((x) => x.name.includes(podName)),
|
||||
// undefined,
|
||||
// 4,
|
||||
// ),
|
||||
// );
|
||||
if (waitComplete || phase !== 'Pending') return true;
|
||||
|
||||
return false;
|
||||
},
|
||||
{
|
||||
timeout: 2000000,
|
||||
intervalBetweenAttempts: 15000,
|
||||
},
|
||||
);
|
||||
if (!waitComplete) {
|
||||
CloudRunnerLogger.log(message);
|
||||
}
|
||||
|
||||
return waitComplete;
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesTaskRunner;
|
||||
@@ -1,14 +1,13 @@
|
||||
import BuildParameters from '../../../build-parameters';
|
||||
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
|
||||
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||
import { CloudRunnerSystem } from '../../services/core/cloud-runner-system';
|
||||
import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import { ProviderInterface } from '../provider-interface';
|
||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||
import CloudRunnerSecret from '../../options/cloud-runner-secret';
|
||||
import { ProviderResource } from '../provider-resource';
|
||||
import { ProviderWorkflow } from '../provider-workflow';
|
||||
import { quote } from 'shell-quote';
|
||||
|
||||
class LocalOrchestrator implements ProviderInterface {
|
||||
class LocalCloudRunner implements ProviderInterface {
|
||||
listResources(): Promise<ProviderResource[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
@@ -59,29 +58,15 @@ class LocalOrchestrator implements ProviderInterface {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
workingdir: string,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
environment: OrchestratorEnvironmentVariable[],
|
||||
environment: CloudRunnerEnvironmentVariable[],
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
secrets: OrchestratorSecret[],
|
||||
secrets: CloudRunnerSecret[],
|
||||
): Promise<string> {
|
||||
OrchestratorLogger.log(image);
|
||||
OrchestratorLogger.log(buildGuid);
|
||||
OrchestratorLogger.log(commands);
|
||||
CloudRunnerLogger.log(image);
|
||||
CloudRunnerLogger.log(buildGuid);
|
||||
CloudRunnerLogger.log(commands);
|
||||
|
||||
// On Windows, many built-in hooks use POSIX shell syntax. Execute via bash if available.
|
||||
if (process.platform === 'win32') {
|
||||
const inline = commands
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.filter((x) => x.trim().length > 0)
|
||||
.join(' ; ');
|
||||
|
||||
// Use shell-quote to properly escape the command string, preventing command injection
|
||||
const bashWrapped = `bash -lc ${quote([inline])}`;
|
||||
|
||||
return await OrchestratorSystem.Run(bashWrapped);
|
||||
}
|
||||
|
||||
return await OrchestratorSystem.Run(commands);
|
||||
return await CloudRunnerSystem.Run(commands);
|
||||
}
|
||||
}
|
||||
export default LocalOrchestrator;
|
||||
export default LocalCloudRunner;
|
||||
@@ -1,6 +1,6 @@
|
||||
import BuildParameters from '../../build-parameters';
|
||||
import OrchestratorEnvironmentVariable from '../options/orchestrator-environment-variable';
|
||||
import OrchestratorSecret from '../options/orchestrator-secret';
|
||||
import CloudRunnerEnvironmentVariable from '../options/cloud-runner-environment-variable';
|
||||
import CloudRunnerSecret from '../options/cloud-runner-secret';
|
||||
import { ProviderResource } from './provider-resource';
|
||||
import { ProviderWorkflow } from './provider-workflow';
|
||||
|
||||
@@ -35,9 +35,9 @@ export interface ProviderInterface {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
workingdir: string,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
environment: OrchestratorEnvironmentVariable[],
|
||||
environment: CloudRunnerEnvironmentVariable[],
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
secrets: OrchestratorSecret[],
|
||||
secrets: CloudRunnerSecret[],
|
||||
): Promise<string>;
|
||||
garbageCollect(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
@@ -1,12 +1,12 @@
|
||||
import BuildParameters from '../../../build-parameters';
|
||||
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||
import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
|
||||
import CloudRunnerLogger from '../../services/core/cloud-runner-logger';
|
||||
import { ProviderInterface } from '../provider-interface';
|
||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||
import CloudRunnerSecret from '../../options/cloud-runner-secret';
|
||||
import { ProviderResource } from '../provider-resource';
|
||||
import { ProviderWorkflow } from '../provider-workflow';
|
||||
|
||||
class TestOrchestrator implements ProviderInterface {
|
||||
class TestCloudRunner implements ProviderInterface {
|
||||
listResources(): Promise<ProviderResource[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
@@ -51,17 +51,17 @@ class TestOrchestrator implements ProviderInterface {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
workingdir: string,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
environment: OrchestratorEnvironmentVariable[],
|
||||
environment: CloudRunnerEnvironmentVariable[],
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
secrets: OrchestratorSecret[],
|
||||
secrets: CloudRunnerSecret[],
|
||||
): Promise<string> {
|
||||
OrchestratorLogger.log(image);
|
||||
OrchestratorLogger.log(buildGuid);
|
||||
OrchestratorLogger.log(commands);
|
||||
CloudRunnerLogger.log(image);
|
||||
CloudRunnerLogger.log(buildGuid);
|
||||
CloudRunnerLogger.log(commands);
|
||||
|
||||
return await new Promise((result) => {
|
||||
result(commands);
|
||||
});
|
||||
}
|
||||
}
|
||||
export default TestOrchestrator;
|
||||
export default TestCloudRunner;
|
||||
181
src/model/cloud-runner/remote-client/caching.ts
Normal file
181
src/model/cloud-runner/remote-client/caching.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { assert } from 'node:console';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import CloudRunner from '../cloud-runner';
|
||||
import CloudRunnerLogger from '../services/core/cloud-runner-logger';
|
||||
import { CloudRunnerFolders } from '../options/cloud-runner-folders';
|
||||
import { CloudRunnerSystem } from '../services/core/cloud-runner-system';
|
||||
import { LfsHashing } from '../services/utility/lfs-hashing';
|
||||
import { RemoteClientLogger } from './remote-client-logger';
|
||||
import { Cli } from '../../cli/cli';
|
||||
import { CliFunction } from '../../cli/cli-functions-repository';
|
||||
// eslint-disable-next-line github/no-then
|
||||
const fileExists = async (fpath: fs.PathLike) => !!(await fs.promises.stat(fpath).catch(() => false));
|
||||
|
||||
export class Caching {
|
||||
@CliFunction(`cache-push`, `push to cache`)
|
||||
static async cachePush() {
|
||||
try {
|
||||
const buildParameter = JSON.parse(process.env.BUILD_PARAMETERS || '{}');
|
||||
CloudRunner.buildParameters = buildParameter;
|
||||
await Caching.PushToCache(
|
||||
Cli.options!['cachePushTo'],
|
||||
Cli.options!['cachePushFrom'],
|
||||
Cli.options!['artifactName'] || '',
|
||||
);
|
||||
} catch (error: any) {
|
||||
CloudRunnerLogger.log(`${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@CliFunction(`cache-pull`, `pull from cache`)
|
||||
static async cachePull() {
|
||||
try {
|
||||
const buildParameter = JSON.parse(process.env.BUILD_PARAMETERS || '{}');
|
||||
CloudRunner.buildParameters = buildParameter;
|
||||
await Caching.PullFromCache(
|
||||
Cli.options!['cachePushFrom'],
|
||||
Cli.options!['cachePushTo'],
|
||||
Cli.options!['artifactName'] || '',
|
||||
);
|
||||
} catch (error: any) {
|
||||
CloudRunnerLogger.log(`${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async PushToCache(cacheFolder: string, sourceFolder: string, cacheArtifactName: string) {
|
||||
CloudRunnerLogger.log(`Pushing to cache ${sourceFolder}`);
|
||||
cacheArtifactName = cacheArtifactName.replace(' ', '');
|
||||
const startPath = process.cwd();
|
||||
let compressionSuffix = '';
|
||||
if (CloudRunner.buildParameters.useCompressionStrategy === true) {
|
||||
compressionSuffix = `.lz4`;
|
||||
}
|
||||
CloudRunnerLogger.log(`Compression: ${CloudRunner.buildParameters.useCompressionStrategy} ${compressionSuffix}`);
|
||||
try {
|
||||
if (!(await fileExists(cacheFolder))) {
|
||||
await CloudRunnerSystem.Run(`mkdir -p ${cacheFolder}`);
|
||||
}
|
||||
process.chdir(path.resolve(sourceFolder, '..'));
|
||||
|
||||
if (CloudRunner.buildParameters.cloudRunnerDebug === true) {
|
||||
CloudRunnerLogger.log(
|
||||
`Hashed cache folder ${await LfsHashing.hashAllFiles(sourceFolder)} ${sourceFolder} ${path.basename(
|
||||
sourceFolder,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
const contents = await fs.promises.readdir(path.basename(sourceFolder));
|
||||
CloudRunnerLogger.log(
|
||||
`There is ${contents.length} files/dir in the source folder ${path.basename(sourceFolder)}`,
|
||||
);
|
||||
|
||||
if (contents.length === 0) {
|
||||
CloudRunnerLogger.log(
|
||||
`Did not push source folder to cache because it was empty ${path.basename(sourceFolder)}`,
|
||||
);
|
||||
process.chdir(`${startPath}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await CloudRunnerSystem.Run(
|
||||
`tar -cf ${cacheArtifactName}.tar${compressionSuffix} "${path.basename(sourceFolder)}"`,
|
||||
);
|
||||
await CloudRunnerSystem.Run(`du ${cacheArtifactName}.tar${compressionSuffix}`);
|
||||
assert(await fileExists(`${cacheArtifactName}.tar${compressionSuffix}`), 'cache archive exists');
|
||||
assert(await fileExists(path.basename(sourceFolder)), 'source folder exists');
|
||||
await CloudRunnerSystem.Run(`mv ${cacheArtifactName}.tar${compressionSuffix} ${cacheFolder}`);
|
||||
RemoteClientLogger.log(`moved cache entry ${cacheArtifactName} to ${cacheFolder}`);
|
||||
assert(
|
||||
await fileExists(`${path.join(cacheFolder, cacheArtifactName)}.tar${compressionSuffix}`),
|
||||
'cache archive exists inside cache folder',
|
||||
);
|
||||
} catch (error) {
|
||||
process.chdir(`${startPath}`);
|
||||
throw error;
|
||||
}
|
||||
process.chdir(`${startPath}`);
|
||||
}
|
||||
public static async PullFromCache(cacheFolder: string, destinationFolder: string, cacheArtifactName: string = ``) {
|
||||
CloudRunnerLogger.log(`Pulling from cache ${destinationFolder} ${CloudRunner.buildParameters.skipCache}`);
|
||||
if (`${CloudRunner.buildParameters.skipCache}` === `true`) {
|
||||
CloudRunnerLogger.log(`Skipping cache debugSkipCache is true`);
|
||||
|
||||
return;
|
||||
}
|
||||
cacheArtifactName = cacheArtifactName.replace(' ', '');
|
||||
let compressionSuffix = '';
|
||||
if (CloudRunner.buildParameters.useCompressionStrategy === true) {
|
||||
compressionSuffix = `.lz4`;
|
||||
}
|
||||
const startPath = process.cwd();
|
||||
RemoteClientLogger.log(`Caching for (lz4 ${compressionSuffix}) ${path.basename(destinationFolder)}`);
|
||||
try {
|
||||
if (!(await fileExists(cacheFolder))) {
|
||||
await fs.promises.mkdir(cacheFolder);
|
||||
}
|
||||
|
||||
if (!(await fileExists(destinationFolder))) {
|
||||
await fs.promises.mkdir(destinationFolder);
|
||||
}
|
||||
|
||||
const latestInBranch = await (
|
||||
await CloudRunnerSystem.Run(`ls -t "${cacheFolder}" | grep .tar${compressionSuffix}$ | head -1`)
|
||||
)
|
||||
.replace(/\n/g, ``)
|
||||
.replace(`.tar${compressionSuffix}`, '');
|
||||
|
||||
process.chdir(cacheFolder);
|
||||
|
||||
const cacheSelection =
|
||||
cacheArtifactName !== `` && (await fileExists(`${cacheArtifactName}.tar${compressionSuffix}`))
|
||||
? cacheArtifactName
|
||||
: latestInBranch;
|
||||
await CloudRunnerLogger.log(`cache key ${cacheArtifactName} selection ${cacheSelection}`);
|
||||
|
||||
if (await fileExists(`${cacheSelection}.tar${compressionSuffix}`)) {
|
||||
const resultsFolder = `results${CloudRunner.buildParameters.buildGuid}`;
|
||||
await CloudRunnerSystem.Run(`mkdir -p ${resultsFolder}`);
|
||||
RemoteClientLogger.log(`cache item exists ${cacheFolder}/${cacheSelection}.tar${compressionSuffix}`);
|
||||
const fullResultsFolder = path.join(cacheFolder, resultsFolder);
|
||||
await CloudRunnerSystem.Run(`tar -xf ${cacheSelection}.tar${compressionSuffix} -C ${fullResultsFolder}`);
|
||||
RemoteClientLogger.log(`cache item extracted to ${fullResultsFolder}`);
|
||||
assert(await fileExists(fullResultsFolder), `cache extraction results folder exists`);
|
||||
const destinationParentFolder = path.resolve(destinationFolder, '..');
|
||||
|
||||
if (await fileExists(destinationFolder)) {
|
||||
await fs.promises.rmdir(destinationFolder, { recursive: true });
|
||||
}
|
||||
await CloudRunnerSystem.Run(
|
||||
`mv "${path.join(fullResultsFolder, path.basename(destinationFolder))}" "${destinationParentFolder}"`,
|
||||
);
|
||||
const contents = await fs.promises.readdir(
|
||||
path.join(destinationParentFolder, path.basename(destinationFolder)),
|
||||
);
|
||||
CloudRunnerLogger.log(
|
||||
`There is ${contents.length} files/dir in the cache pulled contents for ${path.basename(destinationFolder)}`,
|
||||
);
|
||||
} else {
|
||||
RemoteClientLogger.logWarning(`cache item ${cacheArtifactName} doesn't exist ${destinationFolder}`);
|
||||
if (cacheSelection !== ``) {
|
||||
RemoteClientLogger.logWarning(
|
||||
`cache item ${cacheArtifactName}.tar${compressionSuffix} doesn't exist ${destinationFolder}`,
|
||||
);
|
||||
throw new Error(`Failed to get cache item, but cache hit was found: ${cacheSelection}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
process.chdir(startPath);
|
||||
throw error;
|
||||
}
|
||||
process.chdir(startPath);
|
||||
}
|
||||
|
||||
public static async handleCachePurging() {
|
||||
if (process.env.PURGE_REMOTE_BUILDER_CACHE !== undefined) {
|
||||
RemoteClientLogger.log(`purging ${CloudRunnerFolders.purgeRemoteCaching}`);
|
||||
fs.promises.rmdir(CloudRunnerFolders.cacheFolder, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
251
src/model/cloud-runner/remote-client/index.ts
Normal file
251
src/model/cloud-runner/remote-client/index.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import fs from 'node:fs';
|
||||
import CloudRunner from '../cloud-runner';
|
||||
import { CloudRunnerFolders } from '../options/cloud-runner-folders';
|
||||
import { Caching } from './caching';
|
||||
import { LfsHashing } from '../services/utility/lfs-hashing';
|
||||
import { RemoteClientLogger } from './remote-client-logger';
|
||||
import path from 'node:path';
|
||||
import { assert } from 'node:console';
|
||||
import CloudRunnerLogger from '../services/core/cloud-runner-logger';
|
||||
import { CliFunction } from '../../cli/cli-functions-repository';
|
||||
import { CloudRunnerSystem } from '../services/core/cloud-runner-system';
|
||||
import YAML from 'yaml';
|
||||
import GitHub from '../../github';
|
||||
import BuildParameters from '../../build-parameters';
|
||||
import { Cli } from '../../cli/cli';
|
||||
import CloudRunnerOptions from '../options/cloud-runner-options';
|
||||
|
||||
export class RemoteClient {
|
||||
@CliFunction(`remote-cli-pre-build`, `sets up a repository, usually before a game-ci build`)
|
||||
static async setupRemoteClient() {
|
||||
CloudRunnerLogger.log(`bootstrap game ci cloud runner...`);
|
||||
if (!(await RemoteClient.handleRetainedWorkspace())) {
|
||||
await RemoteClient.bootstrapRepository();
|
||||
}
|
||||
await RemoteClient.replaceLargePackageReferencesWithSharedReferences();
|
||||
await RemoteClient.runCustomHookFiles(`before-build`);
|
||||
}
|
||||
|
||||
@CliFunction('remote-cli-log-stream', `log stream from standard input`)
|
||||
public static async remoteClientLogStream() {
|
||||
const logFile = Cli.options!['logFile'];
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding('utf8');
|
||||
|
||||
let lingeringLine = '';
|
||||
|
||||
process.stdin.on('data', (chunk) => {
|
||||
const lines = chunk.toString().split('\n');
|
||||
|
||||
lines[0] = lingeringLine + lines[0];
|
||||
lingeringLine = lines.pop() || '';
|
||||
|
||||
for (const element of lines) {
|
||||
if (CloudRunnerOptions.providerStrategy !== 'k8s') {
|
||||
CloudRunnerLogger.log(element);
|
||||
} else {
|
||||
fs.appendFileSync(logFile, element);
|
||||
CloudRunnerLogger.log(element);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
if (CloudRunnerOptions.providerStrategy !== 'k8s') {
|
||||
CloudRunnerLogger.log(lingeringLine);
|
||||
} else {
|
||||
fs.appendFileSync(logFile, lingeringLine);
|
||||
CloudRunnerLogger.log(lingeringLine);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@CliFunction(`remote-cli-post-build`, `runs a cloud runner build`)
|
||||
public static async remoteClientPostBuild(): Promise<string> {
|
||||
RemoteClientLogger.log(`Running POST build tasks`);
|
||||
|
||||
await Caching.PushToCache(
|
||||
CloudRunnerFolders.ToLinuxFolder(`${CloudRunnerFolders.cacheFolderForCacheKeyFull}/Library`),
|
||||
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.libraryFolderAbsolute),
|
||||
`lib-${CloudRunner.buildParameters.buildGuid}`,
|
||||
);
|
||||
|
||||
await Caching.PushToCache(
|
||||
CloudRunnerFolders.ToLinuxFolder(`${CloudRunnerFolders.cacheFolderForCacheKeyFull}/build`),
|
||||
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.projectBuildFolderAbsolute),
|
||||
`build-${CloudRunner.buildParameters.buildGuid}`,
|
||||
);
|
||||
|
||||
if (!BuildParameters.shouldUseRetainedWorkspaceMode(CloudRunner.buildParameters)) {
|
||||
await CloudRunnerSystem.Run(
|
||||
`rm -r ${CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await RemoteClient.runCustomHookFiles(`after-build`);
|
||||
|
||||
// WIP - need to give the pod permissions to create config map
|
||||
await RemoteClientLogger.handleLogManagementPostJob();
|
||||
|
||||
return new Promise((result) => result(``));
|
||||
}
|
||||
static async runCustomHookFiles(hookLifecycle: string) {
|
||||
RemoteClientLogger.log(`RunCustomHookFiles: ${hookLifecycle}`);
|
||||
const gameCiCustomHooksPath = path.join(CloudRunnerFolders.repoPathAbsolute, `game-ci`, `hooks`);
|
||||
try {
|
||||
const files = fs.readdirSync(gameCiCustomHooksPath);
|
||||
for (const file of files) {
|
||||
const fileContents = fs.readFileSync(path.join(gameCiCustomHooksPath, file), `utf8`);
|
||||
const fileContentsObject = YAML.parse(fileContents.toString());
|
||||
if (fileContentsObject.hook === hookLifecycle) {
|
||||
RemoteClientLogger.log(`Active Hook File ${file} \n \n file contents: \n ${fileContents}`);
|
||||
await CloudRunnerSystem.Run(fileContentsObject.commands);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
RemoteClientLogger.log(JSON.stringify(error, undefined, 4));
|
||||
}
|
||||
}
|
||||
public static async bootstrapRepository() {
|
||||
await CloudRunnerSystem.Run(
|
||||
`mkdir -p ${CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute)}`,
|
||||
);
|
||||
await CloudRunnerSystem.Run(
|
||||
`mkdir -p ${CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.cacheFolderForCacheKeyFull)}`,
|
||||
);
|
||||
await RemoteClient.cloneRepoWithoutLFSFiles();
|
||||
await RemoteClient.sizeOfFolder(
|
||||
'repo before lfs cache pull',
|
||||
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.repoPathAbsolute),
|
||||
);
|
||||
const lfsHashes = await LfsHashing.createLFSHashFiles();
|
||||
if (fs.existsSync(CloudRunnerFolders.libraryFolderAbsolute)) {
|
||||
RemoteClientLogger.logWarning(`!Warning!: The Unity library was included in the git repository`);
|
||||
}
|
||||
await Caching.PullFromCache(
|
||||
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.lfsCacheFolderFull),
|
||||
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.lfsFolderAbsolute),
|
||||
`${lfsHashes.lfsGuidSum}`,
|
||||
);
|
||||
await RemoteClient.sizeOfFolder('repo after lfs cache pull', CloudRunnerFolders.repoPathAbsolute);
|
||||
await RemoteClient.pullLatestLFS();
|
||||
await RemoteClient.sizeOfFolder('repo before lfs git pull', CloudRunnerFolders.repoPathAbsolute);
|
||||
await Caching.PushToCache(
|
||||
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.lfsCacheFolderFull),
|
||||
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.lfsFolderAbsolute),
|
||||
`${lfsHashes.lfsGuidSum}`,
|
||||
);
|
||||
await Caching.PullFromCache(
|
||||
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.libraryCacheFolderFull),
|
||||
CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.libraryFolderAbsolute),
|
||||
);
|
||||
await RemoteClient.sizeOfFolder('repo after library cache pull', CloudRunnerFolders.repoPathAbsolute);
|
||||
await Caching.handleCachePurging();
|
||||
}
|
||||
|
||||
private static async sizeOfFolder(message: string, folder: string) {
|
||||
if (CloudRunner.buildParameters.cloudRunnerDebug) {
|
||||
CloudRunnerLogger.log(`Size of ${message}`);
|
||||
await CloudRunnerSystem.Run(`du -sh ${folder}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async cloneRepoWithoutLFSFiles() {
|
||||
process.chdir(`${CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute}`);
|
||||
if (
|
||||
fs.existsSync(CloudRunnerFolders.repoPathAbsolute) &&
|
||||
!fs.existsSync(path.join(CloudRunnerFolders.repoPathAbsolute, `.git`))
|
||||
) {
|
||||
await CloudRunnerSystem.Run(`rm -r ${CloudRunnerFolders.repoPathAbsolute}`);
|
||||
CloudRunnerLogger.log(`${CloudRunnerFolders.repoPathAbsolute} repo exists, but no git folder, cleaning up`);
|
||||
}
|
||||
|
||||
if (
|
||||
BuildParameters.shouldUseRetainedWorkspaceMode(CloudRunner.buildParameters) &&
|
||||
fs.existsSync(path.join(CloudRunnerFolders.repoPathAbsolute, `.git`))
|
||||
) {
|
||||
process.chdir(CloudRunnerFolders.repoPathAbsolute);
|
||||
RemoteClientLogger.log(
|
||||
`${
|
||||
CloudRunnerFolders.repoPathAbsolute
|
||||
} repo exists - skipping clone - retained workspace mode ${BuildParameters.shouldUseRetainedWorkspaceMode(
|
||||
CloudRunner.buildParameters,
|
||||
)}`,
|
||||
);
|
||||
await CloudRunnerSystem.Run(`git fetch && git reset --hard ${CloudRunner.buildParameters.gitSha}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
|
||||
await CloudRunnerSystem.Run(`git config --global advice.detachedHead false`);
|
||||
RemoteClientLogger.log(`Cloning the repository being built:`);
|
||||
await CloudRunnerSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
|
||||
await CloudRunnerSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
|
||||
try {
|
||||
await CloudRunnerSystem.Run(
|
||||
`git clone ${CloudRunnerFolders.targetBuildRepoUrl} ${path.basename(CloudRunnerFolders.repoPathAbsolute)}`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
throw error;
|
||||
}
|
||||
process.chdir(CloudRunnerFolders.repoPathAbsolute);
|
||||
await CloudRunnerSystem.Run(`git lfs install`);
|
||||
assert(fs.existsSync(`.git`), 'git folder exists');
|
||||
RemoteClientLogger.log(`${CloudRunner.buildParameters.branch}`);
|
||||
if (CloudRunner.buildParameters.gitSha !== undefined) {
|
||||
await CloudRunnerSystem.Run(`git checkout ${CloudRunner.buildParameters.gitSha}`);
|
||||
} else {
|
||||
await CloudRunnerSystem.Run(`git checkout ${CloudRunner.buildParameters.branch}`);
|
||||
RemoteClientLogger.log(`buildParameter Git Sha is empty`);
|
||||
}
|
||||
|
||||
assert(fs.existsSync(path.join(`.git`, `lfs`)), 'LFS folder should not exist before caching');
|
||||
RemoteClientLogger.log(`Checked out ${CloudRunner.buildParameters.branch}`);
|
||||
}
|
||||
|
||||
static async replaceLargePackageReferencesWithSharedReferences() {
|
||||
CloudRunnerLogger.log(`Use Shared Pkgs ${CloudRunner.buildParameters.useLargePackages}`);
|
||||
GitHub.updateGitHubCheck(`Use Shared Pkgs ${CloudRunner.buildParameters.useLargePackages}`, ``);
|
||||
if (CloudRunner.buildParameters.useLargePackages) {
|
||||
const filePath = path.join(CloudRunnerFolders.projectPathAbsolute, `Packages/manifest.json`);
|
||||
let manifest = fs.readFileSync(filePath, 'utf8');
|
||||
manifest = manifest.replace(/LargeContent/g, '../../../LargeContent');
|
||||
fs.writeFileSync(filePath, manifest);
|
||||
CloudRunnerLogger.log(`Package Manifest \n ${manifest}`);
|
||||
GitHub.updateGitHubCheck(`Package Manifest \n ${manifest}`, ``);
|
||||
}
|
||||
}
|
||||
|
||||
private static async pullLatestLFS() {
|
||||
process.chdir(CloudRunnerFolders.repoPathAbsolute);
|
||||
await CloudRunnerSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge -- %f"`);
|
||||
await CloudRunnerSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process"`);
|
||||
if (!CloudRunner.buildParameters.skipLfs) {
|
||||
await CloudRunnerSystem.Run(`git lfs pull`);
|
||||
RemoteClientLogger.log(`pulled latest LFS files`);
|
||||
assert(fs.existsSync(CloudRunnerFolders.lfsFolderAbsolute));
|
||||
}
|
||||
}
|
||||
static async handleRetainedWorkspace() {
|
||||
RemoteClientLogger.log(
|
||||
`Retained Workspace: ${BuildParameters.shouldUseRetainedWorkspaceMode(CloudRunner.buildParameters)}`,
|
||||
);
|
||||
if (
|
||||
BuildParameters.shouldUseRetainedWorkspaceMode(CloudRunner.buildParameters) &&
|
||||
fs.existsSync(CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute)) &&
|
||||
fs.existsSync(CloudRunnerFolders.ToLinuxFolder(path.join(CloudRunnerFolders.repoPathAbsolute, `.git`)))
|
||||
) {
|
||||
CloudRunnerLogger.log(`Retained Workspace Already Exists!`);
|
||||
process.chdir(CloudRunnerFolders.ToLinuxFolder(CloudRunnerFolders.repoPathAbsolute));
|
||||
await CloudRunnerSystem.Run(`git fetch`);
|
||||
await CloudRunnerSystem.Run(`git lfs pull`);
|
||||
await CloudRunnerSystem.Run(`git reset --hard "${CloudRunner.buildParameters.gitSha}"`);
|
||||
await CloudRunnerSystem.Run(`git checkout ${CloudRunner.buildParameters.gitSha}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
82
src/model/cloud-runner/remote-client/remote-client-logger.ts
Normal file
82
src/model/cloud-runner/remote-client/remote-client-logger.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import CloudRunnerLogger from '../services/core/cloud-runner-logger';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import CloudRunner from '../cloud-runner';
|
||||
import CloudRunnerOptions from '../options/cloud-runner-options';
|
||||
|
||||
export class RemoteClientLogger {
|
||||
private static get LogFilePath() {
|
||||
return path.join(`/home`, `job-log.txt`);
|
||||
}
|
||||
|
||||
public static log(message: string) {
|
||||
const finalMessage = `[Client] ${message}`;
|
||||
this.appendToFile(finalMessage);
|
||||
CloudRunnerLogger.log(finalMessage);
|
||||
}
|
||||
|
||||
public static logCliError(message: string) {
|
||||
CloudRunnerLogger.log(`[Client][Error] ${message}`);
|
||||
}
|
||||
|
||||
public static logCliDiagnostic(message: string) {
|
||||
CloudRunnerLogger.log(`[Client][Diagnostic] ${message}`);
|
||||
}
|
||||
|
||||
public static logWarning(message: string) {
|
||||
CloudRunnerLogger.logWarning(message);
|
||||
}
|
||||
|
||||
public static appendToFile(message: string) {
|
||||
if (CloudRunner.isCloudRunnerEnvironment) {
|
||||
fs.appendFileSync(RemoteClientLogger.LogFilePath, `${message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async handleLogManagementPostJob() {
|
||||
if (CloudRunnerOptions.providerStrategy !== 'k8s') {
|
||||
return;
|
||||
}
|
||||
CloudRunnerLogger.log(`Collected Logs`);
|
||||
|
||||
// check for log file not existing
|
||||
if (!fs.existsSync(RemoteClientLogger.LogFilePath)) {
|
||||
CloudRunnerLogger.log(`Log file does not exist`);
|
||||
|
||||
// check if CloudRunner.isCloudRunnerEnvironment is true, log
|
||||
if (!CloudRunner.isCloudRunnerEnvironment) {
|
||||
CloudRunnerLogger.log(`Cloud Runner is not running in a cloud environment, not collecting logs`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
CloudRunnerLogger.log(`Log file exist`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
|
||||
// let hashedLogs = fs.readFileSync(RemoteClientLogger.LogFilePath).toString();
|
||||
//
|
||||
// hashedLogs = md5(hashedLogs);
|
||||
//
|
||||
// for (let index = 0; index < 3; index++) {
|
||||
// CloudRunnerLogger.log(`LOGHASH: ${hashedLogs}`);
|
||||
// const logs = fs.readFileSync(RemoteClientLogger.LogFilePath).toString();
|
||||
// CloudRunnerLogger.log(`LOGS: ${Buffer.from(logs).toString('base64')}`);
|
||||
// CloudRunnerLogger.log(
|
||||
// `Game CI's "Cloud Runner System" will cancel the log when it has successfully received the log data to verify all logs have been received.`,
|
||||
// );
|
||||
//
|
||||
// // wait for 15 seconds to allow the log to be sent
|
||||
// await new Promise((resolve) => setTimeout(resolve, 15000));
|
||||
// }
|
||||
}
|
||||
public static HandleLog(message: string): boolean {
|
||||
if (RemoteClientLogger.value !== '') {
|
||||
RemoteClientLogger.value += `\n`;
|
||||
}
|
||||
|
||||
RemoteClientLogger.value += message;
|
||||
|
||||
return false;
|
||||
}
|
||||
static value: string = '';
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as core from '@actions/core';
|
||||
|
||||
class OrchestratorLogger {
|
||||
class CloudRunnerLogger {
|
||||
private static timestamp: number;
|
||||
private static globalTimestamp: number;
|
||||
|
||||
@@ -44,4 +44,4 @@ class OrchestratorLogger {
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
export default OrchestratorLogger;
|
||||
export default CloudRunnerLogger;
|
||||
@@ -1,6 +1,6 @@
|
||||
import BuildParameters from '../../../build-parameters';
|
||||
|
||||
class OrchestratorResult {
|
||||
class CloudRunnerResult {
|
||||
public BuildParameters: BuildParameters;
|
||||
public BuildResults: string;
|
||||
public BuildSucceeded: boolean;
|
||||
@@ -21,4 +21,4 @@ class OrchestratorResult {
|
||||
this.LibraryCacheUsed = libraryCacheUsed;
|
||||
}
|
||||
}
|
||||
export default OrchestratorResult;
|
||||
export default CloudRunnerResult;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { exec } from 'child_process';
|
||||
import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
|
||||
|
||||
export class OrchestratorSystem {
|
||||
export class CloudRunnerSystem {
|
||||
public static async RunAndReadLines(command: string): Promise<string[]> {
|
||||
const result = await OrchestratorSystem.Run(command, false, true);
|
||||
const result = await CloudRunnerSystem.Run(command, false, true);
|
||||
|
||||
return result
|
||||
.split(`\n`)
|
||||
@@ -1,7 +1,7 @@
|
||||
import GitHub from '../../../github';
|
||||
import Orchestrator from '../../orchestrator';
|
||||
import { OrchestratorStatics } from '../../options/orchestrator-statics';
|
||||
import OrchestratorLogger from './orchestrator-logger';
|
||||
import CloudRunner from '../../cloud-runner';
|
||||
import { CloudRunnerStatics } from '../../options/cloud-runner-statics';
|
||||
import CloudRunnerLogger from './cloud-runner-logger';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
export class FollowLogStreamService {
|
||||
@@ -11,8 +11,8 @@ export class FollowLogStreamService {
|
||||
static errors = ``;
|
||||
public static DidReceiveEndOfTransmission = false;
|
||||
public static handleIteration(message: string, shouldReadLogs: boolean, shouldCleanup: boolean, output: string) {
|
||||
if (message.includes(`---${Orchestrator.buildParameters.logId}`)) {
|
||||
OrchestratorLogger.log('End of log transmission received');
|
||||
if (message.includes(`---${CloudRunner.buildParameters.logId}`)) {
|
||||
CloudRunnerLogger.log('End of log transmission received');
|
||||
FollowLogStreamService.DidReceiveEndOfTransmission = true;
|
||||
shouldReadLogs = false;
|
||||
} else if (message.includes('Rebuilding Library because the asset database could not be found!')) {
|
||||
@@ -47,10 +47,10 @@ export class FollowLogStreamService {
|
||||
} else if (message.toLowerCase().includes('cannot be found')) {
|
||||
FollowLogStreamService.errors += `\n${message}`;
|
||||
}
|
||||
|
||||
// Always append log lines to output so tests can assert on BuildResults
|
||||
output += `${message}\n`;
|
||||
OrchestratorLogger.log(`[${OrchestratorStatics.logPrefix}] ${message}`);
|
||||
if (CloudRunner.buildParameters.cloudRunnerDebug) {
|
||||
output += `${message}\n`;
|
||||
}
|
||||
CloudRunnerLogger.log(`[${CloudRunnerStatics.logPrefix}] ${message}`);
|
||||
|
||||
return { shouldReadLogs, shouldCleanup, output };
|
||||
}
|
||||
@@ -1,112 +1,23 @@
|
||||
import OrchestratorLogger from './orchestrator-logger';
|
||||
import { CloudRunnerSystem } from './cloud-runner-system';
|
||||
import fs from 'node:fs';
|
||||
import CloudRunnerLogger from './cloud-runner-logger';
|
||||
import BuildParameters from '../../../build-parameters';
|
||||
import Orchestrator from '../../orchestrator';
|
||||
import Input from '../../../input';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadBucketCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { AwsClientFactory } from '../../providers/aws/aws-client-factory';
|
||||
import { promisify } from 'node:util';
|
||||
import { exec as execCallback } from 'node:child_process';
|
||||
const exec = promisify(execCallback);
|
||||
import CloudRunner from '../../cloud-runner';
|
||||
export class SharedWorkspaceLocking {
|
||||
private static _s3: S3;
|
||||
private static get s3(): S3 {
|
||||
if (!SharedWorkspaceLocking._s3) {
|
||||
// Use factory so LocalStack endpoint/path-style settings are honored
|
||||
SharedWorkspaceLocking._s3 = AwsClientFactory.getS3();
|
||||
}
|
||||
|
||||
return SharedWorkspaceLocking._s3;
|
||||
}
|
||||
private static get useRclone() {
|
||||
return Orchestrator.buildParameters.storageProvider === 'rclone';
|
||||
}
|
||||
private static async rclone(command: string): Promise<string> {
|
||||
const { stdout } = await exec(`rclone ${command}`);
|
||||
|
||||
return stdout.toString();
|
||||
}
|
||||
private static get bucket() {
|
||||
return SharedWorkspaceLocking.useRclone
|
||||
? Orchestrator.buildParameters.rcloneRemote
|
||||
: Orchestrator.buildParameters.awsStackName;
|
||||
}
|
||||
public static get workspaceBucketRoot() {
|
||||
return SharedWorkspaceLocking.useRclone
|
||||
? `${SharedWorkspaceLocking.bucket}/`
|
||||
: `s3://${SharedWorkspaceLocking.bucket}/`;
|
||||
return `s3://${CloudRunner.buildParameters.awsStackName}/`;
|
||||
}
|
||||
public static get workspaceRoot() {
|
||||
return `${SharedWorkspaceLocking.workspaceBucketRoot}locks/`;
|
||||
}
|
||||
private static get workspacePrefix() {
|
||||
return `locks/`;
|
||||
}
|
||||
private static async ensureBucketExists(): Promise<void> {
|
||||
const bucket = SharedWorkspaceLocking.bucket;
|
||||
if (SharedWorkspaceLocking.useRclone) {
|
||||
try {
|
||||
await SharedWorkspaceLocking.rclone(`lsf ${bucket}`);
|
||||
} catch {
|
||||
await SharedWorkspaceLocking.rclone(`mkdir ${bucket}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await SharedWorkspaceLocking.s3.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||
} catch {
|
||||
const region = Input.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
|
||||
const createParameters: any = { Bucket: bucket };
|
||||
if (region && region !== 'us-east-1') {
|
||||
createParameters.CreateBucketConfiguration = { LocationConstraint: region };
|
||||
}
|
||||
await SharedWorkspaceLocking.s3.send(new CreateBucketCommand(createParameters));
|
||||
}
|
||||
}
|
||||
private static async listObjects(prefix: string, bucket = SharedWorkspaceLocking.bucket): Promise<string[]> {
|
||||
await SharedWorkspaceLocking.ensureBucketExists();
|
||||
if (prefix !== '' && !prefix.endsWith('/')) {
|
||||
prefix += '/';
|
||||
}
|
||||
if (SharedWorkspaceLocking.useRclone) {
|
||||
const path = `${bucket}/${prefix}`;
|
||||
try {
|
||||
const output = await SharedWorkspaceLocking.rclone(`lsjson ${path}`);
|
||||
const json = JSON.parse(output) as { Name: string; IsDir: boolean }[];
|
||||
|
||||
return json.map((entry) => (entry.IsDir ? `${entry.Name}/` : entry.Name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const result = await SharedWorkspaceLocking.s3.send(
|
||||
new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, Delimiter: '/' }),
|
||||
);
|
||||
const entries: string[] = [];
|
||||
for (const p of result.CommonPrefixes || []) {
|
||||
if (p.Prefix) entries.push(p.Prefix.slice(prefix.length));
|
||||
}
|
||||
for (const c of result.Contents || []) {
|
||||
if (c.Key && c.Key !== prefix) entries.push(c.Key.slice(prefix.length));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
public static async GetAllWorkspaces(buildParametersContext: BuildParameters): Promise<string[]> {
|
||||
if (!(await SharedWorkspaceLocking.DoesCacheKeyTopLevelExist(buildParametersContext))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
await SharedWorkspaceLocking.listObjects(
|
||||
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
|
||||
await SharedWorkspaceLocking.ReadLines(
|
||||
`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/`,
|
||||
)
|
||||
)
|
||||
.map((x) => x.replace(`/`, ``))
|
||||
@@ -115,11 +26,13 @@ export class SharedWorkspaceLocking {
|
||||
}
|
||||
public static async DoesCacheKeyTopLevelExist(buildParametersContext: BuildParameters) {
|
||||
try {
|
||||
const rootLines = await SharedWorkspaceLocking.listObjects('');
|
||||
const rootLines = await SharedWorkspaceLocking.ReadLines(
|
||||
`aws s3 ls ${SharedWorkspaceLocking.workspaceBucketRoot}`,
|
||||
);
|
||||
const lockFolderExists = rootLines.map((x) => x.replace(`/`, ``)).includes(`locks`);
|
||||
|
||||
if (lockFolderExists) {
|
||||
const lines = await SharedWorkspaceLocking.listObjects(SharedWorkspaceLocking.workspacePrefix);
|
||||
const lines = await SharedWorkspaceLocking.ReadLines(`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}`);
|
||||
|
||||
return lines.map((x) => x.replace(`/`, ``)).includes(buildParametersContext.cacheKey);
|
||||
} else {
|
||||
@@ -131,7 +44,7 @@ export class SharedWorkspaceLocking {
|
||||
}
|
||||
|
||||
public static NewWorkspaceName() {
|
||||
return `${Orchestrator.retainedWorkspacePrefix}-${Orchestrator.buildParameters.buildGuid}`;
|
||||
return `${CloudRunner.retainedWorkspacePrefix}-${CloudRunner.buildParameters.buildGuid}`;
|
||||
}
|
||||
public static async GetAllLocksForWorkspace(
|
||||
workspace: string,
|
||||
@@ -142,8 +55,8 @@ export class SharedWorkspaceLocking {
|
||||
}
|
||||
|
||||
return (
|
||||
await SharedWorkspaceLocking.listObjects(
|
||||
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
|
||||
await SharedWorkspaceLocking.ReadLines(
|
||||
`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/`,
|
||||
)
|
||||
)
|
||||
.map((x) => x.replace(`/`, ``))
|
||||
@@ -156,10 +69,10 @@ export class SharedWorkspaceLocking {
|
||||
|
||||
if (await SharedWorkspaceLocking.DoesCacheKeyTopLevelExist(buildParametersContext)) {
|
||||
const workspaces = await SharedWorkspaceLocking.GetFreeWorkspaces(buildParametersContext);
|
||||
OrchestratorLogger.log(`run agent ${runId} is trying to access a workspace, free: ${JSON.stringify(workspaces)}`);
|
||||
CloudRunnerLogger.log(`run agent ${runId} is trying to access a workspace, free: ${JSON.stringify(workspaces)}`);
|
||||
for (const element of workspaces) {
|
||||
const lockResult = await SharedWorkspaceLocking.LockWorkspace(element, runId, buildParametersContext);
|
||||
OrchestratorLogger.log(
|
||||
CloudRunnerLogger.log(
|
||||
`run agent: ${runId} try lock workspace: ${element} locking attempt result: ${lockResult}`,
|
||||
);
|
||||
|
||||
@@ -171,12 +84,12 @@ export class SharedWorkspaceLocking {
|
||||
|
||||
if (await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext)) {
|
||||
workspace = SharedWorkspaceLocking.NewWorkspaceName();
|
||||
Orchestrator.lockedWorkspace = workspace;
|
||||
CloudRunner.lockedWorkspace = workspace;
|
||||
}
|
||||
|
||||
const createResult = await SharedWorkspaceLocking.CreateWorkspace(workspace, buildParametersContext);
|
||||
const lockResult = await SharedWorkspaceLocking.LockWorkspace(workspace, runId, buildParametersContext);
|
||||
OrchestratorLogger.log(
|
||||
CloudRunnerLogger.log(
|
||||
`run agent ${runId} didn't find a free workspace so created: ${workspace} createWorkspaceSuccess: ${createResult} Lock:${lockResult}`,
|
||||
);
|
||||
|
||||
@@ -204,7 +117,7 @@ export class SharedWorkspaceLocking {
|
||||
.sort((x) => x.timestamp);
|
||||
const lockMatches = locks.filter((x) => x.name.includes(runId));
|
||||
const includesRunLock = lockMatches.length > 0 && locks.indexOf(lockMatches[0]) === 0;
|
||||
OrchestratorLogger.log(
|
||||
CloudRunnerLogger.log(
|
||||
`Checking has workspace lock, runId: ${runId}, workspace: ${workspace}, success: ${includesRunLock} \n- Num of locks created by Run Agent: ${
|
||||
lockMatches.length
|
||||
} Num of Locks: ${locks.length}, Time ordered index for Run Agent: ${locks.indexOf(lockMatches[0])} \n \n`,
|
||||
@@ -219,7 +132,7 @@ export class SharedWorkspaceLocking {
|
||||
for (const element of workspaces) {
|
||||
const isLocked = await SharedWorkspaceLocking.IsWorkspaceLocked(element, buildParametersContext);
|
||||
const isBelowMax = await SharedWorkspaceLocking.IsWorkspaceBelowMax(element, buildParametersContext);
|
||||
OrchestratorLogger.log(`workspace ${element} locked:${isLocked} below max:${isBelowMax}`);
|
||||
CloudRunnerLogger.log(`workspace ${element} locked:${isLocked} below max:${isBelowMax}`);
|
||||
if (!isLocked && isBelowMax) {
|
||||
result.push(element);
|
||||
}
|
||||
@@ -269,8 +182,8 @@ export class SharedWorkspaceLocking {
|
||||
}
|
||||
|
||||
return (
|
||||
await SharedWorkspaceLocking.listObjects(
|
||||
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
|
||||
await SharedWorkspaceLocking.ReadLines(
|
||||
`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/`,
|
||||
)
|
||||
)
|
||||
.map((x) => x.replace(`/`, ``))
|
||||
@@ -282,8 +195,8 @@ export class SharedWorkspaceLocking {
|
||||
if (!(await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext))) {
|
||||
throw new Error(`workspace doesn't exist ${workspace}`);
|
||||
}
|
||||
const files = await SharedWorkspaceLocking.listObjects(
|
||||
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
|
||||
const files = await SharedWorkspaceLocking.ReadLines(
|
||||
`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/`,
|
||||
);
|
||||
|
||||
const lockFilesExist =
|
||||
@@ -299,19 +212,20 @@ export class SharedWorkspaceLocking {
|
||||
throw new Error(`${workspace} already exists`);
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const key = `${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/${timestamp}_${workspace}_workspace`;
|
||||
await SharedWorkspaceLocking.ensureBucketExists();
|
||||
await (SharedWorkspaceLocking.useRclone
|
||||
? SharedWorkspaceLocking.rclone(`touch ${SharedWorkspaceLocking.bucket}/${key}`)
|
||||
: SharedWorkspaceLocking.s3.send(
|
||||
new PutObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: key, Body: new Uint8Array(0) }),
|
||||
));
|
||||
const file = `${timestamp}_${workspace}_workspace`;
|
||||
fs.writeFileSync(file, '');
|
||||
await CloudRunnerSystem.Run(
|
||||
`aws s3 cp ./${file} ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/${file}`,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
fs.rmSync(file);
|
||||
|
||||
const workspaces = await SharedWorkspaceLocking.GetAllWorkspaces(buildParametersContext);
|
||||
|
||||
OrchestratorLogger.log(`All workspaces ${workspaces}`);
|
||||
CloudRunnerLogger.log(`All workspaces ${workspaces}`);
|
||||
if (!(await SharedWorkspaceLocking.IsWorkspaceBelowMax(workspace, buildParametersContext))) {
|
||||
OrchestratorLogger.log(`Workspace is above max ${workspaces} ${buildParametersContext.maxRetainedWorkspaces}`);
|
||||
CloudRunnerLogger.log(`Workspace is above max ${workspaces} ${buildParametersContext.maxRetainedWorkspaces}`);
|
||||
await SharedWorkspaceLocking.CleanupWorkspace(workspace, buildParametersContext);
|
||||
|
||||
return false;
|
||||
@@ -327,24 +241,25 @@ export class SharedWorkspaceLocking {
|
||||
): Promise<boolean> {
|
||||
const existingWorkspace = workspace.endsWith(`_workspace`);
|
||||
const ending = existingWorkspace ? workspace : `${workspace}_workspace`;
|
||||
const key = `${SharedWorkspaceLocking.workspacePrefix}${
|
||||
buildParametersContext.cacheKey
|
||||
}/${Date.now()}_${runId}_${ending}_lock`;
|
||||
await SharedWorkspaceLocking.ensureBucketExists();
|
||||
await (SharedWorkspaceLocking.useRclone
|
||||
? SharedWorkspaceLocking.rclone(`touch ${SharedWorkspaceLocking.bucket}/${key}`)
|
||||
: SharedWorkspaceLocking.s3.send(
|
||||
new PutObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: key, Body: new Uint8Array(0) }),
|
||||
));
|
||||
const file = `${Date.now()}_${runId}_${ending}_lock`;
|
||||
fs.writeFileSync(file, '');
|
||||
await CloudRunnerSystem.Run(
|
||||
`aws s3 cp ./${file} ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/${file}`,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
fs.rmSync(file);
|
||||
|
||||
const hasLock = await SharedWorkspaceLocking.HasWorkspaceLock(workspace, runId, buildParametersContext);
|
||||
|
||||
if (hasLock) {
|
||||
Orchestrator.lockedWorkspace = workspace;
|
||||
CloudRunner.lockedWorkspace = workspace;
|
||||
} else {
|
||||
await (SharedWorkspaceLocking.useRclone
|
||||
? SharedWorkspaceLocking.rclone(`delete ${SharedWorkspaceLocking.bucket}/${key}`)
|
||||
: SharedWorkspaceLocking.s3.send(new DeleteObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: key })));
|
||||
await CloudRunnerSystem.Run(
|
||||
`aws s3 rm ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/${file}`,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
return hasLock;
|
||||
@@ -355,47 +270,30 @@ export class SharedWorkspaceLocking {
|
||||
runId: string,
|
||||
buildParametersContext: BuildParameters,
|
||||
): Promise<boolean> {
|
||||
await SharedWorkspaceLocking.ensureBucketExists();
|
||||
const files = await SharedWorkspaceLocking.GetAllLocksForWorkspace(workspace, buildParametersContext);
|
||||
const file = files.find((x) => x.includes(workspace) && x.endsWith(`_lock`) && x.includes(runId));
|
||||
OrchestratorLogger.log(`All Locks ${files} ${workspace} ${runId}`);
|
||||
OrchestratorLogger.log(`Deleting lock ${workspace}/${file}`);
|
||||
OrchestratorLogger.log(`rm ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/${file}`);
|
||||
if (file) {
|
||||
await (SharedWorkspaceLocking.useRclone
|
||||
? SharedWorkspaceLocking.rclone(
|
||||
`delete ${SharedWorkspaceLocking.bucket}/${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/${file}`,
|
||||
)
|
||||
: SharedWorkspaceLocking.s3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: SharedWorkspaceLocking.bucket,
|
||||
Key: `${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/${file}`,
|
||||
}),
|
||||
));
|
||||
}
|
||||
CloudRunnerLogger.log(`All Locks ${files} ${workspace} ${runId}`);
|
||||
CloudRunnerLogger.log(`Deleting lock ${workspace}/${file}`);
|
||||
CloudRunnerLogger.log(`rm ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/${file}`);
|
||||
await CloudRunnerSystem.Run(
|
||||
`aws s3 rm ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/${file}`,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
return !(await SharedWorkspaceLocking.HasWorkspaceLock(workspace, runId, buildParametersContext));
|
||||
}
|
||||
|
||||
public static async CleanupWorkspace(workspace: string, buildParametersContext: BuildParameters) {
|
||||
const prefix = `${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`;
|
||||
const files = await SharedWorkspaceLocking.listObjects(prefix);
|
||||
for (const file of files.filter((x) => x.includes(`_${workspace}_`))) {
|
||||
await (SharedWorkspaceLocking.useRclone
|
||||
? SharedWorkspaceLocking.rclone(`delete ${SharedWorkspaceLocking.bucket}/${prefix}${file}`)
|
||||
: SharedWorkspaceLocking.s3.send(
|
||||
new DeleteObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: `${prefix}${file}` }),
|
||||
));
|
||||
}
|
||||
await CloudRunnerSystem.Run(
|
||||
`aws s3 rm ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey} --exclude "*" --include "*_${workspace}_*"`,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
public static async ReadLines(command: string): Promise<string[]> {
|
||||
const path = command.replace('aws s3 ls', '').replace('rclone lsf', '').trim();
|
||||
const withoutScheme = path.replace('s3://', '');
|
||||
const [bucket, ...rest] = withoutScheme.split('/');
|
||||
const prefix = rest.join('/');
|
||||
|
||||
return SharedWorkspaceLocking.listObjects(prefix, bucket);
|
||||
return CloudRunnerSystem.RunAndReadLines(command);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import BuildParameters from '../../../build-parameters';
|
||||
import Input from '../../../input';
|
||||
import OrchestratorOptions from '../../options/orchestrator-options';
|
||||
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
||||
import OrchestratorOptionsReader from '../../options/orchestrator-options-reader';
|
||||
import OrchestratorQueryOverride from '../../options/orchestrator-query-override';
|
||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||
import CloudRunnerOptions from '../../options/cloud-runner-options';
|
||||
import CloudRunnerEnvironmentVariable from '../../options/cloud-runner-environment-variable';
|
||||
import CloudRunnerOptionsReader from '../../options/cloud-runner-options-reader';
|
||||
import CloudRunnerQueryOverride from '../../options/cloud-runner-query-override';
|
||||
import CloudRunnerSecret from '../../options/cloud-runner-secret';
|
||||
import { CommandHookService } from '../hooks/command-hook-service';
|
||||
|
||||
export class TaskParameterSerializer {
|
||||
@@ -19,10 +19,10 @@ export class TaskParameterSerializer {
|
||||
'NAME',
|
||||
'CUSTOM_JOB',
|
||||
]);
|
||||
public static createOrchestratorEnvironmentVariables(
|
||||
public static createCloudRunnerEnvironmentVariables(
|
||||
buildParameters: BuildParameters,
|
||||
): OrchestratorEnvironmentVariable[] {
|
||||
const result: OrchestratorEnvironmentVariable[] = this.uniqBy(
|
||||
): CloudRunnerEnvironmentVariable[] {
|
||||
const result: CloudRunnerEnvironmentVariable[] = this.uniqBy(
|
||||
[
|
||||
...[
|
||||
{ name: 'BUILD_TARGET', value: buildParameters.targetPlatform },
|
||||
@@ -31,11 +31,8 @@ export class TaskParameterSerializer {
|
||||
],
|
||||
...TaskParameterSerializer.serializeFromObject(buildParameters),
|
||||
...TaskParameterSerializer.serializeInput(),
|
||||
...TaskParameterSerializer.serializeOrchestratorOptions(),
|
||||
...TaskParameterSerializer.serializeCloudRunnerOptions(),
|
||||
...CommandHookService.getSecrets(CommandHookService.getHooks(buildParameters.commandHooks)),
|
||||
|
||||
// Include AWS environment variables for LocalStack compatibility
|
||||
...TaskParameterSerializer.serializeAwsEnvironmentVariables(),
|
||||
]
|
||||
.filter(
|
||||
(x) =>
|
||||
@@ -50,14 +47,14 @@ export class TaskParameterSerializer {
|
||||
|
||||
return x;
|
||||
}),
|
||||
(item: OrchestratorEnvironmentVariable) => item.name,
|
||||
(item: CloudRunnerEnvironmentVariable) => item.name,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
static uniqBy(a: OrchestratorEnvironmentVariable[], key: (parameters: OrchestratorEnvironmentVariable) => string) {
|
||||
static uniqBy(a: CloudRunnerEnvironmentVariable[], key: (parameters: CloudRunnerEnvironmentVariable) => string) {
|
||||
const seen: { [key: string]: boolean } = {};
|
||||
|
||||
return a.filter(function (item) {
|
||||
@@ -90,34 +87,12 @@ export class TaskParameterSerializer {
|
||||
return TaskParameterSerializer.serializeFromType(Input);
|
||||
}
|
||||
|
||||
private static serializeOrchestratorOptions() {
|
||||
return TaskParameterSerializer.serializeFromType(OrchestratorOptions);
|
||||
}
|
||||
|
||||
private static serializeAwsEnvironmentVariables() {
|
||||
const awsEnvironmentVariables = [
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_DEFAULT_REGION',
|
||||
'AWS_REGION',
|
||||
'AWS_S3_ENDPOINT',
|
||||
'AWS_ENDPOINT',
|
||||
'AWS_CLOUD_FORMATION_ENDPOINT',
|
||||
'AWS_ECS_ENDPOINT',
|
||||
'AWS_KINESIS_ENDPOINT',
|
||||
'AWS_CLOUD_WATCH_LOGS_ENDPOINT',
|
||||
];
|
||||
|
||||
return awsEnvironmentVariables
|
||||
.filter((key) => process.env[key] !== undefined)
|
||||
.map((key) => ({
|
||||
name: key,
|
||||
value: process.env[key] || '',
|
||||
}));
|
||||
private static serializeCloudRunnerOptions() {
|
||||
return TaskParameterSerializer.serializeFromType(CloudRunnerOptions);
|
||||
}
|
||||
|
||||
public static ToEnvVarFormat(input: string): string {
|
||||
return OrchestratorOptions.ToEnvVarFormat(input);
|
||||
return CloudRunnerOptions.ToEnvVarFormat(input);
|
||||
}
|
||||
|
||||
public static UndoEnvVarFormat(element: string): string {
|
||||
@@ -153,7 +128,7 @@ export class TaskParameterSerializer {
|
||||
|
||||
private static serializeFromType(type: any) {
|
||||
const array: any[] = [];
|
||||
const input = OrchestratorOptionsReader.GetProperties();
|
||||
const input = CloudRunnerOptionsReader.GetProperties();
|
||||
for (const element of input) {
|
||||
if (typeof type[element] !== 'function' && array.filter((x) => x.name === element).length === 0) {
|
||||
array.push({
|
||||
@@ -166,7 +141,7 @@ export class TaskParameterSerializer {
|
||||
return array;
|
||||
}
|
||||
|
||||
public static readDefaultSecrets(): OrchestratorSecret[] {
|
||||
public static readDefaultSecrets(): CloudRunnerSecret[] {
|
||||
let array = new Array();
|
||||
array = TaskParameterSerializer.tryAddInput(array, 'UNITY_SERIAL');
|
||||
array = TaskParameterSerializer.tryAddInput(array, 'UNITY_EMAIL');
|
||||
@@ -179,13 +154,13 @@ export class TaskParameterSerializer {
|
||||
}
|
||||
|
||||
private static getValue(key: string) {
|
||||
return OrchestratorQueryOverride.queryOverrides !== undefined &&
|
||||
OrchestratorQueryOverride.queryOverrides[key] !== undefined
|
||||
? OrchestratorQueryOverride.queryOverrides[key]
|
||||
return CloudRunnerQueryOverride.queryOverrides !== undefined &&
|
||||
CloudRunnerQueryOverride.queryOverrides[key] !== undefined
|
||||
? CloudRunnerQueryOverride.queryOverrides[key]
|
||||
: process.env[key];
|
||||
}
|
||||
|
||||
private static tryAddInput(array: OrchestratorSecret[], key: string): OrchestratorSecret[] {
|
||||
private static tryAddInput(array: CloudRunnerSecret[], key: string): CloudRunnerSecret[] {
|
||||
const value = TaskParameterSerializer.getValue(key);
|
||||
if (value !== undefined && value !== '' && value !== 'null') {
|
||||
array.push({
|
||||
@@ -2,26 +2,26 @@ import { BuildParameters, Input } from '../../..';
|
||||
import YAML from 'yaml';
|
||||
import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
|
||||
import path from 'node:path';
|
||||
import OrchestratorOptions from '../../options/orchestrator-options';
|
||||
import CloudRunnerOptions from '../../options/cloud-runner-options';
|
||||
import * as fs from 'node:fs';
|
||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||
import CloudRunnerLogger from '../core/cloud-runner-logger';
|
||||
import { CommandHook } from './command-hook';
|
||||
|
||||
// import OrchestratorLogger from './orchestrator-logger';
|
||||
// import CloudRunnerLogger from './cloud-runner-logger';
|
||||
|
||||
export class CommandHookService {
|
||||
public static ApplyHooksToCommands(commands: string, buildParameters: BuildParameters): string {
|
||||
const hooks = CommandHookService.getHooks(buildParameters.commandHooks);
|
||||
OrchestratorLogger.log(`Applying hooks ${hooks.length}`);
|
||||
CloudRunnerLogger.log(`Applying hooks ${hooks.length}`);
|
||||
|
||||
return `echo "---"
|
||||
echo "start orchestrator init"
|
||||
${OrchestratorOptions.orchestratorDebug ? `printenv` : `#`}
|
||||
echo "start of orchestrator job"
|
||||
echo "start cloud runner init"
|
||||
${CloudRunnerOptions.cloudRunnerDebug ? `printenv` : `#`}
|
||||
echo "start of cloud runner job"
|
||||
${hooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
|
||||
${commands}
|
||||
${hooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
|
||||
echo "end of orchestrator job"
|
||||
echo "end of cloud runner job"
|
||||
echo "---${buildParameters.logId}"`;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ echo "---${buildParameters.logId}"`;
|
||||
const gameCiCustomHooksPath = path.join(process.cwd(), `game-ci`, `command-hooks`);
|
||||
const files = fs.readdirSync(gameCiCustomHooksPath);
|
||||
for (const file of files) {
|
||||
if (!OrchestratorOptions.commandHookFiles.includes(file.replace(`.yaml`, ``))) {
|
||||
if (!CloudRunnerOptions.commandHookFiles.includes(file.replace(`.yaml`, ``))) {
|
||||
continue;
|
||||
}
|
||||
const fileContents = fs.readFileSync(path.join(gameCiCustomHooksPath, file), `utf8`);
|
||||
@@ -89,9 +89,9 @@ echo "---${buildParameters.logId}"`;
|
||||
return [];
|
||||
}
|
||||
|
||||
// if (Orchestrator.buildParameters?.orchestratorIntegrationTests) {
|
||||
// if (CloudRunner.buildParameters?.cloudRunnerIntegrationTests) {
|
||||
|
||||
// OrchestratorLogger.log(`Parsing build hooks: ${steps}`);
|
||||
// CloudRunnerLogger.log(`Parsing build hooks: ${steps}`);
|
||||
|
||||
// }
|
||||
const isArray = hooks.replace(/\s/g, ``)[0] === `-`;
|
||||
@@ -1,8 +1,8 @@
|
||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||
import CloudRunnerSecret from '../../options/cloud-runner-secret';
|
||||
|
||||
export class CommandHook {
|
||||
public commands: string[] = new Array<string>();
|
||||
public secrets: OrchestratorSecret[] = new Array<OrchestratorSecret>();
|
||||
public secrets: CloudRunnerSecret[] = new Array<CloudRunnerSecret>();
|
||||
public name!: string;
|
||||
public hook!: string[];
|
||||
public step!: string[];
|
||||
265
src/model/cloud-runner/services/hooks/container-hook-service.ts
Normal file
265
src/model/cloud-runner/services/hooks/container-hook-service.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import YAML from 'yaml';
|
||||
import CloudRunner from '../../cloud-runner';
|
||||
import { CustomWorkflow } from '../../workflows/custom-workflow';
|
||||
import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import Input from '../../../input';
|
||||
import CloudRunnerOptions from '../../options/cloud-runner-options';
|
||||
import { ContainerHook as ContainerHook } from './container-hook';
|
||||
import { CloudRunnerStepParameters } from '../../options/cloud-runner-step-parameters';
|
||||
|
||||
export class ContainerHookService {
|
||||
static GetContainerHooksFromFiles(hookLifecycle: string): ContainerHook[] {
|
||||
const results: ContainerHook[] = [];
|
||||
try {
|
||||
const gameCiCustomStepsPath = path.join(process.cwd(), `game-ci`, `container-hooks`);
|
||||
const files = fs.readdirSync(gameCiCustomStepsPath);
|
||||
for (const file of files) {
|
||||
if (!CloudRunnerOptions.containerHookFiles.includes(file.replace(`.yaml`, ``))) {
|
||||
// RemoteClientLogger.log(`Skipping CustomStepFile: ${file}`);
|
||||
continue;
|
||||
}
|
||||
const fileContents = fs.readFileSync(path.join(gameCiCustomStepsPath, file), `utf8`);
|
||||
const fileContentsObject = ContainerHookService.ParseContainerHooks(fileContents)[0];
|
||||
if (fileContentsObject.hook === hookLifecycle) {
|
||||
results.push(fileContentsObject);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
RemoteClientLogger.log(`Failed Getting: ${hookLifecycle} \n ${JSON.stringify(error, undefined, 4)}`);
|
||||
}
|
||||
|
||||
// RemoteClientLogger.log(`Active Steps From Files: \n ${JSON.stringify(results, undefined, 4)}`);
|
||||
|
||||
const builtInContainerHooks: ContainerHook[] = ContainerHookService.ParseContainerHooks(
|
||||
`- name: aws-s3-upload-build
|
||||
image: amazon/aws-cli
|
||||
hook: after
|
||||
commands: |
|
||||
aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID --profile default
|
||||
aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY --profile default
|
||||
aws configure set region $AWS_DEFAULT_REGION --profile default
|
||||
aws s3 cp /data/cache/$CACHE_KEY/build/build-${CloudRunner.buildParameters.buildGuid}.tar${
|
||||
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
||||
} s3://${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/build/build-$BUILD_GUID.tar${
|
||||
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
||||
}
|
||||
rm /data/cache/$CACHE_KEY/build/build-${CloudRunner.buildParameters.buildGuid}.tar${
|
||||
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
||||
}
|
||||
secrets:
|
||||
- name: awsAccessKeyId
|
||||
value: ${process.env.AWS_ACCESS_KEY_ID || ``}
|
||||
- name: awsSecretAccessKey
|
||||
value: ${process.env.AWS_SECRET_ACCESS_KEY || ``}
|
||||
- name: awsDefaultRegion
|
||||
value: ${process.env.AWS_REGION || ``}
|
||||
- name: aws-s3-pull-build
|
||||
image: amazon/aws-cli
|
||||
commands: |
|
||||
aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID --profile default
|
||||
aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY --profile default
|
||||
aws configure set region $AWS_DEFAULT_REGION --profile default
|
||||
aws s3 ls ${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/ || true
|
||||
aws s3 ls ${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/build || true
|
||||
mkdir -p /data/cache/$CACHE_KEY/build/
|
||||
aws s3 cp s3://${
|
||||
CloudRunner.buildParameters.awsStackName
|
||||
}/cloud-runner-cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
|
||||
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
||||
} /data/cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
|
||||
CloudRunner.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
||||
}
|
||||
secrets:
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
- name: AWS_DEFAULT_REGION
|
||||
- name: BUILD_GUID_TARGET
|
||||
- name: steam-deploy-client
|
||||
image: steamcmd/steamcmd
|
||||
commands: |
|
||||
apt-get update
|
||||
apt-get install -y curl tar coreutils git tree > /dev/null
|
||||
curl -s https://gist.githubusercontent.com/frostebite/1d56f5505b36b403b64193b7a6e54cdc/raw/fa6639ed4ef750c4268ea319d63aa80f52712ffb/deploy-client-steam.sh | bash
|
||||
secrets:
|
||||
- name: STEAM_USERNAME
|
||||
- name: STEAM_PASSWORD
|
||||
- name: STEAM_APPID
|
||||
- name: STEAM_SSFN_FILE_NAME
|
||||
- name: STEAM_SSFN_FILE_CONTENTS
|
||||
- name: STEAM_CONFIG_VDF_1
|
||||
- name: STEAM_CONFIG_VDF_2
|
||||
- name: STEAM_CONFIG_VDF_3
|
||||
- name: STEAM_CONFIG_VDF_4
|
||||
- name: BUILD_GUID_TARGET
|
||||
- name: RELEASE_BRANCH
|
||||
- name: steam-deploy-project
|
||||
image: steamcmd/steamcmd
|
||||
commands: |
|
||||
apt-get update
|
||||
apt-get install -y curl tar coreutils git tree > /dev/null
|
||||
curl -s https://gist.githubusercontent.com/frostebite/969da6a41002a0e901174124b643709f/raw/02403e53fb292026cba81ddcf4ff35fc1eba111d/steam-deploy-project.sh | bash
|
||||
secrets:
|
||||
- name: STEAM_USERNAME
|
||||
- name: STEAM_PASSWORD
|
||||
- name: STEAM_APPID
|
||||
- name: STEAM_SSFN_FILE_NAME
|
||||
- name: STEAM_SSFN_FILE_CONTENTS
|
||||
- name: STEAM_CONFIG_VDF_1
|
||||
- name: STEAM_CONFIG_VDF_2
|
||||
- name: STEAM_CONFIG_VDF_3
|
||||
- name: STEAM_CONFIG_VDF_4
|
||||
- name: BUILD_GUID_2
|
||||
- name: RELEASE_BRANCH
|
||||
- name: aws-s3-upload-cache
|
||||
image: amazon/aws-cli
|
||||
hook: after
|
||||
commands: |
|
||||
aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID --profile default
|
||||
aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY --profile default
|
||||
aws configure set region $AWS_DEFAULT_REGION --profile default
|
||||
aws s3 cp --recursive /data/cache/$CACHE_KEY/lfs s3://${
|
||||
CloudRunner.buildParameters.awsStackName
|
||||
}/cloud-runner-cache/$CACHE_KEY/lfs
|
||||
rm -r /data/cache/$CACHE_KEY/lfs
|
||||
aws s3 cp --recursive /data/cache/$CACHE_KEY/Library s3://${
|
||||
CloudRunner.buildParameters.awsStackName
|
||||
}/cloud-runner-cache/$CACHE_KEY/Library
|
||||
rm -r /data/cache/$CACHE_KEY/Library
|
||||
secrets:
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
value: ${process.env.AWS_ACCESS_KEY_ID || ``}
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
value: ${process.env.AWS_SECRET_ACCESS_KEY || ``}
|
||||
- name: AWS_DEFAULT_REGION
|
||||
value: ${process.env.AWS_REGION || ``}
|
||||
- name: aws-s3-pull-cache
|
||||
image: amazon/aws-cli
|
||||
hook: before
|
||||
commands: |
|
||||
aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID --profile default
|
||||
aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY --profile default
|
||||
aws configure set region $AWS_DEFAULT_REGION --profile default
|
||||
mkdir -p /data/cache/$CACHE_KEY/Library/
|
||||
mkdir -p /data/cache/$CACHE_KEY/lfs/
|
||||
aws s3 ls ${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/ || true
|
||||
aws s3 ls ${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/ || true
|
||||
BUCKET1="${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/Library/"
|
||||
aws s3 ls $BUCKET1 || true
|
||||
OBJECT1="$(aws s3 ls $BUCKET1 | sort | tail -n 1 | awk '{print $4}' || '')"
|
||||
aws s3 cp s3://$BUCKET1$OBJECT1 /data/cache/$CACHE_KEY/Library/ || true
|
||||
BUCKET2="${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/$CACHE_KEY/lfs/"
|
||||
aws s3 ls $BUCKET2 || true
|
||||
OBJECT2="$(aws s3 ls $BUCKET2 | sort | tail -n 1 | awk '{print $4}' || '')"
|
||||
aws s3 cp s3://$BUCKET2$OBJECT2 /data/cache/$CACHE_KEY/lfs/ || true
|
||||
secrets:
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
value: ${process.env.AWS_ACCESS_KEY_ID || ``}
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
value: ${process.env.AWS_SECRET_ACCESS_KEY || ``}
|
||||
- name: AWS_DEFAULT_REGION
|
||||
value: ${process.env.AWS_REGION || ``}
|
||||
- name: debug-cache
|
||||
image: ubuntu
|
||||
hook: after
|
||||
commands: |
|
||||
apt-get update > /dev/null
|
||||
${CloudRunnerOptions.cloudRunnerDebug ? `apt-get install -y tree > /dev/null` : `#`}
|
||||
${CloudRunnerOptions.cloudRunnerDebug ? `tree -L 3 /data/cache` : `#`}
|
||||
secrets:
|
||||
- name: awsAccessKeyId
|
||||
value: ${process.env.AWS_ACCESS_KEY_ID || ``}
|
||||
- name: awsSecretAccessKey
|
||||
value: ${process.env.AWS_SECRET_ACCESS_KEY || ``}
|
||||
- name: awsDefaultRegion
|
||||
value: ${process.env.AWS_REGION || ``}`,
|
||||
).filter((x) => CloudRunnerOptions.containerHookFiles.includes(x.name) && x.hook === hookLifecycle);
|
||||
if (builtInContainerHooks.length > 0) {
|
||||
results.push(...builtInContainerHooks);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static ConvertYamlSecrets(object: ContainerHook) {
|
||||
if (object.secrets === undefined) {
|
||||
object.secrets = [];
|
||||
|
||||
return;
|
||||
}
|
||||
object.secrets = object.secrets.map((x: { [key: string]: any }) => {
|
||||
return {
|
||||
ParameterKey: x.name,
|
||||
EnvironmentVariable: Input.ToEnvVarFormat(x.name),
|
||||
ParameterValue: x.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static ParseContainerHooks(steps: string): ContainerHook[] {
|
||||
if (steps === '') {
|
||||
return [];
|
||||
}
|
||||
const isArray = steps.replace(/\s/g, ``)[0] === `-`;
|
||||
const object: ContainerHook[] = isArray ? YAML.parse(steps) : [YAML.parse(steps)];
|
||||
for (const step of object) {
|
||||
ContainerHookService.ConvertYamlSecrets(step);
|
||||
if (step.secrets === undefined) {
|
||||
step.secrets = [];
|
||||
} else {
|
||||
for (const secret of step.secrets) {
|
||||
if (secret.ParameterValue === undefined && process.env[secret.EnvironmentVariable] !== undefined) {
|
||||
if (CloudRunner.buildParameters?.cloudRunnerDebug) {
|
||||
// CloudRunnerLogger.log(`Injecting custom step ${step.name} from env var ${secret.ParameterKey}`);
|
||||
}
|
||||
secret.ParameterValue = process.env[secret.ParameterKey] || ``;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (step.image === undefined) {
|
||||
step.image = `ubuntu`;
|
||||
}
|
||||
}
|
||||
if (object === undefined) {
|
||||
throw new Error(`Failed to parse ${steps}`);
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
static async RunPostBuildSteps(cloudRunnerStepState: CloudRunnerStepParameters) {
|
||||
let output = ``;
|
||||
const steps: ContainerHook[] = [
|
||||
...ContainerHookService.ParseContainerHooks(CloudRunner.buildParameters.postBuildContainerHooks),
|
||||
...ContainerHookService.GetContainerHooksFromFiles(`after`),
|
||||
];
|
||||
|
||||
if (steps.length > 0) {
|
||||
output += await CustomWorkflow.runContainerJob(
|
||||
steps,
|
||||
cloudRunnerStepState.environment,
|
||||
cloudRunnerStepState.secrets,
|
||||
);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
static async RunPreBuildSteps(cloudRunnerStepState: CloudRunnerStepParameters) {
|
||||
let output = ``;
|
||||
const steps: ContainerHook[] = [
|
||||
...ContainerHookService.ParseContainerHooks(CloudRunner.buildParameters.preBuildContainerHooks),
|
||||
...ContainerHookService.GetContainerHooksFromFiles(`before`),
|
||||
];
|
||||
|
||||
if (steps.length > 0) {
|
||||
output += await CustomWorkflow.runContainerJob(
|
||||
steps,
|
||||
cloudRunnerStepState.environment,
|
||||
cloudRunnerStepState.secrets,
|
||||
);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user