mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-01 06:16:14 -07:00
Compare commits
12 Commits
feature/ho
...
feature/us
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4233b08bae | ||
|
|
2badde1790 | ||
|
|
eba50f7627 | ||
|
|
f8b20890d9 | ||
|
|
b57598a959 | ||
|
|
7b2ec07fc1 | ||
|
|
3d3a018c23 | ||
|
|
a12e3e829e | ||
|
|
2321712bb4 | ||
|
|
ef0555fb0d | ||
|
|
4a7fc08e63 | ||
|
|
ce7ce7a416 |
@@ -2,3 +2,4 @@ dist/
|
|||||||
lib/
|
lib/
|
||||||
node_modules/
|
node_modules/
|
||||||
jest.config.js
|
jest.config.js
|
||||||
|
src/types/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"root": true,
|
||||||
"plugins": ["jest", "@typescript-eslint", "prettier", "unicorn"],
|
"plugins": ["jest", "@typescript-eslint", "prettier", "unicorn"],
|
||||||
"extends": ["plugin:unicorn/recommended", "plugin:github/recommended", "plugin:prettier/recommended"],
|
"extends": ["plugin:unicorn/recommended", "plugin:github/recommended", "plugin:prettier/recommended"],
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
|
|||||||
7
.github/workflows/build-tests-mac.yml
vendored
7
.github/workflows/build-tests-mac.yml
vendored
@@ -12,16 +12,15 @@ jobs:
|
|||||||
buildForAllPlatformsMacOS:
|
buildForAllPlatformsMacOS:
|
||||||
name: ${{ matrix.targetPlatform }} on ${{ matrix.unityVersion }}
|
name: ${{ matrix.targetPlatform }} on ${{ matrix.unityVersion }}
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
continue-on-error: true
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
projectPath:
|
projectPath:
|
||||||
- test-project
|
- test-project
|
||||||
unityVersion:
|
unityVersion:
|
||||||
- 2021.3.45f1
|
- 2021.3.45f2
|
||||||
- 2022.3.13f1
|
- 2022.3.62f3
|
||||||
- 2023.2.2f1
|
- 2023.2.22f1
|
||||||
targetPlatform:
|
targetPlatform:
|
||||||
- StandaloneOSX # Build a MacOS executable
|
- StandaloneOSX # Build a MacOS executable
|
||||||
- iOS # Build an iOS executable
|
- iOS # Build an iOS executable
|
||||||
|
|||||||
6
.github/workflows/build-tests-ubuntu.yml
vendored
6
.github/workflows/build-tests-ubuntu.yml
vendored
@@ -48,9 +48,9 @@ jobs:
|
|||||||
projectPath:
|
projectPath:
|
||||||
- test-project
|
- test-project
|
||||||
unityVersion:
|
unityVersion:
|
||||||
- 2021.3.32f1
|
- 2021.3.45f2
|
||||||
- 2022.3.13f1
|
- 2022.3.62f3
|
||||||
- 2023.2.2f1
|
- 2023.2.22f1
|
||||||
targetPlatform:
|
targetPlatform:
|
||||||
- StandaloneOSX # Build a macOS standalone (Intel 64-bit) with mono backend.
|
- StandaloneOSX # Build a macOS standalone (Intel 64-bit) with mono backend.
|
||||||
- StandaloneWindows64 # Build a Windows 64-bit standalone with mono backend.
|
- StandaloneWindows64 # Build a Windows 64-bit standalone with mono backend.
|
||||||
|
|||||||
40
.github/workflows/build-tests-windows.yml
vendored
40
.github/workflows/build-tests-windows.yml
vendored
@@ -18,9 +18,9 @@ jobs:
|
|||||||
projectPath:
|
projectPath:
|
||||||
- test-project
|
- test-project
|
||||||
unityVersion:
|
unityVersion:
|
||||||
- 2021.3.32f1
|
- 2021.3.45f2
|
||||||
- 2022.3.13f1
|
- 2022.3.62f3
|
||||||
- 2023.2.2f1
|
- 2023.2.22f1
|
||||||
targetPlatform:
|
targetPlatform:
|
||||||
- Android # Build an Android apk.
|
- Android # Build an Android apk.
|
||||||
- StandaloneWindows64 # Build a Windows 64-bit standalone.
|
- StandaloneWindows64 # Build a Windows 64-bit standalone.
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
- unityVersion: 6000.0.36f1
|
- unityVersion: 6000.0.36f1
|
||||||
targetPlatform: StandaloneWindows64
|
targetPlatform: StandaloneWindows64
|
||||||
buildProfile: 'Assets/Settings/Build Profiles/Sample Windows Build Profile.asset'
|
buildProfile: 'Assets/Settings/Build Profiles/Sample Windows Build Profile.asset'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
###########################
|
###########################
|
||||||
# Checkout #
|
# Checkout #
|
||||||
@@ -66,6 +66,34 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
Move-Item -Path "./test-project/ProjectSettings/ProjectSettingsIl2cpp.asset" -Destination "./test-project/ProjectSettings/ProjectSettings.asset" -Force
|
Move-Item -Path "./test-project/ProjectSettings/ProjectSettingsIl2cpp.asset" -Destination "./test-project/ProjectSettings/ProjectSettings.asset" -Force
|
||||||
|
|
||||||
|
###########################
|
||||||
|
# Docker Readiness #
|
||||||
|
###########################
|
||||||
|
- name: Ensure Docker daemon is ready
|
||||||
|
timeout-minutes: 2
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$maxRetries = 10
|
||||||
|
$retryDelay = 6
|
||||||
|
for ($i = 0; $i -lt $maxRetries; $i++) {
|
||||||
|
$svc = Get-Service docker -ErrorAction SilentlyContinue
|
||||||
|
if ($svc -and $svc.Status -eq 'Running') {
|
||||||
|
docker version 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host "Docker is ready."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($svc -and $svc.Status -eq 'Stopped') {
|
||||||
|
Write-Host "Docker service stopped, attempting to start..."
|
||||||
|
Start-Service docker -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Write-Host "Waiting for Docker daemon (attempt $($i+1)/$maxRetries)..."
|
||||||
|
Start-Sleep -Seconds $retryDelay
|
||||||
|
}
|
||||||
|
Write-Error "Docker daemon did not start within $($maxRetries * $retryDelay) seconds"
|
||||||
|
exit 1
|
||||||
|
|
||||||
###########################
|
###########################
|
||||||
# Build #
|
# Build #
|
||||||
###########################
|
###########################
|
||||||
@@ -146,6 +174,8 @@ jobs:
|
|||||||
###########################
|
###########################
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
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 }})${{ matrix.enableGpu && ' With
|
||||||
|
GPU' || '' }}${{ matrix.buildProfile && ' With Build Profile' || '' }}
|
||||||
path: build
|
path: build
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|||||||
11
.github/workflows/integrity-check.yml
vendored
11
.github/workflows/integrity-check.yml
vendored
@@ -2,7 +2,8 @@ name: Integrity
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push: { branches: [main] }
|
push: { branches: [main] }
|
||||||
pull_request: {}
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, labeled]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -32,7 +33,9 @@ jobs:
|
|||||||
- run: yarn build || { echo "build command should always succeed" ; exit 61; }
|
- 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; }
|
# - run: yarn build --quiet && git diff --quiet dist || { echo "dist should be auto generated" ; git diff dist ; exit 62; }
|
||||||
|
|
||||||
orchestrator:
|
orchestrator-integration:
|
||||||
name: Orchestrator Integrity
|
name: Orchestrator Integration
|
||||||
uses: ./.github/workflows/orchestrator-integrity.yml
|
if: >-
|
||||||
|
github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'run-integration')
|
||||||
|
uses: ./.github/workflows/validate-orchestrator-integration.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
61
.github/workflows/orchestrator-async-checks.yml
vendored
61
.github/workflows/orchestrator-async-checks.yml
vendored
@@ -1,61 +0,0 @@
|
|||||||
name: Async Checks API
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
checksObject:
|
|
||||||
description: ''
|
|
||||||
required: false
|
|
||||||
default: ''
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
checks: 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 }}/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
|
|
||||||
DEBUG: true
|
|
||||||
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
|
||||||
PROJECT_PATH: test-project
|
|
||||||
UNITY_VERSION: 2019.3.15f1
|
|
||||||
USE_IL2CPP: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
asyncChecks:
|
|
||||||
name: Async Checks
|
|
||||||
if: github.event.event_type != 'pull_request_target'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- timeout-minutes: 180
|
|
||||||
env:
|
|
||||||
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
|
||||||
PROJECT_PATH: test-project
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GIT_PRIVATE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TARGET_PLATFORM: StandaloneWindows64
|
|
||||||
orchestratorTests: true
|
|
||||||
versioning: None
|
|
||||||
ORCHESTRATOR_CLUSTER: local-docker
|
|
||||||
# Commented out: Using LocalStack tests instead of real AWS
|
|
||||||
# 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
|
|
||||||
cd unity-builder
|
|
||||||
yarn
|
|
||||||
ls
|
|
||||||
yarn run cli -m checks-update
|
|
||||||
1109
.github/workflows/orchestrator-integrity.yml
vendored
1109
.github/workflows/orchestrator-integrity.yml
vendored
File diff suppressed because it is too large
Load Diff
91
.github/workflows/sync-secrets.yml
vendored
Normal file
91
.github/workflows/sync-secrets.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
name: Sync Secrets to Repositories
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target_repo:
|
||||||
|
description: 'Target repository (org/repo format)'
|
||||||
|
required: true
|
||||||
|
default: 'game-ci/orchestrator'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- game-ci/orchestrator
|
||||||
|
- game-ci/cli
|
||||||
|
dry_run:
|
||||||
|
description: 'Dry run (list secrets to sync without writing)'
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-secrets:
|
||||||
|
name: Sync secrets to ${{ inputs.target_repo }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Sync secrets
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
|
||||||
|
TARGET_REPO: ${{ inputs.target_repo }}
|
||||||
|
DRY_RUN: ${{ inputs.dry_run }}
|
||||||
|
# Secrets to sync — values come from repo + org secrets available here
|
||||||
|
SECRET_UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||||
|
SECRET_UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||||
|
SECRET_UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
||||||
|
SECRET_GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
|
||||||
|
SECRET_GOOGLE_SERVICE_ACCOUNT_EMAIL: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_EMAIL }}
|
||||||
|
SECRET_GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
|
||||||
|
SECRET_CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
SECRET_UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
||||||
|
SECRET_NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
run: |
|
||||||
|
SECRETS=(
|
||||||
|
"UNITY_EMAIL:SECRET_UNITY_EMAIL"
|
||||||
|
"UNITY_PASSWORD:SECRET_UNITY_PASSWORD"
|
||||||
|
"UNITY_SERIAL:SECRET_UNITY_SERIAL"
|
||||||
|
"UNITY_LICENSE:SECRET_UNITY_LICENSE"
|
||||||
|
"GIT_PRIVATE_TOKEN:SECRET_GIT_PRIVATE_TOKEN"
|
||||||
|
"GOOGLE_SERVICE_ACCOUNT_EMAIL:SECRET_GOOGLE_SERVICE_ACCOUNT_EMAIL"
|
||||||
|
"GOOGLE_SERVICE_ACCOUNT_KEY:SECRET_GOOGLE_SERVICE_ACCOUNT_KEY"
|
||||||
|
"CODECOV_TOKEN:SECRET_CODECOV_TOKEN"
|
||||||
|
"NPM_TOKEN:SECRET_NPM_TOKEN"
|
||||||
|
)
|
||||||
|
|
||||||
|
synced=0
|
||||||
|
skipped=0
|
||||||
|
|
||||||
|
for entry in "${SECRETS[@]}"; do
|
||||||
|
name="${entry%%:*}"
|
||||||
|
env_var="${entry##*:}"
|
||||||
|
value="${!env_var}"
|
||||||
|
|
||||||
|
if [ -z "$value" ]; then
|
||||||
|
echo "⏭ SKIP: $name (not available in this repo's context)"
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo "🔍 DRY RUN: would sync $name → $TARGET_REPO"
|
||||||
|
else
|
||||||
|
if echo "$value" | gh secret set "$name" -R "$TARGET_REPO" --body - 2>/dev/null; then
|
||||||
|
echo "✅ SYNCED: $name → $TARGET_REPO"
|
||||||
|
else
|
||||||
|
echo "⚠️ FAILED: $name → $TARGET_REPO (continuing)"
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
synced=$((synced - 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
synced=$((synced + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Summary ==="
|
||||||
|
echo "Synced: $synced"
|
||||||
|
echo "Skipped (not available): $skipped"
|
||||||
|
echo "Target: $TARGET_REPO"
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo "Mode: DRY RUN (no secrets were written)"
|
||||||
|
fi
|
||||||
203
.github/workflows/validate-community-plugins.yml
vendored
Normal file
203
.github/workflows/validate-community-plugins.yml
vendored
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
name: Validate Community Plugins
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run weekly on Sunday at 02:00 UTC
|
||||||
|
- cron: '0 2 * * 0'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
plugin_filter:
|
||||||
|
description: 'Filter plugins by name (regex pattern, empty = all)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
unity_version:
|
||||||
|
description: 'Override Unity version (empty = use plugin default)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
load-plugins:
|
||||||
|
name: Load Plugin Registry
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
matrix: ${{ steps.parse.outputs.matrix }}
|
||||||
|
plugin_count: ${{ steps.parse.outputs.count }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Parse plugin registry
|
||||||
|
id: parse
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
|
||||||
|
const registry = yaml.load(fs.readFileSync('community-plugins.yml', 'utf8'));
|
||||||
|
let plugins = registry.plugins || [];
|
||||||
|
|
||||||
|
// Apply name filter if provided
|
||||||
|
const filter = '${{ github.event.inputs.plugin_filter }}';
|
||||||
|
if (filter) {
|
||||||
|
const regex = new RegExp(filter, 'i');
|
||||||
|
plugins = plugins.filter(p => regex.test(p.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand platform matrix
|
||||||
|
const matrix = [];
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
const platforms = plugin.platforms || ['StandaloneLinux64'];
|
||||||
|
for (const platform of platforms) {
|
||||||
|
matrix.push({
|
||||||
|
name: plugin.name,
|
||||||
|
package: plugin.package,
|
||||||
|
source: plugin.source || 'git',
|
||||||
|
unity: '${{ github.event.inputs.unity_version }}' || plugin.unity || '2021.3',
|
||||||
|
platform: platform,
|
||||||
|
timeout: plugin.timeout || 30
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
core.setOutput('matrix', JSON.stringify({ include: matrix }));
|
||||||
|
core.setOutput('count', matrix.length);
|
||||||
|
console.log(`Found ${matrix.length} plugin-platform combinations to validate`);
|
||||||
|
|
||||||
|
validate:
|
||||||
|
name: '${{ matrix.name }} (${{ matrix.platform }})'
|
||||||
|
needs: load-plugins
|
||||||
|
if: needs.load-plugins.outputs.plugin_count > 0
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: ${{ fromJson(matrix.timeout) }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJson(needs.load-plugins.outputs.matrix) }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Create test project
|
||||||
|
run: |
|
||||||
|
mkdir -p test-project/Assets
|
||||||
|
mkdir -p test-project/Packages
|
||||||
|
mkdir -p test-project/ProjectSettings
|
||||||
|
|
||||||
|
# Create minimal manifest.json
|
||||||
|
if [ "${{ matrix.source }}" = "git" ]; then
|
||||||
|
cat > test-project/Packages/manifest.json << 'MANIFEST'
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"com.unity.modules.imgui": "1.0.0",
|
||||||
|
"com.unity.modules.jsonserialize": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Add git package via manifest
|
||||||
|
cd test-project
|
||||||
|
cat Packages/manifest.json | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
manifest = json.load(sys.stdin)
|
||||||
|
manifest['dependencies']['${{ matrix.name }}'] = '${{ matrix.package }}'
|
||||||
|
json.dump(manifest, sys.stdout, indent=2)
|
||||||
|
" > Packages/manifest.tmp && mv Packages/manifest.tmp Packages/manifest.json
|
||||||
|
cd ..
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create minimal ProjectSettings
|
||||||
|
cat > test-project/ProjectSettings/ProjectVersion.txt << EOF
|
||||||
|
m_EditorVersion: ${{ matrix.unity }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build with unity-builder
|
||||||
|
uses: ./
|
||||||
|
id: build
|
||||||
|
with:
|
||||||
|
projectPath: test-project
|
||||||
|
targetPlatform: ${{ matrix.platform }}
|
||||||
|
unityVersion: ${{ matrix.unity }}
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Record result
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
STATUS="${{ steps.build.outcome }}"
|
||||||
|
echo "## ${{ matrix.name }} — ${{ matrix.platform }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "$STATUS" = "success" ]; then
|
||||||
|
echo "✅ **PASSED** — Compiled and built successfully" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "❌ **FAILED** — Build or compilation failed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Unity: ${{ matrix.unity }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Platform: ${{ matrix.platform }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Source: ${{ matrix.source }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Package: \`${{ matrix.package }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
report:
|
||||||
|
name: Validation Report
|
||||||
|
needs: [load-plugins, validate]
|
||||||
|
if: always()
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Generate summary
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { data: run } = await github.rest.actions.listJobsForWorkflowRun({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
run_id: context.runId
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateJobs = run.jobs.filter(j => j.name.startsWith('validate'));
|
||||||
|
const passed = validateJobs.filter(j => j.conclusion === 'success').length;
|
||||||
|
const failed = validateJobs.filter(j => j.conclusion === 'failure').length;
|
||||||
|
const total = validateJobs.length;
|
||||||
|
|
||||||
|
let summary = `# Community Plugin Validation Report\n\n`;
|
||||||
|
summary += `**${passed}/${total} passed** | ${failed} failed\n\n`;
|
||||||
|
summary += `| Plugin | Platform | Status |\n|--------|----------|--------|\n`;
|
||||||
|
|
||||||
|
for (const job of validateJobs) {
|
||||||
|
const icon = job.conclusion === 'success' ? '✅' : '❌';
|
||||||
|
summary += `| ${job.name} | | ${icon} ${job.conclusion} |\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await core.summary.addRaw(summary).write();
|
||||||
|
|
||||||
|
// Create or update issue if there are failures
|
||||||
|
if (failed > 0) {
|
||||||
|
const title = `Community Plugin Validation: ${failed} failure(s) — ${new Date().toISOString().split('T')[0]}`;
|
||||||
|
const body = summary + `\n\n[Workflow Run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`;
|
||||||
|
|
||||||
|
const { data: issues } = await github.rest.issues.listForRepo({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
state: 'open',
|
||||||
|
labels: 'community-plugin-validation'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (issues.length > 0) {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issues[0].number,
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.create({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
labels: ['community-plugin-validation']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1172
.github/workflows/validate-orchestrator-integration.yml
vendored
Normal file
1172
.github/workflows/validate-orchestrator-integration.yml
vendored
Normal file
File diff suppressed because it is too large
Load Diff
237
.github/workflows/validate-orchestrator.yml
vendored
Normal file
237
.github/workflows/validate-orchestrator.yml
vendored
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
name: Validate Orchestrator Compatibility
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Essential plugin health checks — runs on every PR and push.
|
||||||
|
# Fast (~5 min): compilation, unit tests, plugin interface, type declarations.
|
||||||
|
#
|
||||||
|
# For exhaustive integration tests (k8s, AWS, local-docker, rclone) see
|
||||||
|
# validate-orchestrator-integration.yml which runs on a daily cron.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main, 'release/**', 'feature/**', 'refactor/**']
|
||||||
|
paths:
|
||||||
|
- 'src/model/orchestrator-plugin.ts'
|
||||||
|
- 'src/model/build-parameters.ts'
|
||||||
|
- 'src/model/input.ts'
|
||||||
|
- 'src/model/github.ts'
|
||||||
|
- 'src/model/cli/cli.ts'
|
||||||
|
- 'src/model/input-readers/**'
|
||||||
|
- 'src/index.ts'
|
||||||
|
- 'src/types/game-ci-orchestrator.d.ts'
|
||||||
|
- 'action.yml'
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- '.github/workflows/validate-orchestrator.yml'
|
||||||
|
pull_request:
|
||||||
|
branches: [main, 'release/**']
|
||||||
|
paths:
|
||||||
|
- 'src/model/orchestrator-plugin.ts'
|
||||||
|
- 'src/model/build-parameters.ts'
|
||||||
|
- 'src/model/input.ts'
|
||||||
|
- 'src/model/github.ts'
|
||||||
|
- 'src/model/cli/cli.ts'
|
||||||
|
- 'src/model/input-readers/**'
|
||||||
|
- 'src/index.ts'
|
||||||
|
- 'src/types/game-ci-orchestrator.d.ts'
|
||||||
|
- 'action.yml'
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- '.github/workflows/validate-orchestrator.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ============================================================================
|
||||||
|
# PLUGIN ARCHITECTURE HEALTH CHECK
|
||||||
|
# ============================================================================
|
||||||
|
# Validates that:
|
||||||
|
# 1. unity-builder compiles and its unit tests pass
|
||||||
|
# 2. Plugin loader degrades gracefully without orchestrator
|
||||||
|
# 3. Orchestrator compiles and its unit tests pass
|
||||||
|
# 4. Plugin loader loads all services when orchestrator is installed
|
||||||
|
# 5. Type declarations match actual exports
|
||||||
|
# ============================================================================
|
||||||
|
plugin-health:
|
||||||
|
name: Plugin Architecture Health
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout unity-builder
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Checkout orchestrator
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: game-ci/orchestrator
|
||||||
|
ref: ${{ github.head_ref || github.ref_name }}
|
||||||
|
path: orchestrator-standalone
|
||||||
|
continue-on-error: true
|
||||||
|
id: orchestrator-branch
|
||||||
|
|
||||||
|
- name: Fallback to orchestrator main branch
|
||||||
|
if: steps.orchestrator-branch.outcome == 'failure'
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: game-ci/orchestrator
|
||||||
|
path: orchestrator-standalone
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: yarn
|
||||||
|
|
||||||
|
# --- unity-builder compilation and tests ---
|
||||||
|
- name: Install unity-builder dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build unity-builder
|
||||||
|
run: |
|
||||||
|
echo "Building unity-builder TypeScript..."
|
||||||
|
npx tsc
|
||||||
|
echo "✓ unity-builder compiles successfully"
|
||||||
|
|
||||||
|
- name: Run orchestrator-plugin unit tests
|
||||||
|
run: |
|
||||||
|
echo "Running orchestrator-plugin unit tests..."
|
||||||
|
npx jest orchestrator-plugin --verbose --detectOpenHandles --forceExit
|
||||||
|
|
||||||
|
# --- Plugin loader without orchestrator ---
|
||||||
|
- name: Verify plugin loader returns undefined without orchestrator
|
||||||
|
run: |
|
||||||
|
echo "Checking plugin loader handles missing @game-ci/orchestrator..."
|
||||||
|
node -e "
|
||||||
|
const { loadOrchestratorPlugin } = require('./lib/model/orchestrator-plugin');
|
||||||
|
(async () => {
|
||||||
|
const plugin = await loadOrchestratorPlugin();
|
||||||
|
if (plugin !== undefined) {
|
||||||
|
console.error('ERROR: loadOrchestratorPlugin should return undefined when package not installed');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ loadOrchestratorPlugin() returns undefined when package not installed');
|
||||||
|
})();
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Verify orchestrator type declarations exist
|
||||||
|
run: |
|
||||||
|
if [ -f "src/types/game-ci-orchestrator.d.ts" ]; then
|
||||||
|
echo "✓ Type declarations for @game-ci/orchestrator exist"
|
||||||
|
else
|
||||||
|
echo "::error::Missing type declarations: src/types/game-ci-orchestrator.d.ts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Orchestrator compilation and tests ---
|
||||||
|
- name: Build and pack orchestrator
|
||||||
|
working-directory: orchestrator-standalone
|
||||||
|
run: |
|
||||||
|
yarn install --frozen-lockfile
|
||||||
|
echo "Building orchestrator..."
|
||||||
|
npx tsc
|
||||||
|
echo "✓ orchestrator compiles successfully"
|
||||||
|
echo "Packing orchestrator as tarball..."
|
||||||
|
npm pack
|
||||||
|
|
||||||
|
- name: Run orchestrator unit tests
|
||||||
|
working-directory: orchestrator-standalone
|
||||||
|
run: |
|
||||||
|
echo "Running orchestrator unit tests..."
|
||||||
|
npx jest --no-cache 2>&1 | tail -20
|
||||||
|
|
||||||
|
# --- Plugin loader with orchestrator installed ---
|
||||||
|
- name: Install orchestrator into unity-builder
|
||||||
|
run: |
|
||||||
|
echo "Installing orchestrator into unity-builder workspace..."
|
||||||
|
npm install ./orchestrator-standalone/game-ci-orchestrator-*.tgz --no-save --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Verify plugin loader returns exports with orchestrator installed
|
||||||
|
run: |
|
||||||
|
echo "Checking plugin loader returns defined exports..."
|
||||||
|
node -e "
|
||||||
|
const { loadOrchestratorPlugin } = require('./lib/model/orchestrator-plugin');
|
||||||
|
(async () => {
|
||||||
|
const plugin = await loadOrchestratorPlugin();
|
||||||
|
if (plugin === undefined) {
|
||||||
|
console.error('ERROR: loadOrchestratorPlugin should return defined plugin when package is installed');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const lifecycleMethods = [
|
||||||
|
'initialize', 'canHandleBuild', 'handleBuild',
|
||||||
|
'beforeLocalBuild', 'afterLocalBuild', 'handlePostBuild',
|
||||||
|
];
|
||||||
|
for (const method of lifecycleMethods) {
|
||||||
|
if (typeof plugin[method] !== 'function') {
|
||||||
|
console.error('ERROR: plugin.' + method + ' should be a function, got ' + typeof plugin[method]);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('✓ loadOrchestratorPlugin() returns plugin with all ' + lifecycleMethods.length + ' lifecycle methods');
|
||||||
|
})();
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Verify type declarations match orchestrator exports
|
||||||
|
run: |
|
||||||
|
echo "Checking type declarations align with orchestrator exports..."
|
||||||
|
node -e "
|
||||||
|
const orch = require('@game-ci/orchestrator');
|
||||||
|
const expectedExports = [
|
||||||
|
'Orchestrator', 'BuildReliabilityService', 'TestWorkflowService',
|
||||||
|
'HotRunnerService', 'OutputService', 'OutputTypeRegistry',
|
||||||
|
'ArtifactUploadHandler', 'IncrementalSyncService',
|
||||||
|
'ChildWorkspaceService', 'LocalCacheService', 'SubmoduleProfileService',
|
||||||
|
'LfsAgentService', 'GitHooksService',
|
||||||
|
];
|
||||||
|
const missing = expectedExports.filter(e => orch[e] === undefined);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error('ERROR: Missing exports from @game-ci/orchestrator:', missing.join(', '));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ All ' + expectedExports.length + ' declared exports present in orchestrator package');
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Smoke test orchestrator build wiring
|
||||||
|
run: |
|
||||||
|
echo "Verifying orchestrator build wiring end-to-end..."
|
||||||
|
node -e "
|
||||||
|
const { loadOrchestratorPlugin } = require('./lib/model/orchestrator-plugin');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// Verify plugin loads successfully with orchestrator installed
|
||||||
|
const plugin = await loadOrchestratorPlugin();
|
||||||
|
if (plugin === undefined) {
|
||||||
|
console.error('ERROR: plugin should be defined when orchestrator is installed');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all lifecycle methods are callable
|
||||||
|
const lifecycleMethods = [
|
||||||
|
'initialize', 'canHandleBuild', 'handleBuild',
|
||||||
|
'beforeLocalBuild', 'afterLocalBuild', 'handlePostBuild',
|
||||||
|
];
|
||||||
|
for (const m of lifecycleMethods) {
|
||||||
|
if (typeof plugin[m] !== 'function') {
|
||||||
|
console.error('ERROR: plugin.' + m + ' should be a function, got ' + typeof plugin[m]);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('✓ Plugin has all ' + lifecycleMethods.length + ' lifecycle methods');
|
||||||
|
|
||||||
|
// Verify canHandleBuild returns a boolean
|
||||||
|
const canHandle = plugin.canHandleBuild();
|
||||||
|
if (typeof canHandle !== 'boolean') {
|
||||||
|
console.error('ERROR: canHandleBuild() should return a boolean, got ' + typeof canHandle);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ canHandleBuild() returns boolean');
|
||||||
|
|
||||||
|
console.log('✓ Plugin architecture wiring verified');
|
||||||
|
})();
|
||||||
|
"
|
||||||
150
action.yml
150
action.yml
@@ -47,6 +47,10 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
description: 'Custom parameters to configure the build.'
|
description: 'Custom parameters to configure the build.'
|
||||||
|
useHostNetwork:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
description: 'Initialises Docker using the host network. (Linux only)'
|
||||||
versioning:
|
versioning:
|
||||||
required: false
|
required: false
|
||||||
default: 'Semantic'
|
default: 'Semantic'
|
||||||
@@ -104,11 +108,13 @@ inputs:
|
|||||||
gitPrivateToken:
|
gitPrivateToken:
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
description: '[Orchestrator] Github private token to pull from github'
|
description: 'Github private token to pull from github'
|
||||||
githubOwner:
|
providerStrategy:
|
||||||
|
default: 'local'
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
description:
|
||||||
description: '[Orchestrator] GitHub owner name or organization/team name'
|
'Build execution strategy. Use "local" for local Docker/Mac builds. For remote builds (aws, k8s, etc.), install
|
||||||
|
@game-ci/orchestrator and use the game-ci/orchestrator action which declares its own inputs.'
|
||||||
runAsHostUser:
|
runAsHostUser:
|
||||||
required: false
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
@@ -149,102 +155,7 @@ inputs:
|
|||||||
allowDirtyBuild:
|
allowDirtyBuild:
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
description: '[Orchestrator] Allows the branch of the build to be dirty, and still generate the build.'
|
description: '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),
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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)'
|
|
||||||
customJob:
|
|
||||||
required: false
|
|
||||||
default: ''
|
|
||||||
description:
|
|
||||||
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (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.'
|
|
||||||
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
|
|
||||||
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'
|
|
||||||
containerMemory:
|
|
||||||
default: ''
|
|
||||||
required: false
|
|
||||||
description: '[Orchestrator] 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"'
|
|
||||||
readInputOverrideCommand:
|
|
||||||
default: ''
|
|
||||||
required: false
|
|
||||||
description:
|
|
||||||
'[Orchestrator] 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
|
|
||||||
completion.'
|
|
||||||
kubeVolume:
|
|
||||||
default: ''
|
|
||||||
required: false
|
|
||||||
description: '[Orchestrator] 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.'
|
|
||||||
kubeVolumeSize:
|
|
||||||
default: '5Gi'
|
|
||||||
required: false
|
|
||||||
description: '[Orchestrator] Amount of disc space to assign the Kubernetes Persistent Volume'
|
|
||||||
cacheKey:
|
|
||||||
default: ''
|
|
||||||
required: false
|
|
||||||
description: '[Orchestrator] 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
|
|
||||||
imports or self-hosted ephemeral runners.'
|
|
||||||
cacheUnityInstallationOnMac:
|
cacheUnityInstallationOnMac:
|
||||||
default: 'false'
|
default: 'false'
|
||||||
required: false
|
required: false
|
||||||
@@ -269,45 +180,6 @@ inputs:
|
|||||||
default: 'false'
|
default: 'false'
|
||||||
required: false
|
required: false
|
||||||
description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.'
|
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.'
|
|
||||||
|
|
||||||
hotRunnerEnabled:
|
|
||||||
description: '[HotRunner] Use persistent hot runner for builds (requires pre-registered runners)'
|
|
||||||
required: false
|
|
||||||
default: 'false'
|
|
||||||
hotRunnerTransport:
|
|
||||||
description: '[HotRunner] Transport protocol for hot runner communication: websocket, grpc, named-pipe'
|
|
||||||
required: false
|
|
||||||
default: 'websocket'
|
|
||||||
hotRunnerHost:
|
|
||||||
description: '[HotRunner] Hot runner host address'
|
|
||||||
required: false
|
|
||||||
default: 'localhost'
|
|
||||||
hotRunnerPort:
|
|
||||||
description: '[HotRunner] Hot runner port number'
|
|
||||||
required: false
|
|
||||||
default: '9090'
|
|
||||||
hotRunnerHealthInterval:
|
|
||||||
description: '[HotRunner] Health check interval in seconds'
|
|
||||||
required: false
|
|
||||||
default: '30'
|
|
||||||
hotRunnerMaxIdle:
|
|
||||||
description: '[HotRunner] Maximum idle time in seconds before recycling runner'
|
|
||||||
required: false
|
|
||||||
default: '3600'
|
|
||||||
hotRunnerFallbackToCold:
|
|
||||||
description: '[HotRunner] Fall back to cold build if no hot runner available'
|
|
||||||
required: false
|
|
||||||
default: 'true'
|
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
volume:
|
volume:
|
||||||
|
|||||||
27
community-plugins.yml
Normal file
27
community-plugins.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Community Plugin Validation Registry
|
||||||
|
# Packages listed here are automatically tested on a schedule
|
||||||
|
# to ensure compatibility with unity-builder.
|
||||||
|
#
|
||||||
|
# Format:
|
||||||
|
# - name: Human-readable name
|
||||||
|
# package: UPM package name or git URL
|
||||||
|
# source: upm | git | asset-store
|
||||||
|
# unity: Minimum Unity version (optional, defaults to 2021.3)
|
||||||
|
# platforms: List of platforms to test (optional, defaults to [StandaloneLinux64])
|
||||||
|
# timeout: Build timeout in minutes (optional, defaults to 30)
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
# Example entries — community members can submit PRs to add their packages
|
||||||
|
- name: UniTask
|
||||||
|
package: https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask
|
||||||
|
source: git
|
||||||
|
platforms: [StandaloneLinux64, StandaloneWindows64]
|
||||||
|
|
||||||
|
- name: NaughtyAttributes
|
||||||
|
package: https://github.com/dbrizov/NaughtyAttributes.git?path=Assets/NaughtyAttributes
|
||||||
|
source: git
|
||||||
|
|
||||||
|
- name: Unity Atoms
|
||||||
|
package: https://github.com/unity-atoms/unity-atoms.git
|
||||||
|
source: git
|
||||||
|
platforms: [StandaloneLinux64]
|
||||||
264985
dist/index.js
generated
vendored
264985
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
18407
dist/licenses.txt
generated
vendored
18407
dist/licenses.txt
generated
vendored
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -11,19 +11,8 @@
|
|||||||
"build": "yarn && tsc && ncc build lib --source-map --license licenses.txt",
|
"build": "yarn && tsc && ncc build lib --source-map --license licenses.txt",
|
||||||
"lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts",
|
"lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts",
|
||||||
"format": "prettier --write \"src/**/*.{js,ts}\"",
|
"format": "prettier --write \"src/**/*.{js,ts}\"",
|
||||||
"cli": "yarn ts-node src/index.ts -m cli",
|
|
||||||
"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",
|
|
||||||
"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": "jest",
|
"test": "jest",
|
||||||
"test:ci": "jest --config=jest.ci.config.js --runInBand",
|
"test:ci": "jest --config=jest.ci.config.js --runInBand"
|
||||||
"test-i": "cross-env orchestratorTests=true yarn test -i -t \"orchestrator\"",
|
|
||||||
"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\""
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.x"
|
"node": ">=18.x"
|
||||||
@@ -33,35 +22,20 @@
|
|||||||
"@actions/core": "^1.11.1",
|
"@actions/core": "^1.11.1",
|
||||||
"@actions/exec": "^1.1.1",
|
"@actions/exec": "^1.1.1",
|
||||||
"@actions/github": "^6.0.0",
|
"@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",
|
|
||||||
"aws-sdk": "^2.1081.0",
|
|
||||||
"base-64": "^1.0.0",
|
|
||||||
"commander": "^9.0.0",
|
"commander": "^9.0.0",
|
||||||
"commander-ts": "^0.2.0",
|
"commander-ts": "^0.2.0",
|
||||||
"kubernetes-client": "^9.0.0",
|
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"nanoid": "^3.3.1",
|
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"nanoid": "^3.3.1",
|
||||||
"semver": "^7.5.2",
|
"semver": "^7.5.2",
|
||||||
"shell-quote": "^1.8.3",
|
|
||||||
"ts-md5": "^1.3.1",
|
"ts-md5": "^1.3.1",
|
||||||
"unity-changeset": "^3.1.0",
|
"unity-changeset": "^3.1.0",
|
||||||
"uuid": "^9.0.0",
|
|
||||||
"yaml": "^2.2.2"
|
"yaml": "^2.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/base-64": "^1.0.0",
|
|
||||||
"@types/jest": "^27.4.1",
|
"@types/jest": "^27.4.1",
|
||||||
"@types/node": "^17.0.23",
|
"@types/node": "^17.0.23",
|
||||||
"@types/semver": "^7.3.9",
|
"@types/semver": "^7.3.9",
|
||||||
"@types/uuid": "^9.0.0",
|
|
||||||
"@typescript-eslint/parser": "4.8.1",
|
"@typescript-eslint/parser": "4.8.1",
|
||||||
"@vercel/ncc": "^0.36.1",
|
"@vercel/ncc": "^0.36.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
|||||||
262
src/index-plugin-features.test.ts
Normal file
262
src/index-plugin-features.test.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* Integration wiring tests for the plugin lifecycle in index.ts
|
||||||
|
*
|
||||||
|
* These tests verify that:
|
||||||
|
* - The plugin lifecycle hooks are called in the correct order
|
||||||
|
* - Plugin canHandleBuild() controls the execution path
|
||||||
|
* - fallbackToLocal is handled correctly
|
||||||
|
* - When no plugin is installed, local builds still work
|
||||||
|
* - When providerStrategy is non-local without a plugin, an error is thrown
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BuildParameters } from './model';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock plugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockPlugin = {
|
||||||
|
initialize: jest.fn().mockResolvedValue(undefined),
|
||||||
|
canHandleBuild: jest.fn().mockReturnValue(false),
|
||||||
|
handleBuild: jest.fn().mockResolvedValue({ exitCode: 0 }),
|
||||||
|
beforeLocalBuild: jest.fn().mockResolvedValue(undefined),
|
||||||
|
afterLocalBuild: jest.fn().mockResolvedValue(undefined),
|
||||||
|
handlePostBuild: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLoadOrchestratorPlugin = jest.fn().mockResolvedValue(mockPlugin);
|
||||||
|
|
||||||
|
jest.mock('./model/orchestrator-plugin', () => ({
|
||||||
|
loadOrchestratorPlugin: mockLoadOrchestratorPlugin,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@actions/core');
|
||||||
|
jest.mock('./model', () => ({
|
||||||
|
Action: {
|
||||||
|
checkCompatibility: jest.fn(),
|
||||||
|
workspace: '/workspace',
|
||||||
|
actionFolder: '/action',
|
||||||
|
},
|
||||||
|
BuildParameters: {
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
Cache: {
|
||||||
|
verify: jest.fn(),
|
||||||
|
},
|
||||||
|
Docker: {
|
||||||
|
run: jest.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
ImageTag: jest.fn().mockImplementation(() => ({
|
||||||
|
toString: () => 'mock-image:latest',
|
||||||
|
})),
|
||||||
|
Output: {
|
||||||
|
setBuildVersion: jest.fn().mockResolvedValue(''),
|
||||||
|
setAndroidVersionCode: jest.fn().mockResolvedValue(''),
|
||||||
|
setEngineExitCode: jest.fn().mockResolvedValue(''),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./model/cli/cli', () => ({
|
||||||
|
Cli: {
|
||||||
|
InitCliMode: jest.fn().mockReturnValue(false),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./model/mac-builder', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
run: jest.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./model/platform-setup', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
setup: jest.fn().mockResolvedValue(''),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedBuildParametersCreate = BuildParameters.create as jest.Mock;
|
||||||
|
|
||||||
|
function createMockBuildParameters(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
providerStrategy: 'local',
|
||||||
|
targetPlatform: 'StandaloneLinux64',
|
||||||
|
editorVersion: '2021.3.1f1',
|
||||||
|
buildVersion: '1.0.0',
|
||||||
|
androidVersionCode: '1',
|
||||||
|
projectPath: '.',
|
||||||
|
branch: 'main',
|
||||||
|
runnerTempPath: '/tmp',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runIndex(overrides: Record<string, any> = {}): Promise<void> {
|
||||||
|
mockedBuildParametersCreate.mockResolvedValue(createMockBuildParameters(overrides));
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
jest.isolateModules(() => {
|
||||||
|
require('./index');
|
||||||
|
});
|
||||||
|
setTimeout(resolve, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('index.ts plugin lifecycle wiring', () => {
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
const originalEnvironment = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
process.env.GITHUB_WORKSPACE = '/workspace';
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||||
|
|
||||||
|
// Reset plugin to default behavior
|
||||||
|
mockPlugin.canHandleBuild.mockReturnValue(false);
|
||||||
|
mockPlugin.handleBuild.mockResolvedValue({ exitCode: 0 });
|
||||||
|
mockLoadOrchestratorPlugin.mockResolvedValue(mockPlugin);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||||
|
process.env = { ...originalEnvironment };
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Local build with plugin
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('local build with plugin installed', () => {
|
||||||
|
it('should call lifecycle hooks in order: initialize → beforeLocalBuild → [build] → afterLocalBuild → handlePostBuild', async () => {
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
mockPlugin.initialize.mockImplementation(async () => callOrder.push('initialize'));
|
||||||
|
mockPlugin.beforeLocalBuild.mockImplementation(async () => callOrder.push('beforeLocalBuild'));
|
||||||
|
mockPlugin.afterLocalBuild.mockImplementation(async () => callOrder.push('afterLocalBuild'));
|
||||||
|
mockPlugin.handlePostBuild.mockImplementation(async () => callOrder.push('handlePostBuild'));
|
||||||
|
|
||||||
|
await runIndex();
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(['initialize', 'beforeLocalBuild', 'afterLocalBuild', 'handlePostBuild']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass buildParameters and workspace to initialize', async () => {
|
||||||
|
await runIndex({ targetPlatform: 'WebGL' });
|
||||||
|
|
||||||
|
expect(mockPlugin.initialize).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ targetPlatform: 'WebGL' }),
|
||||||
|
'/workspace',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass workspace to beforeLocalBuild', async () => {
|
||||||
|
await runIndex();
|
||||||
|
|
||||||
|
expect(mockPlugin.beforeLocalBuild).toHaveBeenCalledWith('/workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass workspace and exit code to afterLocalBuild', async () => {
|
||||||
|
await runIndex();
|
||||||
|
|
||||||
|
expect(mockPlugin.afterLocalBuild).toHaveBeenCalledWith('/workspace', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass exit code to handlePostBuild', async () => {
|
||||||
|
await runIndex();
|
||||||
|
|
||||||
|
expect(mockPlugin.handlePostBuild).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Plugin handles build entirely
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('plugin handles build (canHandleBuild = true)', () => {
|
||||||
|
it('should call handleBuild instead of Docker.run', async () => {
|
||||||
|
const { Docker } = require('./model');
|
||||||
|
mockPlugin.canHandleBuild.mockReturnValue(true);
|
||||||
|
mockPlugin.handleBuild.mockResolvedValue({ exitCode: 0 });
|
||||||
|
|
||||||
|
await runIndex();
|
||||||
|
|
||||||
|
expect(mockPlugin.handleBuild).toHaveBeenCalledWith('mock-image:latest');
|
||||||
|
expect(Docker.run).not.toHaveBeenCalled();
|
||||||
|
expect(mockPlugin.beforeLocalBuild).not.toHaveBeenCalled();
|
||||||
|
expect(mockPlugin.afterLocalBuild).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still call handlePostBuild after handleBuild', async () => {
|
||||||
|
mockPlugin.canHandleBuild.mockReturnValue(true);
|
||||||
|
mockPlugin.handleBuild.mockResolvedValue({ exitCode: 0 });
|
||||||
|
|
||||||
|
await runIndex();
|
||||||
|
|
||||||
|
expect(mockPlugin.handlePostBuild).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Fallback to local
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('fallback to local build', () => {
|
||||||
|
it('should do a local build when handleBuild returns fallbackToLocal', async () => {
|
||||||
|
const { Docker } = require('./model');
|
||||||
|
mockPlugin.canHandleBuild.mockReturnValue(true);
|
||||||
|
mockPlugin.handleBuild.mockResolvedValue({ exitCode: -1, fallbackToLocal: true });
|
||||||
|
|
||||||
|
await runIndex();
|
||||||
|
|
||||||
|
expect(mockPlugin.handleBuild).toHaveBeenCalled();
|
||||||
|
expect(mockPlugin.beforeLocalBuild).toHaveBeenCalled();
|
||||||
|
expect(Docker.run).toHaveBeenCalled();
|
||||||
|
expect(mockPlugin.afterLocalBuild).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// No plugin installed
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('no plugin installed', () => {
|
||||||
|
it('should build locally without errors when providerStrategy is local', async () => {
|
||||||
|
const { Docker } = require('./model');
|
||||||
|
mockLoadOrchestratorPlugin.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await runIndex({ providerStrategy: 'local' });
|
||||||
|
|
||||||
|
expect(Docker.run).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should error when providerStrategy is non-local and no plugin', async () => {
|
||||||
|
const core = require('@actions/core');
|
||||||
|
mockLoadOrchestratorPlugin.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await runIndex({ providerStrategy: 'aws' });
|
||||||
|
|
||||||
|
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('requires @game-ci/orchestrator'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// canHandleBuild = false with non-local provider
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('plugin installed but canHandleBuild returns false with non-local provider', () => {
|
||||||
|
it('should error when providerStrategy is non-local', async () => {
|
||||||
|
const core = require('@actions/core');
|
||||||
|
mockPlugin.canHandleBuild.mockReturnValue(false);
|
||||||
|
|
||||||
|
await runIndex({ providerStrategy: 'aws' });
|
||||||
|
|
||||||
|
// The plugin is initialized but says it can't handle the build,
|
||||||
|
// and providerStrategy is not local, so it falls to the error case
|
||||||
|
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('requires @game-ci/orchestrator'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
84
src/index.ts
84
src/index.ts
@@ -1,10 +1,9 @@
|
|||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output } from './model';
|
import { Action, BuildParameters, Cache, Docker, ImageTag, Output } from './model';
|
||||||
import { Cli } from './model/cli/cli';
|
import { Cli } from './model/cli/cli';
|
||||||
import MacBuilder from './model/mac-builder';
|
import MacBuilder from './model/mac-builder';
|
||||||
import PlatformSetup from './model/platform-setup';
|
import PlatformSetup from './model/platform-setup';
|
||||||
import { HotRunnerService } from './model/orchestrator/services/hot-runner';
|
import { loadOrchestratorPlugin, OrchestratorPlugin } from './model/orchestrator-plugin';
|
||||||
import { HotRunnerConfig } from './model/orchestrator/services/hot-runner/hot-runner-types';
|
|
||||||
|
|
||||||
async function runMain() {
|
async function runMain() {
|
||||||
try {
|
try {
|
||||||
@@ -17,62 +16,39 @@ async function runMain() {
|
|||||||
Cache.verify();
|
Cache.verify();
|
||||||
|
|
||||||
const { workspace, actionFolder } = Action;
|
const { workspace, actionFolder } = Action;
|
||||||
|
|
||||||
const buildParameters = await BuildParameters.create();
|
const buildParameters = await BuildParameters.create();
|
||||||
const baseImage = new ImageTag(buildParameters);
|
const baseImage = new ImageTag(buildParameters);
|
||||||
|
|
||||||
|
// Load orchestrator plugin (optional — only needed for remote builds and plugin features)
|
||||||
|
const plugin = await loadOrchestratorPlugin();
|
||||||
|
await plugin?.initialize(buildParameters, workspace);
|
||||||
|
|
||||||
let exitCode = -1;
|
let exitCode = -1;
|
||||||
|
|
||||||
// Hot runner path: attempt to use a persistent Unity editor instance
|
if (plugin?.canHandleBuild()) {
|
||||||
if (buildParameters.hotRunnerEnabled) {
|
// Plugin handles the build entirely (remote providers, hot runner, test workflows)
|
||||||
core.info('[HotRunner] Hot runner mode enabled, attempting hot build...');
|
const result = await plugin.handleBuild(baseImage.toString());
|
||||||
|
|
||||||
const hotRunnerConfig: HotRunnerConfig = {
|
exitCode = result.fallbackToLocal
|
||||||
enabled: true,
|
? await runLocalBuild(buildParameters, baseImage, workspace, actionFolder, plugin)
|
||||||
transport: buildParameters.hotRunnerTransport,
|
: result.exitCode;
|
||||||
host: buildParameters.hotRunnerHost,
|
|
||||||
port: buildParameters.hotRunnerPort,
|
|
||||||
healthCheckInterval: buildParameters.hotRunnerHealthInterval,
|
|
||||||
maxIdleTime: buildParameters.hotRunnerMaxIdle,
|
|
||||||
maxJobsBeforeRecycle: 0, // no automatic recycle by job count
|
|
||||||
};
|
|
||||||
|
|
||||||
const hotRunnerService = new HotRunnerService();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await hotRunnerService.initialize(hotRunnerConfig);
|
|
||||||
const result = await hotRunnerService.submitBuild(buildParameters, (output) => {
|
|
||||||
core.info(output);
|
|
||||||
});
|
|
||||||
|
|
||||||
exitCode = result.exitCode;
|
|
||||||
core.info(`[HotRunner] Build completed with exit code ${exitCode}`);
|
|
||||||
await hotRunnerService.shutdown();
|
|
||||||
} catch (hotRunnerError) {
|
|
||||||
await hotRunnerService.shutdown();
|
|
||||||
|
|
||||||
if (buildParameters.hotRunnerFallbackToCold) {
|
|
||||||
core.warning(
|
|
||||||
`[HotRunner] Hot runner failed: ${(hotRunnerError as Error).message}. Falling back to cold build.`,
|
|
||||||
);
|
|
||||||
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
|
|
||||||
} else {
|
|
||||||
throw hotRunnerError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (buildParameters.providerStrategy === 'local') {
|
} else if (buildParameters.providerStrategy === 'local') {
|
||||||
core.info('Building locally');
|
exitCode = await runLocalBuild(buildParameters, baseImage, workspace, actionFolder, plugin);
|
||||||
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
|
|
||||||
} else {
|
} else {
|
||||||
await Orchestrator.run(buildParameters, baseImage.toString());
|
throw new Error(
|
||||||
exitCode = 0;
|
`Provider strategy "${buildParameters.providerStrategy}" requires @game-ci/orchestrator. ` +
|
||||||
|
'Install it via the game-ci/orchestrator action, or use providerStrategy=local.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set output
|
// Set core outputs
|
||||||
await Output.setBuildVersion(buildParameters.buildVersion);
|
await Output.setBuildVersion(buildParameters.buildVersion);
|
||||||
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
|
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
|
||||||
await Output.setEngineExitCode(exitCode);
|
await Output.setEngineExitCode(exitCode);
|
||||||
|
|
||||||
|
// Plugin handles post-build (artifacts, archiving, retention)
|
||||||
|
await plugin?.handlePostBuild(exitCode);
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
core.setFailed(`Build failed with exit code ${exitCode}`);
|
core.setFailed(`Build failed with exit code ${exitCode}`);
|
||||||
}
|
}
|
||||||
@@ -81,28 +57,28 @@ async function runMain() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runColdBuild(
|
async function runLocalBuild(
|
||||||
buildParameters: BuildParameters,
|
buildParameters: BuildParameters,
|
||||||
baseImage: ImageTag,
|
baseImage: ImageTag,
|
||||||
workspace: string,
|
workspace: string,
|
||||||
actionFolder: string,
|
actionFolder: string,
|
||||||
|
plugin?: OrchestratorPlugin,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
if (buildParameters.providerStrategy === 'local') {
|
await plugin?.beforeLocalBuild(workspace);
|
||||||
core.info('Building locally');
|
|
||||||
await PlatformSetup.setup(buildParameters, actionFolder);
|
|
||||||
|
|
||||||
return process.platform === 'darwin'
|
await PlatformSetup.setup(buildParameters, actionFolder);
|
||||||
|
const exitCode =
|
||||||
|
process.platform === 'darwin'
|
||||||
? await MacBuilder.run(actionFolder)
|
? await MacBuilder.run(actionFolder)
|
||||||
: await Docker.run(baseImage.toString(), {
|
: await Docker.run(baseImage.toString(), {
|
||||||
workspace,
|
workspace,
|
||||||
actionFolder,
|
actionFolder,
|
||||||
...buildParameters,
|
...buildParameters,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
await Orchestrator.run(buildParameters, baseImage.toString());
|
|
||||||
|
|
||||||
return 0;
|
await plugin?.afterLocalBuild(workspace, exitCode);
|
||||||
}
|
|
||||||
|
return exitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
runMain();
|
runMain();
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -219,5 +219,10 @@ describe('BuildParameters', () => {
|
|||||||
jest.spyOn(Input, 'customParameters', 'get').mockReturnValue(mockValue);
|
jest.spyOn(Input, 'customParameters', 'get').mockReturnValue(mockValue);
|
||||||
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ customParameters: mockValue }));
|
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ customParameters: mockValue }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([true, false])('returns the flag for useHostNetwork when %s', async (mockValue) => {
|
||||||
|
jest.spyOn(Input, 'useHostNetwork', 'get').mockReturnValue(mockValue);
|
||||||
|
await expect(BuildParameters.create()).resolves.toEqual(expect.objectContaining({ useHostNetwork: mockValue }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { customAlphabet } from 'nanoid';
|
import { customAlphabet } from 'nanoid';
|
||||||
import AndroidVersioning from './android-versioning';
|
import AndroidVersioning from './android-versioning';
|
||||||
import OrchestratorConstants from './orchestrator/options/orchestrator-constants';
|
|
||||||
import OrchestratorBuildGuid from './orchestrator/options/orchestrator-guid';
|
|
||||||
import Input from './input';
|
import Input from './input';
|
||||||
import Platform from './platform';
|
import Platform from './platform';
|
||||||
import UnityVersioning from './unity-versioning';
|
import UnityVersioning from './unity-versioning';
|
||||||
@@ -10,8 +8,6 @@ import { GitRepoReader } from './input-readers/git-repo';
|
|||||||
import { GithubCliReader } from './input-readers/github-cli';
|
import { GithubCliReader } from './input-readers/github-cli';
|
||||||
import { Cli } from './cli/cli';
|
import { Cli } from './cli/cli';
|
||||||
import GitHub from './github';
|
import GitHub from './github';
|
||||||
import OrchestratorOptions from './orchestrator/options/orchestrator-options';
|
|
||||||
import Orchestrator from './orchestrator/orchestrator';
|
|
||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
|
|
||||||
class BuildParameters {
|
class BuildParameters {
|
||||||
@@ -51,72 +47,26 @@ class BuildParameters {
|
|||||||
public containerRegistryImageVersion!: string;
|
public containerRegistryImageVersion!: string;
|
||||||
|
|
||||||
public customParameters!: string;
|
public customParameters!: string;
|
||||||
|
public useHostNetwork!: boolean;
|
||||||
public sshAgent!: string;
|
public sshAgent!: string;
|
||||||
public sshPublicKeysDirectoryPath!: string;
|
public sshPublicKeysDirectoryPath!: string;
|
||||||
public providerStrategy!: string;
|
public providerStrategy!: string;
|
||||||
public gitPrivateToken!: 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;
|
|
||||||
public runAsHostUser!: string;
|
public runAsHostUser!: string;
|
||||||
public chownFilesTo!: string;
|
public chownFilesTo!: string;
|
||||||
public commandHooks!: string;
|
|
||||||
public pullInputList!: string[];
|
|
||||||
public inputPullCommand!: string;
|
|
||||||
public cacheKey!: string;
|
|
||||||
|
|
||||||
public postBuildContainerHooks!: string;
|
|
||||||
public preBuildContainerHooks!: string;
|
|
||||||
public customJob!: string;
|
|
||||||
public runNumber!: string;
|
public runNumber!: string;
|
||||||
public branch!: string;
|
public branch!: string;
|
||||||
public githubRepo!: string;
|
public githubRepo!: string;
|
||||||
public orchestratorRepoName!: string;
|
|
||||||
public cloneDepth!: number;
|
|
||||||
public gitSha!: string;
|
public gitSha!: string;
|
||||||
public logId!: string;
|
public logId!: string;
|
||||||
public buildGuid!: string;
|
public buildGuid!: string;
|
||||||
public orchestratorBranch!: string;
|
|
||||||
public orchestratorDebug!: boolean | undefined;
|
|
||||||
public buildPlatform!: string | undefined;
|
public buildPlatform!: string | undefined;
|
||||||
public isCliMode!: boolean;
|
public isCliMode!: boolean;
|
||||||
public maxRetainedWorkspaces!: number;
|
|
||||||
public useLargePackages!: boolean;
|
|
||||||
public useCompressionStrategy!: boolean;
|
|
||||||
public garbageMaxAge!: number;
|
|
||||||
public githubChecks!: boolean;
|
|
||||||
public asyncWorkflow!: boolean;
|
|
||||||
public githubCheckId!: string;
|
|
||||||
public finalHooks!: string[];
|
|
||||||
public skipLfs!: boolean;
|
|
||||||
public skipCache!: boolean;
|
|
||||||
public cacheUnityInstallationOnMac!: boolean;
|
public cacheUnityInstallationOnMac!: boolean;
|
||||||
public unityHubVersionOnMac!: string;
|
public unityHubVersionOnMac!: string;
|
||||||
public dockerWorkspacePath!: string;
|
public dockerWorkspacePath!: string;
|
||||||
public hotRunnerEnabled!: boolean;
|
|
||||||
public hotRunnerTransport!: 'websocket' | 'grpc' | 'named-pipe';
|
|
||||||
public hotRunnerHost!: string;
|
|
||||||
public hotRunnerPort!: number;
|
|
||||||
public hotRunnerHealthInterval!: number;
|
|
||||||
public hotRunnerMaxIdle!: number;
|
|
||||||
public hotRunnerFallbackToCold!: boolean;
|
|
||||||
|
|
||||||
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
|
|
||||||
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async create(): Promise<BuildParameters> {
|
static async create(): Promise<BuildParameters> {
|
||||||
const buildFile = this.parseBuildFile(Input.buildName, Input.targetPlatform, Input.androidExportType);
|
const buildFile = this.parseBuildFile(Input.buildName, Input.targetPlatform, Input.androidExportType);
|
||||||
@@ -162,6 +112,8 @@ class BuildParameters {
|
|||||||
core.setSecret(`${unitySerial.slice(0, -4)}XXXX`);
|
core.setSecret(`${unitySerial.slice(0, -4)}XXXX`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providerStrategy = Input.getInput('providerStrategy') || (Cli.isCliMode ? 'aws' : 'local');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
editorVersion,
|
editorVersion,
|
||||||
customImage: Input.customImage,
|
customImage: Input.customImage,
|
||||||
@@ -190,6 +142,7 @@ class BuildParameters {
|
|||||||
androidExportType: Input.androidExportType,
|
androidExportType: Input.androidExportType,
|
||||||
androidSymbolType: androidSymbolExportType,
|
androidSymbolType: androidSymbolExportType,
|
||||||
customParameters: Input.customParameters,
|
customParameters: Input.customParameters,
|
||||||
|
useHostNetwork: Input.useHostNetwork,
|
||||||
sshAgent: Input.sshAgent,
|
sshAgent: Input.sshAgent,
|
||||||
sshPublicKeysDirectoryPath: Input.sshPublicKeysDirectoryPath,
|
sshPublicKeysDirectoryPath: Input.sshPublicKeysDirectoryPath,
|
||||||
gitPrivateToken: Input.gitPrivateToken ?? (await GithubCliReader.GetGitHubAuthToken()),
|
gitPrivateToken: Input.gitPrivateToken ?? (await GithubCliReader.GetGitHubAuthToken()),
|
||||||
@@ -200,62 +153,21 @@ class BuildParameters {
|
|||||||
dockerIsolationMode: Input.dockerIsolationMode,
|
dockerIsolationMode: Input.dockerIsolationMode,
|
||||||
containerRegistryRepository: Input.containerRegistryRepository,
|
containerRegistryRepository: Input.containerRegistryRepository,
|
||||||
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
||||||
providerStrategy: OrchestratorOptions.providerStrategy,
|
providerStrategy,
|
||||||
buildPlatform: OrchestratorOptions.buildPlatform,
|
buildPlatform: providerStrategy !== 'local' ? 'linux' : process.platform,
|
||||||
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,
|
|
||||||
runNumber: Input.runNumber,
|
runNumber: Input.runNumber,
|
||||||
branch: Input.branch.replace('/head', '') || (await GitRepoReader.GetBranch()),
|
branch: Input.branch.replace('/head', '') || (await GitRepoReader.GetBranch()),
|
||||||
orchestratorBranch: OrchestratorOptions.orchestratorBranch.split('/').reverse()[0],
|
githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || 'game-ci/unity-builder',
|
||||||
orchestratorDebug: OrchestratorOptions.orchestratorDebug,
|
|
||||||
githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || OrchestratorOptions.orchestratorRepoName,
|
|
||||||
orchestratorRepoName: OrchestratorOptions.orchestratorRepoName,
|
|
||||||
cloneDepth: Number.parseInt(OrchestratorOptions.cloneDepth),
|
|
||||||
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,
|
|
||||||
gitSha: Input.gitSha,
|
gitSha: Input.gitSha,
|
||||||
logId: customAlphabet(OrchestratorConstants.alphabet, 9)(),
|
logId: customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 9)(),
|
||||||
buildGuid: OrchestratorBuildGuid.generateGuid(Input.runNumber, Input.targetPlatform),
|
buildGuid: `${Input.runNumber}-${Input.targetPlatform.toLowerCase().replace('standalone', '')}-${customAlphabet(
|
||||||
commandHooks: OrchestratorOptions.commandHooks,
|
'0123456789abcdefghijklmnopqrstuvwxyz',
|
||||||
inputPullCommand: OrchestratorOptions.inputPullCommand,
|
4,
|
||||||
pullInputList: OrchestratorOptions.pullInputList,
|
)()}`,
|
||||||
kubeStorageClass: OrchestratorOptions.kubeStorageClass,
|
isCliMode: Cli.isCliMode,
|
||||||
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,
|
|
||||||
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
|
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
|
||||||
unityHubVersionOnMac: Input.unityHubVersionOnMac,
|
unityHubVersionOnMac: Input.unityHubVersionOnMac,
|
||||||
dockerWorkspacePath: Input.dockerWorkspacePath,
|
dockerWorkspacePath: Input.dockerWorkspacePath,
|
||||||
hotRunnerEnabled: Input.hotRunnerEnabled,
|
|
||||||
hotRunnerTransport: Input.hotRunnerTransport,
|
|
||||||
hotRunnerHost: Input.hotRunnerHost,
|
|
||||||
hotRunnerPort: Input.hotRunnerPort,
|
|
||||||
hotRunnerHealthInterval: Input.hotRunnerHealthInterval,
|
|
||||||
hotRunnerMaxIdle: Input.hotRunnerMaxIdle,
|
|
||||||
hotRunnerFallbackToCold: Input.hotRunnerFallbackToCold,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { Command } from 'commander-ts';
|
import { Command } from 'commander-ts';
|
||||||
import { BuildParameters, Orchestrator, ImageTag, Input } from '..';
|
import { Input } from '..';
|
||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
import { ActionYamlReader } from '../input-readers/action-yaml';
|
import { ActionYamlReader } from '../input-readers/action-yaml';
|
||||||
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';
|
|
||||||
import OrchestratorQueryOverride from '../orchestrator/options/orchestrator-query-override';
|
|
||||||
import { CliFunction, CliFunctionsRepository } from './cli-functions-repository';
|
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 GitHub from '../github';
|
|
||||||
import { OptionValues } from 'commander';
|
import { OptionValues } from 'commander';
|
||||||
import { InputKey } from '../input';
|
import { InputKey } from '../input';
|
||||||
|
|
||||||
@@ -30,14 +23,13 @@ export class Cli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static InitCliMode() {
|
public static InitCliMode() {
|
||||||
CliFunctionsRepository.PushCliFunctionSource(RemoteClient);
|
|
||||||
CliFunctionsRepository.PushCliFunctionSource(Caching);
|
|
||||||
CliFunctionsRepository.PushCliFunctionSource(LfsHashing);
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.version('0.0.1');
|
program.version('0.0.1');
|
||||||
|
|
||||||
const properties = OrchestratorOptionsReader.GetProperties();
|
|
||||||
const actionYamlReader: ActionYamlReader = new ActionYamlReader();
|
const actionYamlReader: ActionYamlReader = new ActionYamlReader();
|
||||||
|
const properties = Object.getOwnPropertyNames(Input).filter(
|
||||||
|
(p) => p !== 'length' && p !== 'prototype' && p !== 'name',
|
||||||
|
);
|
||||||
for (const element of properties) {
|
for (const element of properties) {
|
||||||
program.option(`--${element} <${element}>`, actionYamlReader.GetActionYamlValue(element));
|
program.option(`--${element} <${element}>`, actionYamlReader.GetActionYamlValue(element));
|
||||||
}
|
}
|
||||||
@@ -53,6 +45,11 @@ export class Cli {
|
|||||||
program.option('--artifactName <artifactName>', 'caching artifact name');
|
program.option('--artifactName <artifactName>', 'caching artifact name');
|
||||||
program.option('--select <select>', 'select a particular resource');
|
program.option('--select <select>', 'select a particular resource');
|
||||||
program.option('--logFile <logFile>', 'output to log file (log stream only)');
|
program.option('--logFile <logFile>', 'output to log file (log stream only)');
|
||||||
|
program.option('--profilePath <profilePath>', 'path to submodule profile YAML');
|
||||||
|
program.option('--variantPath <variantPath>', 'path to submodule variant YAML');
|
||||||
|
program.option('--agentPath <agentPath>', 'path to custom LFS transfer agent');
|
||||||
|
program.option('--agentArgs <agentArgs>', 'arguments for custom LFS transfer agent');
|
||||||
|
program.option('--storagePaths <storagePaths>', 'semicolon-separated storage paths for LFS agent');
|
||||||
program.parse(process.argv);
|
program.parse(process.argv);
|
||||||
Cli.options = program.opts();
|
Cli.options = program.opts();
|
||||||
|
|
||||||
@@ -60,26 +57,15 @@ export class Cli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async RunCli(): Promise<void> {
|
static async RunCli(): Promise<void> {
|
||||||
GitHub.githubInputEnabled = false;
|
|
||||||
if (Cli.options!['populateOverride'] === `true`) {
|
|
||||||
await OrchestratorQueryOverride.PopulateQueryOverrideInput();
|
|
||||||
}
|
|
||||||
if (Cli.options!['logInput']) {
|
|
||||||
Cli.logInput();
|
|
||||||
}
|
|
||||||
const results = CliFunctionsRepository.GetCliFunctions(Cli.options?.mode);
|
const results = CliFunctionsRepository.GetCliFunctions(Cli.options?.mode);
|
||||||
OrchestratorLogger.log(`Entrypoint: ${results.key}`);
|
if (!results) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown CLI mode: ${Cli.options?.mode}. Orchestrator CLI features require @game-ci/orchestrator.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
core.info(`Entrypoint: ${results.key}`);
|
||||||
Cli.options!.versioning = 'None';
|
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)}
|
|
||||||
`);
|
|
||||||
Orchestrator.lockedWorkspace = process.env.LOCKED_WORKSPACE || ``;
|
|
||||||
OrchestratorLogger.log(`Locked Workspace: ${Orchestrator.lockedWorkspace}`);
|
|
||||||
await Orchestrator.setup(Orchestrator.buildParameters);
|
|
||||||
|
|
||||||
return await results.target[results.propertyKey](Cli.options);
|
return await results.target[results.propertyKey](Cli.options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +73,9 @@ export class Cli {
|
|||||||
private static logInput() {
|
private static logInput() {
|
||||||
core.info(`\n`);
|
core.info(`\n`);
|
||||||
core.info(`INPUT:`);
|
core.info(`INPUT:`);
|
||||||
const properties = OrchestratorOptionsReader.GetProperties();
|
const properties = Object.getOwnPropertyNames(Input).filter(
|
||||||
|
(p) => p !== 'length' && p !== 'prototype' && p !== 'name',
|
||||||
|
);
|
||||||
for (const element of properties) {
|
for (const element of properties) {
|
||||||
if (
|
if (
|
||||||
element in Input &&
|
element in Input &&
|
||||||
@@ -103,73 +91,4 @@ export class Cli {
|
|||||||
}
|
}
|
||||||
core.info(`\n`);
|
core.info(`\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@CliFunction(`cli-build`, `runs a orchestrator 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@CliFunction(`async-workflow`, `runs a orchestrator build`)
|
|
||||||
public static async asyncronousWorkflow(): Promise<string> {
|
|
||||||
const buildParameter = await BuildParameters.create();
|
|
||||||
const baseImage = new ImageTag(buildParameter);
|
|
||||||
await Orchestrator.setup(buildParameter);
|
|
||||||
|
|
||||||
return (await Orchestrator.run(buildParameter, baseImage.toString())).BuildResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
@CliFunction(`checks-update`, `runs a orchestrator build`)
|
|
||||||
public static async checksUpdate() {
|
|
||||||
const buildParameter = await BuildParameters.create();
|
|
||||||
|
|
||||||
await Orchestrator.setup(buildParameter);
|
|
||||||
const input = JSON.parse(process.env.CHECKS_UPDATE || ``);
|
|
||||||
core.info(`Checks Update ${process.env.CHECKS_UPDATE}`);
|
|
||||||
if (input.mode === `create`) {
|
|
||||||
throw new Error(`Not supported: only use update`);
|
|
||||||
} else if (input.mode === `update`) {
|
|
||||||
await GitHub.updateGitHubCheckRequest(input.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@CliFunction(`garbage-collect`, `runs garbage collection`)
|
|
||||||
public static async GarbageCollect(): Promise<string> {
|
|
||||||
const buildParameter = await BuildParameters.create();
|
|
||||||
|
|
||||||
await Orchestrator.setup(buildParameter);
|
|
||||||
|
|
||||||
return await Orchestrator.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));
|
|
||||||
|
|
||||||
return result.map((x) => x.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
@CliFunction(`list-worfklow`, `lists running workflows`)
|
|
||||||
public static async ListWorfklow(): Promise<string[]> {
|
|
||||||
const buildParameter = await BuildParameters.create();
|
|
||||||
|
|
||||||
await Orchestrator.setup(buildParameter);
|
|
||||||
|
|
||||||
return (await Orchestrator.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);
|
|
||||||
|
|
||||||
return await Orchestrator.Provider.watchWorkflow();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class Docker {
|
|||||||
const {
|
const {
|
||||||
workspace,
|
workspace,
|
||||||
actionFolder,
|
actionFolder,
|
||||||
|
useHostNetwork,
|
||||||
runnerTempPath,
|
runnerTempPath,
|
||||||
sshAgent,
|
sshAgent,
|
||||||
sshPublicKeysDirectoryPath,
|
sshPublicKeysDirectoryPath,
|
||||||
@@ -85,6 +86,7 @@ class Docker {
|
|||||||
: ''
|
: ''
|
||||||
} \
|
} \
|
||||||
${sshPublicKeysDirectoryPath ? `--volume ${sshPublicKeysDirectoryPath}:/root/.ssh:ro` : ''} \
|
${sshPublicKeysDirectoryPath ? `--volume ${sshPublicKeysDirectoryPath}:/root/.ssh:ro` : ''} \
|
||||||
|
${useHostNetwork ? '--net=host' : ''} \
|
||||||
${entrypointBash ? `--entrypoint ${commandPrefix}` : ``} \
|
${entrypointBash ? `--entrypoint ${commandPrefix}` : ``} \
|
||||||
${image} \
|
${image} \
|
||||||
${entrypointBash ? `-c` : `${commandPrefix} -c`} \
|
${entrypointBash ? `-c` : `${commandPrefix} -c`} \
|
||||||
|
|||||||
@@ -1,222 +1,5 @@
|
|||||||
import OrchestratorLogger from './orchestrator/services/core/orchestrator-logger';
|
|
||||||
import Orchestrator from './orchestrator/orchestrator';
|
|
||||||
import OrchestratorOptions from './orchestrator/options/orchestrator-options';
|
|
||||||
import * as core from '@actions/core';
|
|
||||||
import { Octokit } from '@octokit/core';
|
|
||||||
|
|
||||||
class GitHub {
|
class GitHub {
|
||||||
private static readonly asyncChecksApiWorkflowName = `Async Checks API`;
|
|
||||||
public static githubInputEnabled: boolean = true;
|
public static githubInputEnabled: boolean = true;
|
||||||
private static longDescriptionContent: string = ``;
|
|
||||||
private static startedDate: string;
|
|
||||||
private static endedDate: string;
|
|
||||||
static result: string = ``;
|
|
||||||
static forceAsyncTest: boolean;
|
|
||||||
private static get octokitDefaultToken() {
|
|
||||||
return new Octokit({
|
|
||||||
auth: process.env.GITHUB_TOKEN,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private static get octokitPAT() {
|
|
||||||
return new Octokit({
|
|
||||||
auth: Orchestrator.buildParameters.gitPrivateToken,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private static get sha() {
|
|
||||||
return Orchestrator.buildParameters.gitSha;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static get checkName() {
|
|
||||||
return `Orchestrator (${Orchestrator.buildParameters.buildGuid})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static get nameReadable() {
|
|
||||||
return GitHub.checkName;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static get checkRunId() {
|
|
||||||
return Orchestrator.buildParameters.githubCheckId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static get owner() {
|
|
||||||
return OrchestratorOptions.githubOwner;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static get repo() {
|
|
||||||
return OrchestratorOptions.githubRepoName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async createGitHubCheck(summary: string) {
|
|
||||||
if (!Orchestrator.buildParameters.githubChecks) {
|
|
||||||
return ``;
|
|
||||||
}
|
|
||||||
GitHub.startedDate = new Date().toISOString();
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Creating github check`);
|
|
||||||
const data = {
|
|
||||||
owner: GitHub.owner,
|
|
||||||
repo: GitHub.repo,
|
|
||||||
name: GitHub.checkName,
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
head_sha: GitHub.sha,
|
|
||||||
status: 'queued',
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
external_id: Orchestrator.buildParameters.buildGuid,
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
started_at: GitHub.startedDate,
|
|
||||||
output: {
|
|
||||||
title: GitHub.nameReadable,
|
|
||||||
summary,
|
|
||||||
text: '',
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
alt: 'Game-CI',
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
image_url: 'https://game.ci/assets/images/game-ci-brand-logo-wordmark.svg',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const result = await GitHub.createGitHubCheckRequest(data);
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Creating github check ${result.status}`);
|
|
||||||
|
|
||||||
return result.data.id.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async updateGitHubCheck(
|
|
||||||
longDescription: string,
|
|
||||||
summary: string,
|
|
||||||
result = `neutral`,
|
|
||||||
status = `in_progress`,
|
|
||||||
) {
|
|
||||||
if (`${Orchestrator.buildParameters.githubChecks}` !== `true`) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`githubChecks: ${Orchestrator.buildParameters.githubChecks} checkRunId: ${GitHub.checkRunId} sha: ${GitHub.sha} async: ${Orchestrator.isOrchestratorAsyncEnvironment}`,
|
|
||||||
);
|
|
||||||
GitHub.longDescriptionContent += `\n${longDescription}`;
|
|
||||||
if (GitHub.result !== `success` && GitHub.result !== `failure`) {
|
|
||||||
GitHub.result = result;
|
|
||||||
} else {
|
|
||||||
result = GitHub.result;
|
|
||||||
}
|
|
||||||
const data: any = {
|
|
||||||
owner: GitHub.owner,
|
|
||||||
repo: GitHub.repo,
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
check_run_id: GitHub.checkRunId,
|
|
||||||
name: GitHub.checkName,
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
head_sha: GitHub.sha,
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
started_at: GitHub.startedDate,
|
|
||||||
status,
|
|
||||||
output: {
|
|
||||||
title: GitHub.nameReadable,
|
|
||||||
summary,
|
|
||||||
text: GitHub.longDescriptionContent,
|
|
||||||
annotations: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status === `completed`) {
|
|
||||||
if (GitHub.endedDate !== undefined) {
|
|
||||||
GitHub.endedDate = new Date().toISOString();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
data.completed_at = GitHub.endedDate || GitHub.startedDate;
|
|
||||||
data.conclusion = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
await (Orchestrator.isOrchestratorAsyncEnvironment || GitHub.forceAsyncTest
|
|
||||||
? GitHub.runUpdateAsyncChecksWorkflow(data, `update`)
|
|
||||||
: GitHub.updateGitHubCheckRequest(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async updateGitHubCheckRequest(data: any) {
|
|
||||||
return await GitHub.octokitDefaultToken.request(`PATCH /repos/{owner}/{repo}/check-runs/{check_run_id}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async createGitHubCheckRequest(data: any) {
|
|
||||||
return await GitHub.octokitDefaultToken.request(`POST /repos/{owner}/{repo}/check-runs`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async runUpdateAsyncChecksWorkflow(data: any, mode: string) {
|
|
||||||
if (mode === `create`) {
|
|
||||||
throw new Error(`Not supported: only use update`);
|
|
||||||
}
|
|
||||||
const workflowsResult = await GitHub.octokitPAT.request(`GET /repos/{owner}/{repo}/actions/workflows`, {
|
|
||||||
owner: GitHub.owner,
|
|
||||||
repo: GitHub.repo,
|
|
||||||
});
|
|
||||||
const workflows = workflowsResult.data.workflows;
|
|
||||||
OrchestratorLogger.log(`Got ${workflows.length} workflows`);
|
|
||||||
let selectedId = ``;
|
|
||||||
for (let index = 0; index < workflowsResult.data.total_count; index++) {
|
|
||||||
if (workflows[index].name === GitHub.asyncChecksApiWorkflowName) {
|
|
||||||
selectedId = workflows[index].id.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (selectedId === ``) {
|
|
||||||
core.info(JSON.stringify(workflows));
|
|
||||||
throw new Error(`no workflow with name "${GitHub.asyncChecksApiWorkflowName}"`);
|
|
||||||
}
|
|
||||||
await GitHub.octokitPAT.request(`POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`, {
|
|
||||||
owner: GitHub.owner,
|
|
||||||
repo: GitHub.repo,
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
workflow_id: selectedId,
|
|
||||||
ref: OrchestratorOptions.branch,
|
|
||||||
inputs: {
|
|
||||||
checksObject: JSON.stringify({ data, mode }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async triggerWorkflowOnComplete(triggerWorkflowOnComplete: string[]) {
|
|
||||||
const isLocalAsync = Orchestrator.buildParameters.asyncWorkflow && !Orchestrator.isOrchestratorAsyncEnvironment;
|
|
||||||
if (isLocalAsync || triggerWorkflowOnComplete === undefined || triggerWorkflowOnComplete.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const workflowsResult = await GitHub.octokitPAT.request(`GET /repos/{owner}/{repo}/actions/workflows`, {
|
|
||||||
owner: GitHub.owner,
|
|
||||||
repo: GitHub.repo,
|
|
||||||
});
|
|
||||||
const workflows = workflowsResult.data.workflows;
|
|
||||||
OrchestratorLogger.log(`Got ${workflows.length} workflows`);
|
|
||||||
for (const element of triggerWorkflowOnComplete) {
|
|
||||||
let selectedId = ``;
|
|
||||||
for (let index = 0; index < workflowsResult.data.total_count; index++) {
|
|
||||||
if (workflows[index].name === element) {
|
|
||||||
selectedId = workflows[index].id.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (selectedId === ``) {
|
|
||||||
core.info(JSON.stringify(workflows));
|
|
||||||
throw new Error(`no workflow with name "${GitHub.asyncChecksApiWorkflowName}"`);
|
|
||||||
}
|
|
||||||
await GitHub.octokitPAT.request(`POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`, {
|
|
||||||
owner: GitHub.owner,
|
|
||||||
repo: GitHub.repo,
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
workflow_id: selectedId,
|
|
||||||
ref: OrchestratorOptions.branch,
|
|
||||||
inputs: {
|
|
||||||
buildGuid: Orchestrator.buildParameters.buildGuid,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
core.info(`github workflow complete hook not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async getCheckStatus() {
|
|
||||||
return await GitHub.octokitDefaultToken.request(`GET /repos/{owner}/{repo}/check-runs/{check_run_id}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default GitHub;
|
export default GitHub;
|
||||||
|
|||||||
@@ -9,22 +9,5 @@ import Platform from './platform';
|
|||||||
import Project from './project';
|
import Project from './project';
|
||||||
import Unity from './unity';
|
import Unity from './unity';
|
||||||
import Versioning from './versioning';
|
import Versioning from './versioning';
|
||||||
import Orchestrator from './orchestrator/orchestrator';
|
|
||||||
import loadProvider, { ProviderLoader } from './orchestrator/providers/provider-loader';
|
|
||||||
|
|
||||||
export {
|
export { Action, BuildParameters, Cache, Docker, Input, ImageTag, Output, Platform, Project, Unity, Versioning };
|
||||||
Action,
|
|
||||||
BuildParameters,
|
|
||||||
Cache,
|
|
||||||
Docker,
|
|
||||||
Input,
|
|
||||||
ImageTag,
|
|
||||||
Output,
|
|
||||||
Platform,
|
|
||||||
Project,
|
|
||||||
Unity,
|
|
||||||
Versioning,
|
|
||||||
Orchestrator as Orchestrator,
|
|
||||||
loadProvider,
|
|
||||||
ProviderLoader,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
|
import { exec } from 'node:child_process';
|
||||||
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
|
import Input from '../input';
|
||||||
|
|
||||||
export class GenericInputReader {
|
export class GenericInputReader {
|
||||||
public static async Run(command: string) {
|
public static async Run(command: string) {
|
||||||
if (OrchestratorOptions.providerStrategy === 'local') {
|
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return await OrchestratorSystem.Run(command, false, true);
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(stdout.toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { GitRepoReader } from './git-repo';
|
import { GitRepoReader } from './git-repo';
|
||||||
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
|
import Input from '../input';
|
||||||
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
|
|
||||||
|
|
||||||
describe(`git repo tests`, () => {
|
describe(`git repo tests`, () => {
|
||||||
it(`Branch value parsed from CLI to not contain illegal characters`, async () => {
|
it(`Branch value parsed from CLI to not contain illegal characters`, async () => {
|
||||||
@@ -10,15 +9,15 @@ describe(`git repo tests`, () => {
|
|||||||
|
|
||||||
it(`returns valid branch name when using https`, async () => {
|
it(`returns valid branch name when using https`, async () => {
|
||||||
const mockValue = 'https://github.com/example/example.git';
|
const mockValue = 'https://github.com/example/example.git';
|
||||||
await jest.spyOn(OrchestratorSystem, 'Run').mockReturnValue(Promise.resolve(mockValue));
|
jest.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
|
||||||
await jest.spyOn(OrchestratorOptions, 'providerStrategy', 'get').mockReturnValue('not-local');
|
jest.spyOn(Input, 'getInput').mockReturnValue('not-local');
|
||||||
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
|
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`returns valid branch name when using ssh`, async () => {
|
it(`returns valid branch name when using ssh`, async () => {
|
||||||
const mockValue = 'git@github.com:example/example.git';
|
const mockValue = 'git@github.com:example/example.git';
|
||||||
await jest.spyOn(OrchestratorSystem, 'Run').mockReturnValue(Promise.resolve(mockValue));
|
jest.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
|
||||||
await jest.spyOn(OrchestratorOptions, 'providerStrategy', 'get').mockReturnValue('not-local');
|
jest.spyOn(Input, 'getInput').mockReturnValue('not-local');
|
||||||
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
|
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,42 @@
|
|||||||
import { assert } from 'node:console';
|
import { assert } from 'node:console';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
|
import { exec } from 'node:child_process';
|
||||||
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';
|
import * as core from '@actions/core';
|
||||||
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
|
|
||||||
import Input from '../input';
|
import Input from '../input';
|
||||||
|
|
||||||
export class GitRepoReader {
|
export class GitRepoReader {
|
||||||
|
private static async runCommand(command: string): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(stdout.toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public static async GetRemote() {
|
public static async GetRemote() {
|
||||||
if (OrchestratorOptions.providerStrategy === 'local') {
|
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
assert(fs.existsSync(`.git`));
|
assert(fs.existsSync(`.git`));
|
||||||
const value = (await OrchestratorSystem.Run(`cd ${Input.projectPath} && git remote -v`, false, true)).replace(
|
const value = (await GitRepoReader.runCommand(`cd ${Input.projectPath} && git remote -v`)).replace(/ /g, ``);
|
||||||
/ /g,
|
core.info(`value ${value}`);
|
||||||
``,
|
|
||||||
);
|
|
||||||
OrchestratorLogger.log(`value ${value}`);
|
|
||||||
assert(value.includes('github.com'));
|
assert(value.includes('github.com'));
|
||||||
|
|
||||||
return value.split('github.com')[1].split('.git')[0].slice(1);
|
return value.split('github.com')[1].split('.git')[0].slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async GetBranch() {
|
public static async GetBranch() {
|
||||||
if (OrchestratorOptions.providerStrategy === 'local') {
|
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
assert(fs.existsSync(`.git`));
|
assert(fs.existsSync(`.git`));
|
||||||
|
|
||||||
return (await OrchestratorSystem.Run(`cd ${Input.projectPath} && git branch --show-current`, false, true))
|
return (await GitRepoReader.runCommand(`cd ${Input.projectPath} && git branch --show-current`))
|
||||||
.split('\n')[0]
|
.split('\n')[0]
|
||||||
.replace(/ /g, ``)
|
.replace(/ /g, ``)
|
||||||
.replace('/head', '');
|
.replace('/head', '');
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
|
import { exec } from 'node:child_process';
|
||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
|
import Input from '../input';
|
||||||
|
|
||||||
export class GithubCliReader {
|
export class GithubCliReader {
|
||||||
|
private static async runCommand(command: string, suppressError = false): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout, stderr) => {
|
||||||
|
if (error && !suppressError) {
|
||||||
|
reject(error);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve((stdout || '').toString() + (stderr || '').toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static async GetGitHubAuthToken() {
|
static async GetGitHubAuthToken() {
|
||||||
if (OrchestratorOptions.providerStrategy === 'local') {
|
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const authStatus = await OrchestratorSystem.Run(`gh auth status`, true, true);
|
const authStatus = await GithubCliReader.runCommand(`gh auth status`, true);
|
||||||
if (authStatus.includes('You are not logged') || authStatus === '') {
|
if (authStatus.includes('You are not logged') || authStatus === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await OrchestratorSystem.Run(`gh auth status -t`, false, true))
|
return (await GithubCliReader.runCommand(`gh auth status -t`))
|
||||||
.split(`Token: `)[1]
|
.split(`Token: `)[1]
|
||||||
.replace(/ /g, '')
|
.replace(/ /g, '')
|
||||||
.replace(/\n/g, '');
|
.replace(/\n/g, '');
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
|
import Input from '../input';
|
||||||
|
|
||||||
export function ReadLicense(): string {
|
export function ReadLicense(): string {
|
||||||
if (OrchestratorOptions.providerStrategy === 'local') {
|
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const pipelineFile = path.join(__dirname, `.github`, `workflows`, `orchestrator-k8s-pipeline.yml`);
|
const pipelineFile = path.join(__dirname, `.github`, `workflows`, `orchestrator-k8s-pipeline.yml`);
|
||||||
|
|||||||
@@ -334,4 +334,22 @@ describe('Input', () => {
|
|||||||
expect(spy).toHaveBeenCalledTimes(1);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('useHostNetwork', () => {
|
||||||
|
it('returns the default value', () => {
|
||||||
|
expect(Input.useHostNetwork).toStrictEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when string true is passed', () => {
|
||||||
|
const spy = jest.spyOn(core, 'getInput').mockReturnValue('true');
|
||||||
|
expect(Input.useHostNetwork).toStrictEqual(true);
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when string false is passed', () => {
|
||||||
|
const spy = jest.spyOn(core, 'getInput').mockReturnValue('false');
|
||||||
|
expect(Input.useHostNetwork).toStrictEqual(false);
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { Cli } from './cli/cli';
|
import { Cli } from './cli/cli';
|
||||||
import OrchestratorQueryOverride from './orchestrator/options/orchestrator-query-override';
|
|
||||||
import Platform from './platform';
|
import Platform from './platform';
|
||||||
import GitHub from './github';
|
import GitHub from './github';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
@@ -15,7 +14,8 @@ export type InputKey = keyof typeof Input;
|
|||||||
*
|
*
|
||||||
* Note that input is always passed as a string, even booleans.
|
* Note that input is always passed as a string, even booleans.
|
||||||
*
|
*
|
||||||
* Todo: rename to UserInput and remove anything that is not direct input from the user / ci workflow
|
* Only core build inputs belong here. Orchestrator/plugin inputs are read
|
||||||
|
* directly by the @game-ci/orchestrator plugin via core.getInput() / env vars.
|
||||||
*/
|
*/
|
||||||
class Input {
|
class Input {
|
||||||
public static getInput(query: string): string | undefined {
|
public static getInput(query: string): string | undefined {
|
||||||
@@ -32,10 +32,6 @@ class Input {
|
|||||||
return Cli.query(query, alternativeQuery);
|
return Cli.query(query, alternativeQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (OrchestratorQueryOverride.query(query, alternativeQuery)) {
|
|
||||||
return OrchestratorQueryOverride.query(query, alternativeQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env[query] !== undefined) {
|
if (process.env[query] !== undefined) {
|
||||||
return process.env[query]!;
|
return process.env[query]!;
|
||||||
}
|
}
|
||||||
@@ -45,10 +41,6 @@ class Input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static get region(): string {
|
|
||||||
return Input.getInput('region') ?? 'eu-west-2';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get githubRepo(): string | undefined {
|
static get githubRepo(): string | undefined {
|
||||||
return Input.getInput('GITHUB_REPOSITORY') ?? Input.getInput('GITHUB_REPO') ?? undefined;
|
return Input.getInput('GITHUB_REPOSITORY') ?? Input.getInput('GITHUB_REPO') ?? undefined;
|
||||||
}
|
}
|
||||||
@@ -147,6 +139,12 @@ class Input {
|
|||||||
return Input.getInput('customParameters') ?? '';
|
return Input.getInput('customParameters') ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get useHostNetwork(): boolean {
|
||||||
|
const input = Input.getInput('useHostNetwork') ?? false;
|
||||||
|
|
||||||
|
return input === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
static get versioningStrategy(): string {
|
static get versioningStrategy(): string {
|
||||||
return Input.getInput('versioning') ?? 'Semantic';
|
return Input.getInput('versioning') ?? 'Semantic';
|
||||||
}
|
}
|
||||||
@@ -282,38 +280,6 @@ class Input {
|
|||||||
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
|
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get hotRunnerEnabled(): boolean {
|
|
||||||
const input = Input.getInput('hotRunnerEnabled') ?? false;
|
|
||||||
|
|
||||||
return input === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get hotRunnerTransport(): 'websocket' | 'grpc' | 'named-pipe' {
|
|
||||||
return (Input.getInput('hotRunnerTransport') ?? 'websocket') as 'websocket' | 'grpc' | 'named-pipe';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get hotRunnerHost(): string {
|
|
||||||
return Input.getInput('hotRunnerHost') ?? 'localhost';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get hotRunnerPort(): number {
|
|
||||||
return Number.parseInt(Input.getInput('hotRunnerPort') ?? '9090', 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
static get hotRunnerHealthInterval(): number {
|
|
||||||
return Number.parseInt(Input.getInput('hotRunnerHealthInterval') ?? '30', 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
static get hotRunnerMaxIdle(): number {
|
|
||||||
return Number.parseInt(Input.getInput('hotRunnerMaxIdle') ?? '3600', 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
static get hotRunnerFallbackToCold(): boolean {
|
|
||||||
const input = Input.getInput('hotRunnerFallbackToCold') ?? 'true';
|
|
||||||
|
|
||||||
return input === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ToEnvVarFormat(input: string) {
|
public static ToEnvVarFormat(input: string) {
|
||||||
if (input.toUpperCase() === input) {
|
if (input.toUpperCase() === input) {
|
||||||
return input;
|
return input;
|
||||||
|
|||||||
129
src/model/orchestrator-plugin.test.ts
Normal file
129
src/model/orchestrator-plugin.test.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the orchestrator plugin loader (orchestrator-plugin.ts).
|
||||||
|
*
|
||||||
|
* The plugin loader dynamically imports @game-ci/orchestrator and calls
|
||||||
|
* createPlugin(). Two scenarios:
|
||||||
|
*
|
||||||
|
* 1. Package NOT installed — loadOrchestratorPlugin() returns undefined.
|
||||||
|
* 2. Package IS installed — returns the plugin from createPlugin().
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mockWarning = jest.fn();
|
||||||
|
const mockInfo = jest.fn();
|
||||||
|
jest.mock('@actions/core', () => ({
|
||||||
|
warning: mockWarning,
|
||||||
|
info: mockInfo,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
mockWarning.mockClear();
|
||||||
|
mockInfo.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Part 1: Package NOT installed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('orchestrator-plugin (package not installed)', () => {
|
||||||
|
it('loadOrchestratorPlugin() returns undefined', async () => {
|
||||||
|
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
|
||||||
|
|
||||||
|
const result = await loadOrchestratorPlugin();
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Part 2: Package IS installed (mocked)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('orchestrator-plugin (package installed)', () => {
|
||||||
|
const fakePlugin = {
|
||||||
|
initialize: jest.fn(),
|
||||||
|
canHandleBuild: jest.fn().mockReturnValue(false),
|
||||||
|
handleBuild: jest.fn().mockResolvedValue({ exitCode: 0 }),
|
||||||
|
beforeLocalBuild: jest.fn(),
|
||||||
|
afterLocalBuild: jest.fn(),
|
||||||
|
handlePostBuild: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCreatePlugin = jest.fn().mockReturnValue(fakePlugin);
|
||||||
|
|
||||||
|
function installOrchestratorMock(overrides: Record<string, unknown> = {}) {
|
||||||
|
jest.doMock(
|
||||||
|
'@game-ci/orchestrator',
|
||||||
|
() => ({
|
||||||
|
createPlugin: mockCreatePlugin,
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
{ virtual: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCreatePlugin.mockClear();
|
||||||
|
fakePlugin.initialize.mockClear();
|
||||||
|
fakePlugin.canHandleBuild.mockClear();
|
||||||
|
fakePlugin.handleBuild.mockClear();
|
||||||
|
fakePlugin.beforeLocalBuild.mockClear();
|
||||||
|
fakePlugin.afterLocalBuild.mockClear();
|
||||||
|
fakePlugin.handlePostBuild.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the plugin from createPlugin()', async () => {
|
||||||
|
installOrchestratorMock();
|
||||||
|
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
|
||||||
|
|
||||||
|
const plugin = await loadOrchestratorPlugin();
|
||||||
|
|
||||||
|
expect(plugin).toBeDefined();
|
||||||
|
expect(mockCreatePlugin).toHaveBeenCalledTimes(1);
|
||||||
|
expect(plugin).toBe(fakePlugin);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the plugin with all lifecycle methods', async () => {
|
||||||
|
installOrchestratorMock();
|
||||||
|
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
|
||||||
|
|
||||||
|
const plugin = await loadOrchestratorPlugin();
|
||||||
|
|
||||||
|
expect(typeof plugin!.initialize).toBe('function');
|
||||||
|
expect(typeof plugin!.canHandleBuild).toBe('function');
|
||||||
|
expect(typeof plugin!.handleBuild).toBe('function');
|
||||||
|
expect(typeof plugin!.beforeLocalBuild).toBe('function');
|
||||||
|
expect(typeof plugin!.afterLocalBuild).toBe('function');
|
||||||
|
expect(typeof plugin!.handlePostBuild).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined and warns when createPlugin is not a function', async () => {
|
||||||
|
installOrchestratorMock({ createPlugin: undefined });
|
||||||
|
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
|
||||||
|
|
||||||
|
const plugin = await loadOrchestratorPlugin();
|
||||||
|
|
||||||
|
expect(plugin).toBeUndefined();
|
||||||
|
expect(mockWarning).toHaveBeenCalledWith(expect.stringContaining('does not export createPlugin'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Error handling
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('propagates non-MODULE_NOT_FOUND errors', async () => {
|
||||||
|
const importError = new Error('Syntax error in module');
|
||||||
|
jest.doMock(
|
||||||
|
'@game-ci/orchestrator',
|
||||||
|
() => {
|
||||||
|
throw importError;
|
||||||
|
},
|
||||||
|
{ virtual: true },
|
||||||
|
);
|
||||||
|
const { loadOrchestratorPlugin } = await import('./orchestrator-plugin');
|
||||||
|
|
||||||
|
await expect(loadOrchestratorPlugin()).rejects.toThrow('Syntax error in module');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
73
src/model/orchestrator-plugin.ts
Normal file
73
src/model/orchestrator-plugin.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import * as core from '@actions/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle interface for the orchestrator plugin.
|
||||||
|
*
|
||||||
|
* The orchestrator reads its own configuration from environment variables
|
||||||
|
* and GitHub Actions inputs. Unity-builder only calls these lifecycle hooks
|
||||||
|
* at the appropriate times — it never needs to know individual plugin params.
|
||||||
|
*/
|
||||||
|
export interface OrchestratorPlugin {
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
initialize(coreParameters: Record<string, any>, workspace: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Whether the plugin wants to handle the entire build (remote, hot runner, test workflow). */
|
||||||
|
canHandleBuild(): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the build when canHandleBuild() returns true.
|
||||||
|
* If the plugin needs to fall back to a local build (e.g. hot runner failure),
|
||||||
|
* it returns { exitCode: -1, fallbackToLocal: true }.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
handleBuild(baseImage: string): Promise<{ exitCode: number; fallbackToLocal?: boolean }>;
|
||||||
|
|
||||||
|
/** Pre-build hook for local builds (cache restore, git hooks, sync, etc.). */
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
beforeLocalBuild(workspace: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Post-build hook for local builds (cache save, workspace save, etc.). */
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
afterLocalBuild(workspace: string, exitCode: number): Promise<void>;
|
||||||
|
|
||||||
|
/** Post-build hook for all build types (archiving, artifacts, etc.). */
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
handlePostBuild(exitCode: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to load the orchestrator plugin.
|
||||||
|
* Returns undefined if @game-ci/orchestrator is not installed.
|
||||||
|
*/
|
||||||
|
export async function loadOrchestratorPlugin(): Promise<OrchestratorPlugin | undefined> {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line import/no-unresolved
|
||||||
|
const orchestratorModule = await import('@game-ci/orchestrator');
|
||||||
|
|
||||||
|
if (typeof orchestratorModule.createPlugin !== 'function') {
|
||||||
|
core.warning(
|
||||||
|
'Orchestrator package found but does not export createPlugin(). ' +
|
||||||
|
'Update @game-ci/orchestrator to the latest version.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return orchestratorModule.createPlugin();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isModuleNotFoundError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModuleNotFoundError(error: unknown): boolean {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error) {
|
||||||
|
const code = (error as { code: string }).code;
|
||||||
|
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof (error as Error)?.message === 'string' && /cannot find module/i.test((error as Error).message);
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import OrchestratorLogger from '../services/core/orchestrator-logger';
|
|
||||||
import * as core from '@actions/core';
|
|
||||||
import Orchestrator from '../orchestrator';
|
|
||||||
import OrchestratorSecret from '../options/orchestrator-secret';
|
|
||||||
import BuildParameters from '../../build-parameters';
|
|
||||||
|
|
||||||
export class OrchestratorError {
|
|
||||||
public static async handleException(error: unknown, buildParameters: BuildParameters, secrets: OrchestratorSecret[]) {
|
|
||||||
OrchestratorLogger.error(JSON.stringify(error, undefined, 4));
|
|
||||||
core.setFailed('Orchestrator failed');
|
|
||||||
if (Orchestrator.Provider !== undefined) {
|
|
||||||
await Orchestrator.Provider.cleanupWorkflow(buildParameters, buildParameters.branch, secrets);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
class OrchestratorConstants {
|
|
||||||
static alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
||||||
}
|
|
||||||
export default OrchestratorConstants;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
class OrchestratorEnvironmentVariable {
|
|
||||||
public name!: string;
|
|
||||||
public value!: string;
|
|
||||||
}
|
|
||||||
export default OrchestratorEnvironmentVariable;
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import OrchestratorOptions from './orchestrator-options';
|
|
||||||
import Orchestrator from '../orchestrator';
|
|
||||||
import BuildParameters from '../../build-parameters';
|
|
||||||
|
|
||||||
export class OrchestratorFolders {
|
|
||||||
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 uniqueOrchestratorJobFolderAbsolute(): string {
|
|
||||||
return Orchestrator.buildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(Orchestrator.buildParameters)
|
|
||||||
? path.join(`/`, OrchestratorFolders.buildVolumeFolder, Orchestrator.lockedWorkspace)
|
|
||||||
: path.join(`/`, OrchestratorFolders.buildVolumeFolder, Orchestrator.buildParameters.buildGuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get cacheFolderForAllFull(): string {
|
|
||||||
return path.join('/', OrchestratorFolders.buildVolumeFolder, OrchestratorFolders.cacheFolder);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get cacheFolderForCacheKeyFull(): string {
|
|
||||||
return path.join(
|
|
||||||
'/',
|
|
||||||
OrchestratorFolders.buildVolumeFolder,
|
|
||||||
OrchestratorFolders.cacheFolder,
|
|
||||||
Orchestrator.buildParameters.cacheKey,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get builderPathAbsolute(): string {
|
|
||||||
return path.join(
|
|
||||||
OrchestratorOptions.useSharedBuilder
|
|
||||||
? `/${OrchestratorFolders.buildVolumeFolder}`
|
|
||||||
: OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute,
|
|
||||||
`builder`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get repoPathAbsolute(): string {
|
|
||||||
return path.join(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute, OrchestratorFolders.repositoryFolder);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get projectPathAbsolute(): string {
|
|
||||||
return path.join(OrchestratorFolders.repoPathAbsolute, Orchestrator.buildParameters.projectPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get libraryFolderAbsolute(): string {
|
|
||||||
return path.join(OrchestratorFolders.projectPathAbsolute, `Library`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get projectBuildFolderAbsolute(): string {
|
|
||||||
return path.join(OrchestratorFolders.repoPathAbsolute, Orchestrator.buildParameters.buildPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get lfsFolderAbsolute(): string {
|
|
||||||
return path.join(OrchestratorFolders.repoPathAbsolute, `.git`, `lfs`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get purgeRemoteCaching(): boolean {
|
|
||||||
return process.env.PURGE_REMOTE_BUILDER_CACHE !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get lfsCacheFolderFull() {
|
|
||||||
return path.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `lfs`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get libraryCacheFolderFull() {
|
|
||||||
return path.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `Library`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get unityBuilderRepoUrl(): string {
|
|
||||||
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get targetBuildRepoUrl(): string {
|
|
||||||
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.githubRepo}.git`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get buildVolumeFolder() {
|
|
||||||
return 'data';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get cacheFolder() {
|
|
||||||
return 'cache';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { customAlphabet } from 'nanoid';
|
|
||||||
import OrchestratorConstants from './orchestrator-constants';
|
|
||||||
|
|
||||||
class OrchestratorNamespace {
|
|
||||||
static generateGuid(runNumber: string | number, platform: string) {
|
|
||||||
const nanoid = customAlphabet(OrchestratorConstants.alphabet, 4);
|
|
||||||
|
|
||||||
return `${runNumber}-${platform.toLowerCase().replace('standalone', '')}-${nanoid()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default OrchestratorNamespace;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import Input from '../../input';
|
|
||||||
import OrchestratorOptions from './orchestrator-options';
|
|
||||||
|
|
||||||
class OrchestratorOptionsReader {
|
|
||||||
static GetProperties() {
|
|
||||||
return [...Object.getOwnPropertyNames(Input), ...Object.getOwnPropertyNames(OrchestratorOptions)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default OrchestratorOptionsReader;
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
import { Cli } from '../../cli/cli';
|
|
||||||
import OrchestratorQueryOverride from './orchestrator-query-override';
|
|
||||||
import GitHub from '../../github';
|
|
||||||
import * as core from '@actions/core';
|
|
||||||
|
|
||||||
class OrchestratorOptions {
|
|
||||||
// ### ### ###
|
|
||||||
// Input Handling
|
|
||||||
// ### ### ###
|
|
||||||
public static getInput(query: string): string | undefined {
|
|
||||||
if (GitHub.githubInputEnabled) {
|
|
||||||
const coreInput = core.getInput(query);
|
|
||||||
if (coreInput && coreInput !== '') {
|
|
||||||
return coreInput;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const alternativeQuery = OrchestratorOptions.ToEnvVarFormat(query);
|
|
||||||
|
|
||||||
// Query input sources
|
|
||||||
if (Cli.query(query, alternativeQuery)) {
|
|
||||||
return Cli.query(query, alternativeQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (OrchestratorQueryOverride.query(query, alternativeQuery)) {
|
|
||||||
return OrchestratorQueryOverride.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 OrchestratorOptions.getInput('region') || 'eu-west-2';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
|
||||||
// GitHub parameters
|
|
||||||
// ### ### ###
|
|
||||||
static get githubChecks(): boolean {
|
|
||||||
const value = OrchestratorOptions.getInput('githubChecks');
|
|
||||||
|
|
||||||
return value === `true` || false;
|
|
||||||
}
|
|
||||||
static get githubCheckId(): string {
|
|
||||||
return OrchestratorOptions.getInput('githubCheckId') || ``;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get githubOwner(): string {
|
|
||||||
return OrchestratorOptions.getInput('githubOwner') || OrchestratorOptions.githubRepo?.split(`/`)[0] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get githubRepoName(): string {
|
|
||||||
return OrchestratorOptions.getInput('githubRepoName') || OrchestratorOptions.githubRepo?.split(`/`)[1] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get orchestratorRepoName(): string {
|
|
||||||
return OrchestratorOptions.getInput('orchestratorRepoName') || 'game-ci/unity-builder';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get cloneDepth(): string {
|
|
||||||
return OrchestratorOptions.getInput('cloneDepth') || '50';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get finalHooks(): string[] {
|
|
||||||
return OrchestratorOptions.getInput('finalHooks')?.split(',') || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
|
||||||
// Git syncronization parameters
|
|
||||||
// ### ### ###
|
|
||||||
|
|
||||||
static get githubRepo(): string | undefined {
|
|
||||||
return (
|
|
||||||
OrchestratorOptions.getInput('GITHUB_REPOSITORY') || OrchestratorOptions.getInput('GITHUB_REPO') || undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
static get branch(): string {
|
|
||||||
if (OrchestratorOptions.getInput(`GITHUB_REF`)) {
|
|
||||||
return (
|
|
||||||
OrchestratorOptions.getInput(`GITHUB_REF`)?.replace('refs/', '').replace(`head/`, '').replace(`heads/`, '') ||
|
|
||||||
``
|
|
||||||
);
|
|
||||||
} else if (OrchestratorOptions.getInput('branch')) {
|
|
||||||
return OrchestratorOptions.getInput('branch') || ``;
|
|
||||||
} else {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
|
||||||
// Orchestrator parameters
|
|
||||||
// ### ### ###
|
|
||||||
|
|
||||||
static get buildPlatform(): string {
|
|
||||||
const input = OrchestratorOptions.getInput('buildPlatform');
|
|
||||||
if (input && input !== '') {
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
if (OrchestratorOptions.providerStrategy !== 'local') {
|
|
||||||
return 'linux';
|
|
||||||
}
|
|
||||||
|
|
||||||
return process.platform;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get orchestratorBranch(): string {
|
|
||||||
return OrchestratorOptions.getInput('orchestratorBranch') || 'main';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get providerStrategy(): string {
|
|
||||||
const provider =
|
|
||||||
OrchestratorOptions.getInput('orchestratorCluster') || OrchestratorOptions.getInput('providerStrategy');
|
|
||||||
if (Cli.isCliMode) {
|
|
||||||
return provider || 'aws';
|
|
||||||
}
|
|
||||||
|
|
||||||
return provider || 'local';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get containerCpu(): string {
|
|
||||||
return OrchestratorOptions.getInput('containerCpu') || `1024`;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get containerMemory(): string {
|
|
||||||
return OrchestratorOptions.getInput('containerMemory') || `3072`;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get containerNamespace(): string {
|
|
||||||
return OrchestratorOptions.getInput('containerNamespace') || `default`;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get customJob(): string {
|
|
||||||
return OrchestratorOptions.getInput('customJob') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
|
||||||
// Custom commands from files parameters
|
|
||||||
// ### ### ###
|
|
||||||
|
|
||||||
static get containerHookFiles(): string[] {
|
|
||||||
return OrchestratorOptions.getInput('containerHookFiles')?.split(`,`) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
static get commandHookFiles(): string[] {
|
|
||||||
return OrchestratorOptions.getInput('commandHookFiles')?.split(`,`) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
|
||||||
// Custom commands from yaml parameters
|
|
||||||
// ### ### ###
|
|
||||||
|
|
||||||
static get commandHooks(): string {
|
|
||||||
return OrchestratorOptions.getInput('commandHooks') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get postBuildContainerHooks(): string {
|
|
||||||
return OrchestratorOptions.getInput('postBuildContainerHooks') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get preBuildContainerHooks(): string {
|
|
||||||
return OrchestratorOptions.getInput('preBuildContainerHooks') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
|
||||||
// Input override handling
|
|
||||||
// ### ### ###
|
|
||||||
|
|
||||||
static get pullInputList(): string[] {
|
|
||||||
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
static get inputPullCommand(): string {
|
|
||||||
const value = OrchestratorOptions.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 OrchestratorOptions.getInput('awsStackName') || 'game-ci';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get awsEndpoint(): string | undefined {
|
|
||||||
return OrchestratorOptions.getInput('awsEndpoint');
|
|
||||||
}
|
|
||||||
|
|
||||||
static get awsCloudFormationEndpoint(): string | undefined {
|
|
||||||
return OrchestratorOptions.getInput('awsCloudFormationEndpoint') || OrchestratorOptions.awsEndpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get awsEcsEndpoint(): string | undefined {
|
|
||||||
return OrchestratorOptions.getInput('awsEcsEndpoint') || OrchestratorOptions.awsEndpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get awsKinesisEndpoint(): string | undefined {
|
|
||||||
return OrchestratorOptions.getInput('awsKinesisEndpoint') || OrchestratorOptions.awsEndpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get awsCloudWatchLogsEndpoint(): string | undefined {
|
|
||||||
return OrchestratorOptions.getInput('awsCloudWatchLogsEndpoint') || OrchestratorOptions.awsEndpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get awsS3Endpoint(): string | undefined {
|
|
||||||
return OrchestratorOptions.getInput('awsS3Endpoint') || OrchestratorOptions.awsEndpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
|
||||||
// Storage
|
|
||||||
// ### ### ###
|
|
||||||
|
|
||||||
static get storageProvider(): string {
|
|
||||||
return OrchestratorOptions.getInput('storageProvider') || 's3';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get rcloneRemote(): string {
|
|
||||||
return OrchestratorOptions.getInput('rcloneRemote') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
|
||||||
// K8s
|
|
||||||
// ### ### ###
|
|
||||||
|
|
||||||
static get kubeConfig(): string {
|
|
||||||
return OrchestratorOptions.getInput('kubeConfig') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get kubeVolume(): string {
|
|
||||||
return OrchestratorOptions.getInput('kubeVolume') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get kubeVolumeSize(): string {
|
|
||||||
return OrchestratorOptions.getInput('kubeVolumeSize') || '25Gi';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get kubeStorageClass(): string {
|
|
||||||
return OrchestratorOptions.getInput('kubeStorageClass') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
|
||||||
// Caching
|
|
||||||
// ### ### ###
|
|
||||||
|
|
||||||
static get cacheKey(): string {
|
|
||||||
return OrchestratorOptions.getInput('cacheKey') || OrchestratorOptions.branch;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
|
||||||
// Utility Parameters
|
|
||||||
// ### ### ###
|
|
||||||
|
|
||||||
static get orchestratorDebug(): boolean {
|
|
||||||
return (
|
|
||||||
OrchestratorOptions.getInput(`orchestratorTests`) === `true` ||
|
|
||||||
OrchestratorOptions.getInput(`orchestratorDebug`) === `true` ||
|
|
||||||
OrchestratorOptions.getInput(`orchestratorDebugTree`) === `true` ||
|
|
||||||
OrchestratorOptions.getInput(`orchestratorDebugEnv`) === `true` ||
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
static get skipLfs(): boolean {
|
|
||||||
return OrchestratorOptions.getInput(`skipLfs`) === `true`;
|
|
||||||
}
|
|
||||||
static get skipCache(): boolean {
|
|
||||||
return OrchestratorOptions.getInput(`skipCache`) === `true`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get asyncOrchestrator(): boolean {
|
|
||||||
return OrchestratorOptions.getInput('asyncOrchestrator') === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get resourceTracking(): boolean {
|
|
||||||
return OrchestratorOptions.getInput('resourceTracking') === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get useLargePackages(): boolean {
|
|
||||||
return OrchestratorOptions.getInput(`useLargePackages`) === `true`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get useSharedBuilder(): boolean {
|
|
||||||
return OrchestratorOptions.getInput(`useSharedBuilder`) === `true`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get useCompressionStrategy(): boolean {
|
|
||||||
return OrchestratorOptions.getInput(`useCompressionStrategy`) === `true`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get useCleanupCron(): boolean {
|
|
||||||
return (OrchestratorOptions.getInput(`useCleanupCron`) || 'true') === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
|
||||||
// Retained Workspace
|
|
||||||
// ### ### ###
|
|
||||||
|
|
||||||
public static get maxRetainedWorkspaces(): string {
|
|
||||||
return OrchestratorOptions.getInput(`maxRetainedWorkspaces`) || `0`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
|
||||||
// Garbage Collection
|
|
||||||
// ### ### ###
|
|
||||||
|
|
||||||
static get garbageMaxAge(): number {
|
|
||||||
return Number(OrchestratorOptions.getInput(`garbageMaxAge`)) || 24;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default OrchestratorOptions;
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import Input from '../../input';
|
|
||||||
import { GenericInputReader } from '../../input-readers/generic-input-reader';
|
|
||||||
import OrchestratorOptions from './orchestrator-options';
|
|
||||||
|
|
||||||
const formatFunction = (value: string, arguments_: any[]) => {
|
|
||||||
for (const element of arguments_) {
|
|
||||||
value = value.replace(`{${element.key}}`, element.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
class OrchestratorQueryOverride {
|
|
||||||
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 (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) {
|
|
||||||
return OrchestratorQueryOverride.queryOverrides[key];
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
OrchestratorQueryOverride.queryOverrides &&
|
|
||||||
alternativeKey &&
|
|
||||||
OrchestratorQueryOverride.queryOverrides[alternativeKey] !== undefined
|
|
||||||
) {
|
|
||||||
return OrchestratorQueryOverride.queryOverrides[alternativeKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static shouldUseOverride(query: string) {
|
|
||||||
if (OrchestratorOptions.inputPullCommand !== '') {
|
|
||||||
if (OrchestratorOptions.pullInputList.length > 0) {
|
|
||||||
const doesInclude =
|
|
||||||
OrchestratorOptions.pullInputList.includes(query) ||
|
|
||||||
OrchestratorOptions.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(OrchestratorOptions.inputPullCommand, [{ key: 0, value: query }]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async PopulateQueryOverrideInput() {
|
|
||||||
const queries = OrchestratorOptions.pullInputList;
|
|
||||||
OrchestratorQueryOverride.queryOverrides = {};
|
|
||||||
for (const element of queries) {
|
|
||||||
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
|
|
||||||
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default OrchestratorQueryOverride;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
class OrchestratorSecret {
|
|
||||||
public ParameterKey!: string;
|
|
||||||
public EnvironmentVariable!: string;
|
|
||||||
public ParameterValue!: string;
|
|
||||||
}
|
|
||||||
export default OrchestratorSecret;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export class OrchestratorStatics {
|
|
||||||
public static readonly logPrefix = `Orchestrator`;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import OrchestratorEnvironmentVariable from './orchestrator-environment-variable';
|
|
||||||
import OrchestratorSecret from './orchestrator-secret';
|
|
||||||
|
|
||||||
export class OrchestratorStepParameters {
|
|
||||||
public image: string;
|
|
||||||
public environment: OrchestratorEnvironmentVariable[];
|
|
||||||
public secrets: OrchestratorSecret[];
|
|
||||||
constructor(image: string, environmentVariables: OrchestratorEnvironmentVariable[], secrets: OrchestratorSecret[]) {
|
|
||||||
this.image = image;
|
|
||||||
this.environment = environmentVariables;
|
|
||||||
this.secrets = secrets;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
import AwsBuildPlatform from './providers/aws';
|
|
||||||
import { BuildParameters, Input } from '..';
|
|
||||||
import Kubernetes from './providers/k8s';
|
|
||||||
import OrchestratorLogger from './services/core/orchestrator-logger';
|
|
||||||
import { OrchestratorStepParameters } from './options/orchestrator-step-parameters';
|
|
||||||
import { WorkflowCompositionRoot } from './workflows/workflow-composition-root';
|
|
||||||
import { OrchestratorError } from './error/orchestrator-error';
|
|
||||||
import { TaskParameterSerializer } from './services/core/task-parameter-serializer';
|
|
||||||
import * as core from '@actions/core';
|
|
||||||
import OrchestratorSecret from './options/orchestrator-secret';
|
|
||||||
import { ProviderInterface } from './providers/provider-interface';
|
|
||||||
import OrchestratorEnvironmentVariable from './options/orchestrator-environment-variable';
|
|
||||||
import TestOrchestrator from './providers/test';
|
|
||||||
import LocalOrchestrator from './providers/local';
|
|
||||||
import LocalDockerOrchestrator from './providers/docker';
|
|
||||||
import loadProvider from './providers/provider-loader';
|
|
||||||
import GitHub from '../github';
|
|
||||||
import SharedWorkspaceLocking from './services/core/shared-workspace-locking';
|
|
||||||
import { FollowLogStreamService } from './services/core/follow-log-stream-service';
|
|
||||||
import OrchestratorResult from './services/core/orchestrator-result';
|
|
||||||
import OrchestratorOptions from './options/orchestrator-options';
|
|
||||||
import ResourceTracking from './services/core/resource-tracking';
|
|
||||||
|
|
||||||
class Orchestrator {
|
|
||||||
public static Provider: ProviderInterface;
|
|
||||||
public static buildParameters: BuildParameters;
|
|
||||||
private static defaultSecrets: OrchestratorSecret[];
|
|
||||||
private static orchestratorEnvironmentVariables: OrchestratorEnvironmentVariable[];
|
|
||||||
static lockedWorkspace: string = ``;
|
|
||||||
public static readonly retainedWorkspacePrefix: string = `retained-workspace`;
|
|
||||||
|
|
||||||
// When true, validates AWS CloudFormation templates even when using local-docker execution
|
|
||||||
// This is set by AWS_FORCE_PROVIDER=aws-local mode
|
|
||||||
public static validateAwsTemplates: boolean = false;
|
|
||||||
public static get isOrchestratorEnvironment() {
|
|
||||||
return process.env[`GITHUB_ACTIONS`] !== `true`;
|
|
||||||
}
|
|
||||||
public static get isOrchestratorAsyncEnvironment() {
|
|
||||||
return process.env[`ASYNC_WORKFLOW`] === `true`;
|
|
||||||
}
|
|
||||||
public static async setup(buildParameters: BuildParameters) {
|
|
||||||
OrchestratorLogger.setup();
|
|
||||||
OrchestratorLogger.log(`Setting up orchestrator`);
|
|
||||||
Orchestrator.buildParameters = buildParameters;
|
|
||||||
ResourceTracking.logAllocationSummary('setup');
|
|
||||||
await ResourceTracking.logDiskUsageSnapshot('setup');
|
|
||||||
if (Orchestrator.buildParameters.githubCheckId === ``) {
|
|
||||||
Orchestrator.buildParameters.githubCheckId = await GitHub.createGitHubCheck(
|
|
||||||
Orchestrator.buildParameters.buildGuid,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await Orchestrator.setupSelectedBuildPlatform();
|
|
||||||
Orchestrator.defaultSecrets = TaskParameterSerializer.readDefaultSecrets();
|
|
||||||
Orchestrator.orchestratorEnvironmentVariables =
|
|
||||||
TaskParameterSerializer.createOrchestratorEnvironmentVariables(buildParameters);
|
|
||||||
if (GitHub.githubInputEnabled) {
|
|
||||||
const buildParameterPropertyNames = Object.getOwnPropertyNames(buildParameters);
|
|
||||||
for (const element of Orchestrator.orchestratorEnvironmentVariables) {
|
|
||||||
// OrchestratorLogger.log(`Orchestrator output ${Input.ToEnvVarFormat(element.name)} = ${element.value}`);
|
|
||||||
core.setOutput(Input.ToEnvVarFormat(element.name), element.value);
|
|
||||||
}
|
|
||||||
for (const element of buildParameterPropertyNames) {
|
|
||||||
// OrchestratorLogger.log(`Orchestrator output ${Input.ToEnvVarFormat(element)} = ${buildParameters[element]}`);
|
|
||||||
core.setOutput(Input.ToEnvVarFormat(element), buildParameters[element]);
|
|
||||||
}
|
|
||||||
core.setOutput(
|
|
||||||
Input.ToEnvVarFormat(`buildArtifact`),
|
|
||||||
`build-${Orchestrator.buildParameters.buildGuid}.tar${
|
|
||||||
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
FollowLogStreamService.Reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async setupSelectedBuildPlatform() {
|
|
||||||
OrchestratorLogger.log(`Orchestrator platform selected ${Orchestrator.buildParameters.providerStrategy}`);
|
|
||||||
|
|
||||||
// Detect LocalStack endpoints and handle AWS provider appropriately
|
|
||||||
// AWS_FORCE_PROVIDER options:
|
|
||||||
// - 'aws': Force AWS provider (requires LocalStack Pro with ECS support)
|
|
||||||
// - 'aws-local': Validate AWS templates/config but execute via local-docker (for CI without ECS)
|
|
||||||
// - unset/other: Auto-fallback to local-docker when LocalStack detected
|
|
||||||
const awsForceProvider = process.env.AWS_FORCE_PROVIDER || '';
|
|
||||||
const forceAwsProvider = awsForceProvider === 'aws' || awsForceProvider === 'true';
|
|
||||||
const useAwsLocalMode = awsForceProvider === 'aws-local';
|
|
||||||
const endpointsToCheck = [
|
|
||||||
process.env.AWS_ENDPOINT,
|
|
||||||
process.env.AWS_S3_ENDPOINT,
|
|
||||||
process.env.AWS_CLOUD_FORMATION_ENDPOINT,
|
|
||||||
process.env.AWS_ECS_ENDPOINT,
|
|
||||||
process.env.AWS_KINESIS_ENDPOINT,
|
|
||||||
process.env.AWS_CLOUD_WATCH_LOGS_ENDPOINT,
|
|
||||||
OrchestratorOptions.awsEndpoint,
|
|
||||||
OrchestratorOptions.awsS3Endpoint,
|
|
||||||
OrchestratorOptions.awsCloudFormationEndpoint,
|
|
||||||
OrchestratorOptions.awsEcsEndpoint,
|
|
||||||
OrchestratorOptions.awsKinesisEndpoint,
|
|
||||||
OrchestratorOptions.awsCloudWatchLogsEndpoint,
|
|
||||||
]
|
|
||||||
.filter((x) => typeof x === 'string')
|
|
||||||
.join(' ');
|
|
||||||
const isLocalStack = /localstack|localhost|127\.0\.0\.1/i.test(endpointsToCheck);
|
|
||||||
let provider = Orchestrator.buildParameters.providerStrategy;
|
|
||||||
let validateAwsTemplates = false;
|
|
||||||
|
|
||||||
if (provider === 'aws' && isLocalStack) {
|
|
||||||
if (useAwsLocalMode) {
|
|
||||||
// aws-local mode: Validate AWS templates but execute via local-docker
|
|
||||||
// This provides confidence in AWS CloudFormation without requiring LocalStack Pro
|
|
||||||
OrchestratorLogger.log('AWS_FORCE_PROVIDER=aws-local: Validating AWS templates, executing via local-docker');
|
|
||||||
validateAwsTemplates = true;
|
|
||||||
provider = 'local-docker';
|
|
||||||
} else if (forceAwsProvider) {
|
|
||||||
// Force full AWS provider (requires LocalStack Pro with ECS support)
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
'LocalStack endpoints detected but AWS_FORCE_PROVIDER=aws; using full AWS provider (requires ECS support)',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Auto-fallback to local-docker
|
|
||||||
OrchestratorLogger.log('LocalStack endpoints detected; routing provider to local-docker for this run');
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
'Note: Set AWS_FORCE_PROVIDER=aws-local to validate AWS templates with local-docker execution',
|
|
||||||
);
|
|
||||||
provider = 'local-docker';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store whether we should validate AWS templates (used by aws-local mode)
|
|
||||||
Orchestrator.validateAwsTemplates = validateAwsTemplates;
|
|
||||||
|
|
||||||
switch (provider) {
|
|
||||||
case 'k8s':
|
|
||||||
Orchestrator.Provider = new Kubernetes(Orchestrator.buildParameters);
|
|
||||||
break;
|
|
||||||
case 'aws':
|
|
||||||
Orchestrator.Provider = new AwsBuildPlatform(Orchestrator.buildParameters);
|
|
||||||
|
|
||||||
// Validate that AWS provider is actually being used when expected
|
|
||||||
if (isLocalStack && forceAwsProvider) {
|
|
||||||
OrchestratorLogger.log('✓ AWS provider initialized with LocalStack - AWS functionality will be validated');
|
|
||||||
} else if (isLocalStack && !forceAwsProvider) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
'⚠ WARNING: AWS provider was requested but LocalStack detected without AWS_FORCE_PROVIDER',
|
|
||||||
);
|
|
||||||
OrchestratorLogger.log('⚠ This may cause AWS functionality tests to fail validation');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'test':
|
|
||||||
Orchestrator.Provider = new TestOrchestrator();
|
|
||||||
break;
|
|
||||||
case 'local-docker':
|
|
||||||
Orchestrator.Provider = new LocalDockerOrchestrator();
|
|
||||||
break;
|
|
||||||
case 'local-system':
|
|
||||||
Orchestrator.Provider = new LocalOrchestrator();
|
|
||||||
break;
|
|
||||||
case 'local':
|
|
||||||
Orchestrator.Provider = new LocalOrchestrator();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Try to load provider using the dynamic loader for unknown providers
|
|
||||||
try {
|
|
||||||
Orchestrator.Provider = await loadProvider(provider, Orchestrator.buildParameters);
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.log(`Failed to load provider '${provider}' using dynamic loader: ${error.message}`);
|
|
||||||
OrchestratorLogger.log('Falling back to local provider...');
|
|
||||||
Orchestrator.Provider = new LocalOrchestrator();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final validation: Ensure provider matches expectations
|
|
||||||
const finalProviderName = Orchestrator.Provider.constructor.name;
|
|
||||||
if (Orchestrator.buildParameters.providerStrategy === 'aws' && finalProviderName !== 'AWSBuildEnvironment') {
|
|
||||||
OrchestratorLogger.log(`⚠ WARNING: Expected AWS provider but got ${finalProviderName}`);
|
|
||||||
OrchestratorLogger.log('⚠ AWS functionality tests may not be validating AWS services correctly');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async run(buildParameters: BuildParameters, baseImage: string) {
|
|
||||||
if (baseImage.includes(`undefined`)) {
|
|
||||||
throw new Error(`baseImage is undefined`);
|
|
||||||
}
|
|
||||||
await Orchestrator.setup(buildParameters);
|
|
||||||
|
|
||||||
// When aws-local mode is enabled, validate AWS CloudFormation templates
|
|
||||||
// This ensures AWS templates are correct even when executing via local-docker
|
|
||||||
if (Orchestrator.validateAwsTemplates) {
|
|
||||||
await Orchestrator.validateAwsCloudFormationTemplates();
|
|
||||||
}
|
|
||||||
await Orchestrator.Provider.setupWorkflow(
|
|
||||||
Orchestrator.buildParameters.buildGuid,
|
|
||||||
Orchestrator.buildParameters,
|
|
||||||
Orchestrator.buildParameters.branch,
|
|
||||||
Orchestrator.defaultSecrets,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
if (buildParameters.maxRetainedWorkspaces > 0) {
|
|
||||||
Orchestrator.lockedWorkspace = SharedWorkspaceLocking.NewWorkspaceName();
|
|
||||||
|
|
||||||
const result = await SharedWorkspaceLocking.GetLockedWorkspace(
|
|
||||||
Orchestrator.lockedWorkspace,
|
|
||||||
Orchestrator.buildParameters.buildGuid,
|
|
||||||
Orchestrator.buildParameters,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
OrchestratorLogger.logLine(`Using retained workspace ${Orchestrator.lockedWorkspace}`);
|
|
||||||
Orchestrator.orchestratorEnvironmentVariables = [
|
|
||||||
...Orchestrator.orchestratorEnvironmentVariables,
|
|
||||||
{ name: `LOCKED_WORKSPACE`, value: Orchestrator.lockedWorkspace },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
OrchestratorLogger.log(`Max retained workspaces reached ${buildParameters.maxRetainedWorkspaces}`);
|
|
||||||
buildParameters.maxRetainedWorkspaces = 0;
|
|
||||||
Orchestrator.lockedWorkspace = ``;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Orchestrator.updateStatusWithBuildParameters();
|
|
||||||
const output = await new WorkflowCompositionRoot().run(
|
|
||||||
new OrchestratorStepParameters(
|
|
||||||
baseImage,
|
|
||||||
Orchestrator.orchestratorEnvironmentVariables,
|
|
||||||
Orchestrator.defaultSecrets,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await Orchestrator.Provider.cleanupWorkflow(
|
|
||||||
Orchestrator.buildParameters,
|
|
||||||
Orchestrator.buildParameters.branch,
|
|
||||||
Orchestrator.defaultSecrets,
|
|
||||||
);
|
|
||||||
if (!Orchestrator.buildParameters.isCliMode) core.endGroup();
|
|
||||||
if (buildParameters.asyncWorkflow && this.isOrchestratorEnvironment && this.isOrchestratorAsyncEnvironment) {
|
|
||||||
await GitHub.updateGitHubCheck(Orchestrator.buildParameters.buildGuid, `success`, `success`, `completed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) {
|
|
||||||
const workspace = Orchestrator.lockedWorkspace || ``;
|
|
||||||
await SharedWorkspaceLocking.ReleaseWorkspace(
|
|
||||||
workspace,
|
|
||||||
Orchestrator.buildParameters.buildGuid,
|
|
||||||
Orchestrator.buildParameters,
|
|
||||||
);
|
|
||||||
const isLocked = await SharedWorkspaceLocking.IsWorkspaceLocked(workspace, Orchestrator.buildParameters);
|
|
||||||
if (isLocked) {
|
|
||||||
throw new Error(
|
|
||||||
`still locked after releasing ${await SharedWorkspaceLocking.GetAllLocksForWorkspace(
|
|
||||||
workspace,
|
|
||||||
buildParameters,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Orchestrator.lockedWorkspace = ``;
|
|
||||||
}
|
|
||||||
|
|
||||||
await GitHub.triggerWorkflowOnComplete(Orchestrator.buildParameters.finalHooks);
|
|
||||||
|
|
||||||
if (buildParameters.constantGarbageCollection) {
|
|
||||||
Orchestrator.Provider.garbageCollect(``, true, buildParameters.garbageMaxAge, true, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new OrchestratorResult(buildParameters, output, true, true, false);
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.log(JSON.stringify(error, undefined, 4));
|
|
||||||
await GitHub.updateGitHubCheck(
|
|
||||||
Orchestrator.buildParameters.buildGuid,
|
|
||||||
`Failed - Error ${error?.message || error}`,
|
|
||||||
`failure`,
|
|
||||||
`completed`,
|
|
||||||
);
|
|
||||||
if (!Orchestrator.buildParameters.isCliMode) core.endGroup();
|
|
||||||
await OrchestratorError.handleException(error, Orchestrator.buildParameters, Orchestrator.defaultSecrets);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async updateStatusWithBuildParameters() {
|
|
||||||
const content = { ...Orchestrator.buildParameters };
|
|
||||||
content.gitPrivateToken = ``;
|
|
||||||
content.unitySerial = ``;
|
|
||||||
content.unityEmail = ``;
|
|
||||||
content.unityPassword = ``;
|
|
||||||
const jsonContent = JSON.stringify(content, undefined, 4);
|
|
||||||
await GitHub.updateGitHubCheck(jsonContent, Orchestrator.buildParameters.buildGuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates AWS CloudFormation templates without deploying them.
|
|
||||||
* Used by aws-local mode to ensure AWS templates are correct when executing via local-docker.
|
|
||||||
* This provides confidence that AWS ECS deployments would work with the generated templates.
|
|
||||||
*/
|
|
||||||
private static async validateAwsCloudFormationTemplates() {
|
|
||||||
OrchestratorLogger.log('=== AWS CloudFormation Template Validation (aws-local mode) ===');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Import AWS template formations
|
|
||||||
const { BaseStackFormation } = await import('./providers/aws/cloud-formations/base-stack-formation');
|
|
||||||
const { TaskDefinitionFormation } = await import('./providers/aws/cloud-formations/task-definition-formation');
|
|
||||||
|
|
||||||
// Validate base stack template
|
|
||||||
const baseTemplate = BaseStackFormation.formation;
|
|
||||||
OrchestratorLogger.log(`✓ Base stack template generated (${baseTemplate.length} chars)`);
|
|
||||||
|
|
||||||
// Check for required resources in base stack
|
|
||||||
const requiredBaseResources = ['AWS::EC2::VPC', 'AWS::ECS::Cluster', 'AWS::S3::Bucket', 'AWS::IAM::Role'];
|
|
||||||
for (const resource of requiredBaseResources) {
|
|
||||||
if (baseTemplate.includes(resource)) {
|
|
||||||
OrchestratorLogger.log(` ✓ Contains ${resource}`);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Base stack template missing required resource: ${resource}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate task definition template
|
|
||||||
const taskTemplate = TaskDefinitionFormation.formation;
|
|
||||||
OrchestratorLogger.log(`✓ Task definition template generated (${taskTemplate.length} chars)`);
|
|
||||||
|
|
||||||
// Check for required resources in task definition
|
|
||||||
const requiredTaskResources = ['AWS::ECS::TaskDefinition', 'AWS::Logs::LogGroup'];
|
|
||||||
for (const resource of requiredTaskResources) {
|
|
||||||
if (taskTemplate.includes(resource)) {
|
|
||||||
OrchestratorLogger.log(` ✓ Contains ${resource}`);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Task definition template missing required resource: ${resource}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate YAML syntax by checking for common patterns
|
|
||||||
if (!baseTemplate.includes('AWSTemplateFormatVersion')) {
|
|
||||||
throw new Error('Base stack template missing AWSTemplateFormatVersion');
|
|
||||||
}
|
|
||||||
if (!taskTemplate.includes('AWSTemplateFormatVersion')) {
|
|
||||||
throw new Error('Task definition template missing AWSTemplateFormatVersion');
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log('=== AWS CloudFormation templates validated successfully ===');
|
|
||||||
OrchestratorLogger.log('Note: Actual execution will use local-docker provider');
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.log(`AWS CloudFormation template validation failed: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default Orchestrator;
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
# Provider Loader Dynamic Imports
|
|
||||||
|
|
||||||
## What is a Provider?
|
|
||||||
|
|
||||||
A **provider** is a pluggable backend that Orchestrator uses to run builds and workflows. Examples include **AWS**, **Kubernetes**, or local execution. Each provider implements the [ProviderInterface](https://github.com/game-ci/unity-builder/blob/main/src/model/orchestrator/providers/provider-interface.ts), which defines the common lifecycle methods (setup, run, cleanup, garbage collection, etc.).
|
|
||||||
|
|
||||||
This abstraction makes Orchestrator flexible: you can switch execution environments or add your own provider (via npm package, GitHub repo, or local path) without changing the rest of your pipeline.
|
|
||||||
|
|
||||||
## Dynamic Provider Loading
|
|
||||||
|
|
||||||
The provider loader now supports dynamic loading of providers from multiple sources including local file paths, GitHub repositories, and NPM packages.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Local File Paths**: Load providers from relative or absolute file paths
|
|
||||||
- **GitHub URLs**: Clone and load providers from GitHub repositories with automatic updates
|
|
||||||
- **NPM Packages**: Load providers from installed NPM packages
|
|
||||||
- **Automatic Updates**: GitHub repositories are automatically updated when changes are available
|
|
||||||
- **Caching**: Local caching of cloned repositories for improved performance
|
|
||||||
- **Fallback Support**: Graceful fallback to local provider if loading fails
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Loading Built-in Providers
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { ProviderLoader } from './provider-loader';
|
|
||||||
|
|
||||||
// Load built-in providers
|
|
||||||
const awsProvider = await ProviderLoader.loadProvider('aws', buildParameters);
|
|
||||||
const k8sProvider = await ProviderLoader.loadProvider('k8s', buildParameters);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Loading Local Providers
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Load from relative path
|
|
||||||
const localProvider = await ProviderLoader.loadProvider('./my-local-provider', buildParameters);
|
|
||||||
|
|
||||||
// Load from absolute path
|
|
||||||
const absoluteProvider = await ProviderLoader.loadProvider('/path/to/provider', buildParameters);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Loading GitHub Providers
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Load from GitHub URL
|
|
||||||
const githubProvider = await ProviderLoader.loadProvider(
|
|
||||||
'https://github.com/user/my-provider',
|
|
||||||
buildParameters
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load from specific branch
|
|
||||||
const branchProvider = await ProviderLoader.loadProvider(
|
|
||||||
'https://github.com/user/my-provider/tree/develop',
|
|
||||||
buildParameters
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load from specific path in repository
|
|
||||||
const pathProvider = await ProviderLoader.loadProvider(
|
|
||||||
'https://github.com/user/my-provider/tree/main/src/providers',
|
|
||||||
buildParameters
|
|
||||||
);
|
|
||||||
|
|
||||||
// Shorthand notation
|
|
||||||
const shorthandProvider = await ProviderLoader.loadProvider('user/repo', buildParameters);
|
|
||||||
const branchShorthand = await ProviderLoader.loadProvider('user/repo@develop', buildParameters);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Loading NPM Packages
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Load from NPM package
|
|
||||||
const npmProvider = await ProviderLoader.loadProvider('my-provider-package', buildParameters);
|
|
||||||
|
|
||||||
// Load from scoped NPM package
|
|
||||||
const scopedProvider = await ProviderLoader.loadProvider('@scope/my-provider', buildParameters);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Provider Interface
|
|
||||||
|
|
||||||
All providers must implement the `ProviderInterface`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ProviderInterface {
|
|
||||||
cleanupWorkflow(): Promise<void>;
|
|
||||||
setupWorkflow(buildGuid: string, buildParameters: BuildParameters, branchName: string, defaultSecretsArray: any[]): Promise<void>;
|
|
||||||
runTaskInWorkflow(buildGuid: string, task: string, workingDirectory: string, buildVolumeFolder: string, environmentVariables: any[], secrets: any[]): Promise<string>;
|
|
||||||
garbageCollect(): Promise<void>;
|
|
||||||
listResources(): Promise<ProviderResource[]>;
|
|
||||||
listWorkflow(): Promise<ProviderWorkflow[]>;
|
|
||||||
watchWorkflow(): Promise<void>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Example Provider Implementation
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// my-provider.ts
|
|
||||||
import { ProviderInterface } from './provider-interface';
|
|
||||||
import BuildParameters from './build-parameters';
|
|
||||||
|
|
||||||
export default class MyProvider implements ProviderInterface {
|
|
||||||
constructor(private buildParameters: BuildParameters) {}
|
|
||||||
|
|
||||||
async cleanupWorkflow(): Promise<void> {
|
|
||||||
// Cleanup logic
|
|
||||||
}
|
|
||||||
|
|
||||||
async setupWorkflow(buildGuid: string, buildParameters: BuildParameters, branchName: string, defaultSecretsArray: any[]): Promise<void> {
|
|
||||||
// Setup logic
|
|
||||||
}
|
|
||||||
|
|
||||||
async runTaskInWorkflow(buildGuid: string, task: string, workingDirectory: string, buildVolumeFolder: string, environmentVariables: any[], secrets: any[]): Promise<string> {
|
|
||||||
// Task execution logic
|
|
||||||
return 'Task completed';
|
|
||||||
}
|
|
||||||
|
|
||||||
async garbageCollect(): Promise<void> {
|
|
||||||
// Garbage collection logic
|
|
||||||
}
|
|
||||||
|
|
||||||
async listResources(): Promise<ProviderResource[]> {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async listWorkflow(): Promise<ProviderWorkflow[]> {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async watchWorkflow(): Promise<void> {
|
|
||||||
// Watch logic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Utility Methods
|
|
||||||
|
|
||||||
### Analyze Provider Source
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Analyze a provider source without loading it
|
|
||||||
const sourceInfo = ProviderLoader.analyzeProviderSource('https://github.com/user/repo');
|
|
||||||
console.log(sourceInfo.type); // 'github'
|
|
||||||
console.log(sourceInfo.owner); // 'user'
|
|
||||||
console.log(sourceInfo.repo); // 'repo'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Clean Up Cache
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Clean up old cached repositories (older than 30 days)
|
|
||||||
await ProviderLoader.cleanupCache();
|
|
||||||
|
|
||||||
// Clean up repositories older than 7 days
|
|
||||||
await ProviderLoader.cleanupCache(7);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get Available Providers
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Get list of built-in providers
|
|
||||||
const providers = ProviderLoader.getAvailableProviders();
|
|
||||||
console.log(providers); // ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local']
|
|
||||||
```
|
|
||||||
|
|
||||||
## Supported URL Formats
|
|
||||||
|
|
||||||
### GitHub URLs
|
|
||||||
- `https://github.com/user/repo`
|
|
||||||
- `https://github.com/user/repo.git`
|
|
||||||
- `https://github.com/user/repo/tree/branch`
|
|
||||||
- `https://github.com/user/repo/tree/branch/path/to/provider`
|
|
||||||
- `git@github.com:user/repo.git`
|
|
||||||
|
|
||||||
### Shorthand GitHub References
|
|
||||||
- `user/repo`
|
|
||||||
- `user/repo@branch`
|
|
||||||
- `user/repo@branch/path/to/provider`
|
|
||||||
|
|
||||||
### Local Paths
|
|
||||||
- `./relative/path`
|
|
||||||
- `../relative/path`
|
|
||||||
- `/absolute/path`
|
|
||||||
- `C:\\path\\to\\provider` (Windows)
|
|
||||||
|
|
||||||
### NPM Packages
|
|
||||||
- `package-name`
|
|
||||||
- `@scope/package-name`
|
|
||||||
|
|
||||||
## Caching
|
|
||||||
|
|
||||||
GitHub repositories are automatically cached in the `.provider-cache` directory. The cache key is generated based on the repository owner, name, and branch. This ensures that:
|
|
||||||
|
|
||||||
1. Repositories are only cloned once
|
|
||||||
2. Updates are checked and applied automatically
|
|
||||||
3. Performance is improved for repeated loads
|
|
||||||
4. Storage is managed efficiently
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
The provider loader includes comprehensive error handling:
|
|
||||||
|
|
||||||
- **Missing packages**: Clear error messages when providers cannot be found
|
|
||||||
- **Interface validation**: Ensures providers implement the required interface
|
|
||||||
- **Git operations**: Handles network issues and repository access problems
|
|
||||||
- **Fallback mechanism**: Falls back to local provider if loading fails
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
The provider loader can be configured through environment variables:
|
|
||||||
|
|
||||||
- `PROVIDER_CACHE_DIR`: Custom cache directory (default: `.provider-cache`)
|
|
||||||
- `GIT_TIMEOUT`: Git operation timeout in milliseconds (default: 30000)
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Use specific branches or tags**: Always specify the branch or specific tag when loading from GitHub
|
|
||||||
2. **Implement proper error handling**: Wrap provider loading in try-catch blocks
|
|
||||||
3. **Clean up regularly**: Use the cleanup utility to manage cache size
|
|
||||||
4. **Test locally first**: Test providers locally before deploying
|
|
||||||
5. **Use semantic versioning**: Tag your provider repositories for stable versions
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
|
||||||
import * as core from '@actions/core';
|
|
||||||
import {
|
|
||||||
CloudFormation,
|
|
||||||
CreateStackCommand,
|
|
||||||
// eslint-disable-next-line import/named
|
|
||||||
CreateStackCommandInput,
|
|
||||||
DescribeStacksCommand,
|
|
||||||
// eslint-disable-next-line import/named
|
|
||||||
DescribeStacksCommandInput,
|
|
||||||
ListStacksCommand,
|
|
||||||
// eslint-disable-next-line import/named
|
|
||||||
Parameter,
|
|
||||||
UpdateStackCommand,
|
|
||||||
// eslint-disable-next-line import/named
|
|
||||||
UpdateStackCommandInput,
|
|
||||||
waitUntilStackCreateComplete,
|
|
||||||
waitUntilStackUpdateComplete,
|
|
||||||
} from '@aws-sdk/client-cloudformation';
|
|
||||||
import { BaseStackFormation } from './cloud-formations/base-stack-formation';
|
|
||||||
import crypto from 'node:crypto';
|
|
||||||
|
|
||||||
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 AWSBaseStack {
|
|
||||||
constructor(baseStackName: string) {
|
|
||||||
this.baseStackName = baseStackName;
|
|
||||||
}
|
|
||||||
private baseStackName: string;
|
|
||||||
|
|
||||||
async setupBaseStack(CF: CloudFormation) {
|
|
||||||
const baseStackName = this.baseStackName;
|
|
||||||
const stackWaitTimeSeconds = getStackWaitTime();
|
|
||||||
|
|
||||||
const baseStack = BaseStackFormation.formation;
|
|
||||||
|
|
||||||
// Cloud Formation Input
|
|
||||||
const describeStackInput: DescribeStacksCommandInput = {
|
|
||||||
StackName: baseStackName,
|
|
||||||
};
|
|
||||||
const parametersWithoutHash: Parameter[] = [{ ParameterKey: 'EnvironmentName', ParameterValue: baseStackName }];
|
|
||||||
const parametersHash = crypto
|
|
||||||
.createHash('md5')
|
|
||||||
.update(baseStack + JSON.stringify(parametersWithoutHash))
|
|
||||||
.digest('hex');
|
|
||||||
const parameters: Parameter[] = [
|
|
||||||
...parametersWithoutHash,
|
|
||||||
...[{ ParameterKey: 'Version', ParameterValue: parametersHash }],
|
|
||||||
];
|
|
||||||
const updateInput: UpdateStackCommandInput = {
|
|
||||||
StackName: baseStackName,
|
|
||||||
TemplateBody: baseStack,
|
|
||||||
Parameters: parameters,
|
|
||||||
Capabilities: ['CAPABILITY_IAM'],
|
|
||||||
};
|
|
||||||
const createStackInput: CreateStackCommandInput = {
|
|
||||||
StackName: baseStackName,
|
|
||||||
TemplateBody: baseStack,
|
|
||||||
Parameters: parameters,
|
|
||||||
Capabilities: ['CAPABILITY_IAM'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const stacks = await CF.send(
|
|
||||||
new ListStacksCommand({
|
|
||||||
StackStatusFilter: [
|
|
||||||
'CREATE_IN_PROGRESS',
|
|
||||||
'UPDATE_IN_PROGRESS',
|
|
||||||
'UPDATE_COMPLETE',
|
|
||||||
'CREATE_COMPLETE',
|
|
||||||
'ROLLBACK_COMPLETE',
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const stackNames = stacks.StackSummaries?.map((x) => x.StackName) || [];
|
|
||||||
const stackExists: boolean = stackNames.includes(baseStackName);
|
|
||||||
const describeStack = async () => {
|
|
||||||
return await CF.send(new DescribeStacksCommand(describeStackInput));
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
if (!stackExists) {
|
|
||||||
OrchestratorLogger.log(`${baseStackName} stack does not exist (${JSON.stringify(stackNames)})`);
|
|
||||||
let created = false;
|
|
||||||
try {
|
|
||||||
await CF.send(new CreateStackCommand(createStackInput));
|
|
||||||
created = true;
|
|
||||||
} catch (error: any) {
|
|
||||||
const message = `${error?.name ?? ''} ${error?.message ?? ''}`;
|
|
||||||
if (message.includes('AlreadyExistsException')) {
|
|
||||||
OrchestratorLogger.log(`Base stack already exists, continuing with describe`);
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (created) {
|
|
||||||
OrchestratorLogger.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') {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Waiting up to ${stackWaitTimeSeconds}s for '${baseStackName}' CloudFormation creation to finish`,
|
|
||||||
);
|
|
||||||
await waitUntilStackCreateComplete(
|
|
||||||
{
|
|
||||||
client: CF,
|
|
||||||
maxWaitTime: stackWaitTimeSeconds,
|
|
||||||
},
|
|
||||||
describeStackInput,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stackExists) {
|
|
||||||
OrchestratorLogger.log(`Base stack exists (version: ${stackVersion}, local version: ${parametersHash})`);
|
|
||||||
if (parametersHash !== stackVersion) {
|
|
||||||
OrchestratorLogger.log(`Attempting update of base stack`);
|
|
||||||
try {
|
|
||||||
await CF.send(new UpdateStackCommand(updateInput));
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error['message'].includes('No updates are to be performed')) {
|
|
||||||
OrchestratorLogger.log(`No updates are to be performed`);
|
|
||||||
} else {
|
|
||||||
OrchestratorLogger.log(`Update Failed (Stack name: ${baseStackName})`);
|
|
||||||
OrchestratorLogger.log(error['message']);
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(`Continuing...`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
OrchestratorLogger.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') {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Waiting up to ${stackWaitTimeSeconds}s for '${baseStackName}' CloudFormation update to finish`,
|
|
||||||
);
|
|
||||||
await waitUntilStackUpdateComplete(
|
|
||||||
{
|
|
||||||
client: CF,
|
|
||||||
maxWaitTime: stackWaitTimeSeconds,
|
|
||||||
},
|
|
||||||
describeStackInput,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log('base stack is now ready');
|
|
||||||
} catch (error) {
|
|
||||||
core.error(JSON.stringify(await describeStack(), undefined, 4));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { CloudFormation } from '@aws-sdk/client-cloudformation';
|
|
||||||
import { ECS } from '@aws-sdk/client-ecs';
|
|
||||||
import { Kinesis } from '@aws-sdk/client-kinesis';
|
|
||||||
import { CloudWatchLogs } from '@aws-sdk/client-cloudwatch-logs';
|
|
||||||
import { S3 } from '@aws-sdk/client-s3';
|
|
||||||
import { Input } from '../../..';
|
|
||||||
import OrchestratorOptions from '../../options/orchestrator-options';
|
|
||||||
|
|
||||||
export class AwsClientFactory {
|
|
||||||
private static cloudFormation: CloudFormation;
|
|
||||||
private static ecs: ECS;
|
|
||||||
private static kinesis: Kinesis;
|
|
||||||
private static cloudWatchLogs: CloudWatchLogs;
|
|
||||||
private static s3: S3;
|
|
||||||
|
|
||||||
private static getCredentials() {
|
|
||||||
// Explicitly provide credentials from environment variables for LocalStack compatibility
|
|
||||||
// LocalStack accepts any credentials, but the AWS SDK needs them to be explicitly set
|
|
||||||
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
|
||||||
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
|
||||||
|
|
||||||
if (accessKeyId && secretAccessKey) {
|
|
||||||
return {
|
|
||||||
accessKeyId,
|
|
||||||
secretAccessKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return undefined to let AWS SDK use default credential chain
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getCloudFormation(): CloudFormation {
|
|
||||||
if (!this.cloudFormation) {
|
|
||||||
this.cloudFormation = new CloudFormation({
|
|
||||||
region: Input.region,
|
|
||||||
endpoint: OrchestratorOptions.awsCloudFormationEndpoint,
|
|
||||||
credentials: AwsClientFactory.getCredentials(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.cloudFormation;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getECS(): ECS {
|
|
||||||
if (!this.ecs) {
|
|
||||||
this.ecs = new ECS({
|
|
||||||
region: Input.region,
|
|
||||||
endpoint: OrchestratorOptions.awsEcsEndpoint,
|
|
||||||
credentials: AwsClientFactory.getCredentials(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.ecs;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getKinesis(): Kinesis {
|
|
||||||
if (!this.kinesis) {
|
|
||||||
this.kinesis = new Kinesis({
|
|
||||||
region: Input.region,
|
|
||||||
endpoint: OrchestratorOptions.awsKinesisEndpoint,
|
|
||||||
credentials: AwsClientFactory.getCredentials(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.kinesis;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getCloudWatchLogs(): CloudWatchLogs {
|
|
||||||
if (!this.cloudWatchLogs) {
|
|
||||||
this.cloudWatchLogs = new CloudWatchLogs({
|
|
||||||
region: Input.region,
|
|
||||||
endpoint: OrchestratorOptions.awsCloudWatchLogsEndpoint,
|
|
||||||
credentials: AwsClientFactory.getCredentials(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.cloudWatchLogs;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getS3(): S3 {
|
|
||||||
if (!this.s3) {
|
|
||||||
this.s3 = new S3({
|
|
||||||
region: Input.region,
|
|
||||||
endpoint: OrchestratorOptions.awsS3Endpoint,
|
|
||||||
forcePathStyle: true,
|
|
||||||
credentials: AwsClientFactory.getCredentials(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.s3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { TaskDefinitionFormation } from './cloud-formations/task-definition-formation';
|
|
||||||
|
|
||||||
export class AWSCloudFormationTemplates {
|
|
||||||
public static getParameterTemplate(p1: string) {
|
|
||||||
return `
|
|
||||||
${p1}:
|
|
||||||
Type: String
|
|
||||||
Default: ''
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getSecretTemplate(p1: string) {
|
|
||||||
return `
|
|
||||||
${p1}Secret:
|
|
||||||
Type: AWS::SecretsManager::Secret
|
|
||||||
Properties:
|
|
||||||
Name: '${p1}'
|
|
||||||
SecretString: !Ref ${p1}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getSecretDefinitionTemplate(p1: string, p2: string) {
|
|
||||||
return `
|
|
||||||
Secrets:
|
|
||||||
- Name: '${p1}'
|
|
||||||
ValueFrom: !Ref ${p2}Secret
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static insertAtTemplate(template: string, insertionKey: string, insertion: string) {
|
|
||||||
const index = template.search(insertionKey) + insertionKey.length + '\n'.length;
|
|
||||||
template = [template.slice(0, index), insertion, template.slice(index)].join('');
|
|
||||||
|
|
||||||
return template;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readTaskCloudFormationTemplate(): string {
|
|
||||||
return TaskDefinitionFormation.formation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
|
||||||
import { CloudFormation, DescribeStackEventsCommand } from '@aws-sdk/client-cloudformation';
|
|
||||||
import * as core from '@actions/core';
|
|
||||||
import Orchestrator from '../../orchestrator';
|
|
||||||
|
|
||||||
export class AWSError {
|
|
||||||
static async handleStackCreationFailure(error: any, CF: CloudFormation, taskDefStackName: string) {
|
|
||||||
OrchestratorLogger.log('aws error: ');
|
|
||||||
core.error(JSON.stringify(error, undefined, 4));
|
|
||||||
if (Orchestrator.buildParameters.orchestratorDebug) {
|
|
||||||
OrchestratorLogger.log('Getting events and resources for task stack');
|
|
||||||
const events = (await CF.send(new DescribeStackEventsCommand({ StackName: taskDefStackName }))).StackEvents;
|
|
||||||
OrchestratorLogger.log(JSON.stringify(events, undefined, 4));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
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 { AWSCloudFormationTemplates } from './aws-cloud-formation-templates';
|
|
||||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
|
||||||
import { AWSError } from './aws-error';
|
|
||||||
import Orchestrator from '../../orchestrator';
|
|
||||||
import { CleanupCronFormation } from './cloud-formations/cleanup-cron-formation';
|
|
||||||
import OrchestratorOptions from '../../options/orchestrator-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) {
|
|
||||||
this.baseStackName = baseStackName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async setupCloudFormations(
|
|
||||||
CF: CloudFormation,
|
|
||||||
buildGuid: string,
|
|
||||||
image: string,
|
|
||||||
entrypoint: string[],
|
|
||||||
commands: string,
|
|
||||||
mountdir: string,
|
|
||||||
workingdir: string,
|
|
||||||
secrets: OrchestratorSecret[],
|
|
||||||
): Promise<OrchestratorAWSTaskDef> {
|
|
||||||
const taskDefStackName = `${this.baseStackName}-${buildGuid}`;
|
|
||||||
let taskDefCloudFormation = AWSCloudFormationTemplates.readTaskCloudFormationTemplate();
|
|
||||||
taskDefCloudFormation = taskDefCloudFormation.replace(
|
|
||||||
`ContainerCpu:
|
|
||||||
Default: 1024`,
|
|
||||||
`ContainerCpu:
|
|
||||||
Default: ${Number.parseInt(Orchestrator.buildParameters.containerCpu)}`,
|
|
||||||
);
|
|
||||||
taskDefCloudFormation = taskDefCloudFormation.replace(
|
|
||||||
`ContainerMemory:
|
|
||||||
Default: 2048`,
|
|
||||||
`ContainerMemory:
|
|
||||||
Default: ${Number.parseInt(Orchestrator.buildParameters.containerMemory)}`,
|
|
||||||
);
|
|
||||||
if (!OrchestratorOptions.asyncOrchestrator) {
|
|
||||||
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
|
|
||||||
taskDefCloudFormation,
|
|
||||||
'# template resources logstream',
|
|
||||||
TaskDefinitionFormation.streamLogs,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const secret of secrets) {
|
|
||||||
secret.ParameterKey = `${buildGuid.replace(/[^\dA-Za-z]/g, '')}${secret.ParameterKey.replace(
|
|
||||||
/[^\dA-Za-z]/g,
|
|
||||||
'',
|
|
||||||
)}`;
|
|
||||||
if (typeof secret.ParameterValue == 'number') {
|
|
||||||
secret.ParameterValue = `${secret.ParameterValue}`;
|
|
||||||
}
|
|
||||||
if (!secret.ParameterValue || secret.ParameterValue === '') {
|
|
||||||
secrets = secrets.filter((x) => x !== secret);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
|
|
||||||
taskDefCloudFormation,
|
|
||||||
'p1 - input',
|
|
||||||
AWSCloudFormationTemplates.getParameterTemplate(secret.ParameterKey),
|
|
||||||
);
|
|
||||||
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
|
|
||||||
taskDefCloudFormation,
|
|
||||||
'# template resources secrets',
|
|
||||||
AWSCloudFormationTemplates.getSecretTemplate(`${secret.ParameterKey}`),
|
|
||||||
);
|
|
||||||
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
|
|
||||||
taskDefCloudFormation,
|
|
||||||
'p3 - container def',
|
|
||||||
AWSCloudFormationTemplates.getSecretDefinitionTemplate(secret.EnvironmentVariable, secret.ParameterKey),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const secretsMappedToCloudFormationParameters = secrets.map((x) => {
|
|
||||||
return { ParameterKey: x.ParameterKey.replace(/[^\dA-Za-z]/g, ''), ParameterValue: x.ParameterValue };
|
|
||||||
});
|
|
||||||
const logGroupName = `${this.baseStackName}/${taskDefStackName}`;
|
|
||||||
const parameters = [
|
|
||||||
{
|
|
||||||
ParameterKey: 'EnvironmentName',
|
|
||||||
ParameterValue: this.baseStackName,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ParameterKey: 'ImageUrl',
|
|
||||||
ParameterValue: image,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ParameterKey: 'ServiceName',
|
|
||||||
ParameterValue: taskDefStackName,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ParameterKey: 'LogGroupName',
|
|
||||||
ParameterValue: logGroupName,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ParameterKey: 'Command',
|
|
||||||
ParameterValue: 'echo "this template should be overwritten when running a task"',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ParameterKey: 'EntryPoint',
|
|
||||||
ParameterValue: entrypoint.join(','),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ParameterKey: 'WorkingDirectory',
|
|
||||||
ParameterValue: workingdir,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ParameterKey: 'EFSMountDirectory',
|
|
||||||
ParameterValue: mountdir,
|
|
||||||
},
|
|
||||||
...secretsMappedToCloudFormationParameters,
|
|
||||||
];
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Starting AWS job with memory: ${Orchestrator.buildParameters.containerMemory} cpu: ${Orchestrator.buildParameters.containerCpu}`,
|
|
||||||
);
|
|
||||||
let previousStackExists = true;
|
|
||||||
while (previousStackExists) {
|
|
||||||
previousStackExists = false;
|
|
||||||
const stacks = await CF.send(new ListStacksCommand({}));
|
|
||||||
if (!stacks.StackSummaries) {
|
|
||||||
throw new Error('Faild to get stacks');
|
|
||||||
}
|
|
||||||
for (let index = 0; index < stacks.StackSummaries.length; index++) {
|
|
||||||
const element = stacks.StackSummaries[index];
|
|
||||||
if (element.StackName === taskDefStackName && element.StackStatus !== 'DELETE_COMPLETE') {
|
|
||||||
previousStackExists = true;
|
|
||||||
OrchestratorLogger.log(`Previous stack still exists: ${JSON.stringify(element)}`);
|
|
||||||
await new Promise((promise) => setTimeout(promise, 5000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const createStackInput: CreateStackCommandInput = {
|
|
||||||
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 }));
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await AWSError.handleStackCreationFailure(error, CF, taskDefStackName);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createCleanupStackInput: CreateStackCommandInput = {
|
|
||||||
StackName: `${taskDefStackName}-cleanup`,
|
|
||||||
TemplateBody: CleanupCronFormation.formation,
|
|
||||||
Capabilities: ['CAPABILITY_IAM'],
|
|
||||||
Parameters: [
|
|
||||||
{
|
|
||||||
ParameterKey: 'StackName',
|
|
||||||
ParameterValue: taskDefStackName,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ParameterKey: 'DeleteStackName',
|
|
||||||
ParameterValue: `${taskDefStackName}-cleanup`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ParameterKey: 'TTL',
|
|
||||||
ParameterValue: `1080`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ParameterKey: 'BUILDGUID',
|
|
||||||
ParameterValue: Orchestrator.buildParameters.buildGuid,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ParameterKey: 'EnvironmentName',
|
|
||||||
ParameterValue: this.baseStackName,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
if (OrchestratorOptions.useCleanupCron) {
|
|
||||||
try {
|
|
||||||
OrchestratorLogger.log(`Creating job cleanup formation`);
|
|
||||||
await CF.send(new CreateStackCommand(createCleanupStackInput));
|
|
||||||
|
|
||||||
// await CF.waitFor('stackCreateComplete', { StackName: createCleanupStackInput.StackName }).promise();
|
|
||||||
} catch (error) {
|
|
||||||
await AWSError.handleStackCreationFailure(error, CF, taskDefStackName);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskDefResources = (
|
|
||||||
await CF.send(
|
|
||||||
new DescribeStackResourcesCommand({
|
|
||||||
StackName: taskDefStackName,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
).StackResources;
|
|
||||||
|
|
||||||
const baseResources = (await CF.send(new DescribeStackResourcesCommand({ StackName: this.baseStackName })))
|
|
||||||
.StackResources;
|
|
||||||
|
|
||||||
return {
|
|
||||||
taskDefStackName,
|
|
||||||
taskDefCloudFormation,
|
|
||||||
taskDefResources,
|
|
||||||
baseResources,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
import { DescribeTasksCommand, RunTaskCommand, waitUntilTasksRunning } from '@aws-sdk/client-ecs';
|
|
||||||
import { DescribeStreamCommand, GetRecordsCommand, GetShardIteratorCommand } from '@aws-sdk/client-kinesis';
|
|
||||||
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
|
||||||
import * as core from '@actions/core';
|
|
||||||
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def';
|
|
||||||
import * as zlib from 'node:zlib';
|
|
||||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
|
||||||
import { Input } from '../../..';
|
|
||||||
import Orchestrator from '../../orchestrator';
|
|
||||||
import { CommandHookService } from '../../services/hooks/command-hook-service';
|
|
||||||
import { FollowLogStreamService } from '../../services/core/follow-log-stream-service';
|
|
||||||
import OrchestratorOptions from '../../options/orchestrator-options';
|
|
||||||
import GitHub from '../../../github';
|
|
||||||
import { AwsClientFactory } from './aws-client-factory';
|
|
||||||
|
|
||||||
class AWSTaskRunner {
|
|
||||||
private static readonly encodedUnderscore = `$252F`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform localhost endpoints to host.docker.internal for container environments.
|
|
||||||
* When LocalStack is used, ECS tasks run in Docker containers that need to reach
|
|
||||||
* LocalStack on the host machine via host.docker.internal.
|
|
||||||
*/
|
|
||||||
private static transformEndpointsForContainer(
|
|
||||||
environment: OrchestratorEnvironmentVariable[],
|
|
||||||
): OrchestratorEnvironmentVariable[] {
|
|
||||||
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',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return environment.map((x) => {
|
|
||||||
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 ECS 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(`AWS TaskRunner: Replaced localhost with host.docker.internal for ${x.name}: ${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name: x.name, value };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async runTask(
|
|
||||||
taskDef: OrchestratorAWSTaskDef,
|
|
||||||
environment: OrchestratorEnvironmentVariable[],
|
|
||||||
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 || '';
|
|
||||||
|
|
||||||
// Transform localhost endpoints for container environment
|
|
||||||
const transformedEnvironment = AWSTaskRunner.transformEndpointsForContainer(environment);
|
|
||||||
|
|
||||||
const runParameters = {
|
|
||||||
cluster,
|
|
||||||
taskDefinition,
|
|
||||||
platformVersion: '1.4.0',
|
|
||||||
overrides: {
|
|
||||||
containerOverrides: [
|
|
||||||
{
|
|
||||||
name: taskDef.taskDefStackName,
|
|
||||||
environment: transformedEnvironment,
|
|
||||||
command: ['-c', CommandHookService.ApplyHooksToCommands(commands, Orchestrator.buildParameters)],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
launchType: 'FARGATE',
|
|
||||||
networkConfiguration: {
|
|
||||||
awsvpcConfiguration: {
|
|
||||||
subnets: [SubnetOne, SubnetTwo],
|
|
||||||
assignPublicIp: 'ENABLED',
|
|
||||||
securityGroups: [ContainerSecurityGroup],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (JSON.stringify(runParameters.overrides.containerOverrides).length > 8192) {
|
|
||||||
OrchestratorLogger.log(JSON.stringify(runParameters.overrides.containerOverrides, undefined, 4));
|
|
||||||
throw new Error(`Container Overrides length must be at most 8192`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const task = await AwsClientFactory.getECS().send(new RunTaskCommand(runParameters as any));
|
|
||||||
const taskArn = task.tasks?.[0].taskArn || '';
|
|
||||||
OrchestratorLogger.log('Orchestrator job is starting');
|
|
||||||
await AWSTaskRunner.waitUntilTaskRunning(taskArn, cluster);
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Orchestrator job status is running ${(await AWSTaskRunner.describeTasks(cluster, taskArn))?.lastStatus} Async:${
|
|
||||||
OrchestratorOptions.asyncOrchestrator
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
if (OrchestratorOptions.asyncOrchestrator) {
|
|
||||||
const shouldCleanup: boolean = false;
|
|
||||||
const output: string = '';
|
|
||||||
OrchestratorLogger.log(`Watch Orchestrator To End: false`);
|
|
||||||
|
|
||||||
return { output, shouldCleanup };
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Streaming...`);
|
|
||||||
const { output, shouldCleanup } = await this.streamLogsUntilTaskStops(cluster, taskArn, streamName);
|
|
||||||
let exitCode;
|
|
||||||
let containerState;
|
|
||||||
let taskData;
|
|
||||||
while (exitCode === undefined) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|
||||||
taskData = await AWSTaskRunner.describeTasks(cluster, taskArn);
|
|
||||||
const containers = taskData?.containers as any[] | undefined;
|
|
||||||
if (!containers || containers.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
containerState = containers[0];
|
|
||||||
exitCode = containerState?.exitCode;
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(`Container State: ${JSON.stringify(containerState, undefined, 4)}`);
|
|
||||||
if (exitCode === undefined) {
|
|
||||||
OrchestratorLogger.logWarning(`Undefined exitcode for container`);
|
|
||||||
}
|
|
||||||
const wasSuccessful = exitCode === 0;
|
|
||||||
if (wasSuccessful) {
|
|
||||||
OrchestratorLogger.log(`Orchestrator 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 waitUntilTasksRunning(
|
|
||||||
{
|
|
||||||
client: AwsClientFactory.getECS(),
|
|
||||||
maxWaitTime: 300,
|
|
||||||
minDelay: 5,
|
|
||||||
maxDelay: 30,
|
|
||||||
},
|
|
||||||
{ tasks: [taskArn], cluster },
|
|
||||||
);
|
|
||||||
} catch (error_) {
|
|
||||||
const error = error_ as Error;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
const taskAfterError = await AWSTaskRunner.describeTasks(cluster, taskArn);
|
|
||||||
OrchestratorLogger.log(`Orchestrator job has ended ${taskAfterError?.containers?.[0]?.lastStatus}`);
|
|
||||||
|
|
||||||
core.setFailed(error);
|
|
||||||
core.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async describeTasks(clusterName: string, taskArn: string) {
|
|
||||||
const maxAttempts = 10;
|
|
||||||
let delayMs = 1000;
|
|
||||||
const maxDelayMs = 60000;
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
const tasks = await AwsClientFactory.getECS().send(
|
|
||||||
new DescribeTasksCommand({ cluster: clusterName, tasks: [taskArn] }),
|
|
||||||
);
|
|
||||||
if (tasks.tasks?.[0]) {
|
|
||||||
return tasks.tasks?.[0];
|
|
||||||
}
|
|
||||||
throw new Error('No task found');
|
|
||||||
} catch (error: any) {
|
|
||||||
const isThrottle = error?.name === 'ThrottlingException' || /rate exceeded/i.test(String(error?.message));
|
|
||||||
if (!isThrottle || attempt === maxAttempts) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const jitterMs = Math.floor(Math.random() * Math.min(1000, delayMs));
|
|
||||||
const sleepMs = delayMs + jitterMs;
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`AWS throttled DescribeTasks (attempt ${attempt}/${maxAttempts}), backing off ${sleepMs}ms (${delayMs} + jitter ${jitterMs})`,
|
|
||||||
);
|
|
||||||
await new Promise((r) => setTimeout(r, sleepMs));
|
|
||||||
delayMs = Math.min(delayMs * 2, maxDelayMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async streamLogsUntilTaskStops(clusterName: string, taskArn: string, kinesisStreamName: string) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
OrchestratorLogger.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/${Orchestrator.buildParameters.awsStackName}${AWSTaskRunner.encodedUnderscore}${Orchestrator.buildParameters.awsStackName}-${Orchestrator.buildParameters.buildGuid}`;
|
|
||||||
OrchestratorLogger.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));
|
|
||||||
if (taskData?.lastStatus !== 'RUNNING') {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
||||||
}
|
|
||||||
({ 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,
|
|
||||||
) {
|
|
||||||
let records: any;
|
|
||||||
try {
|
|
||||||
records = await AwsClientFactory.getKinesis().send(new GetRecordsCommand({ ShardIterator: iterator }));
|
|
||||||
} catch (error: any) {
|
|
||||||
const isThrottle = error?.name === 'ThrottlingException' || /rate exceeded/i.test(String(error?.message));
|
|
||||||
if (isThrottle) {
|
|
||||||
const baseBackoffMs = 1000;
|
|
||||||
const jitterMs = Math.floor(Math.random() * 1000);
|
|
||||||
const sleepMs = baseBackoffMs + jitterMs;
|
|
||||||
OrchestratorLogger.log(`AWS throttled GetRecords, backing off ${sleepMs}ms (1000 + jitter ${jitterMs})`);
|
|
||||||
await new Promise((r) => setTimeout(r, sleepMs));
|
|
||||||
|
|
||||||
return { iterator, shouldReadLogs, output, shouldCleanup };
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
iterator = records.NextShardIterator || '';
|
|
||||||
({ shouldReadLogs, output, shouldCleanup } = AWSTaskRunner.logRecords(
|
|
||||||
records,
|
|
||||||
iterator,
|
|
||||||
shouldReadLogs,
|
|
||||||
output,
|
|
||||||
shouldCleanup,
|
|
||||||
));
|
|
||||||
|
|
||||||
return { iterator, shouldReadLogs, output, shouldCleanup };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static checkStreamingShouldContinue(taskData: any, timestamp: number, shouldReadLogs: boolean) {
|
|
||||||
if (taskData?.lastStatus === 'UNKNOWN') {
|
|
||||||
OrchestratorLogger.log('## Orchestrator job unknwon');
|
|
||||||
}
|
|
||||||
if (taskData?.lastStatus !== 'RUNNING') {
|
|
||||||
if (timestamp === 0) {
|
|
||||||
OrchestratorLogger.log('## Orchestrator job stopped, streaming end of logs');
|
|
||||||
timestamp = Date.now();
|
|
||||||
}
|
|
||||||
if (timestamp !== 0 && Date.now() - timestamp > 30000) {
|
|
||||||
OrchestratorLogger.log('## Orchestrator status is not RUNNING for 30 seconds, last query for logs');
|
|
||||||
shouldReadLogs = false;
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(`## Status of job: ${taskData.lastStatus}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { timestamp, shouldReadLogs };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static logRecords(
|
|
||||||
records: any,
|
|
||||||
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 unknown 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 AwsClientFactory.getKinesis().send(new DescribeStreamCommand({ StreamName: kinesisStreamName }));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async getLogIterator(stream: any) {
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await AwsClientFactory.getKinesis().send(
|
|
||||||
new GetShardIteratorCommand({
|
|
||||||
ShardIteratorType: 'TRIM_HORIZON',
|
|
||||||
StreamName: stream.StreamDescription?.StreamName ?? '',
|
|
||||||
ShardId: stream.StreamDescription?.Shards?.[0]?.ShardId || '',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
).ShardIterator || ''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default AWSTaskRunner;
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
export class BaseStackFormation {
|
|
||||||
public static readonly baseStackDecription = `Game-CI base stack`;
|
|
||||||
public static readonly formation: string = `AWSTemplateFormatVersion: '2010-09-09'
|
|
||||||
Description: ${BaseStackFormation.baseStackDecription}
|
|
||||||
Parameters:
|
|
||||||
EnvironmentName:
|
|
||||||
Type: String
|
|
||||||
Default: development
|
|
||||||
Description: 'Your deployment environment: DEV, QA , PROD'
|
|
||||||
Version:
|
|
||||||
Type: String
|
|
||||||
Description: 'hash of template'
|
|
||||||
|
|
||||||
# ContainerPort:
|
|
||||||
# Type: Number
|
|
||||||
# Default: 80
|
|
||||||
# Description: What port number the application inside the docker container is binding to
|
|
||||||
|
|
||||||
Mappings:
|
|
||||||
# Hard values for the subnet masks. These masks define
|
|
||||||
# the range of internal IP addresses that can be assigned.
|
|
||||||
# The VPC can have all IP's from 10.0.0.0 to 10.0.255.255
|
|
||||||
# There are four subnets which cover the ranges:
|
|
||||||
#
|
|
||||||
# 10.0.0.0 - 10.0.0.255
|
|
||||||
# 10.0.1.0 - 10.0.1.255
|
|
||||||
# 10.0.2.0 - 10.0.2.255
|
|
||||||
# 10.0.3.0 - 10.0.3.255
|
|
||||||
|
|
||||||
SubnetConfig:
|
|
||||||
VPC:
|
|
||||||
CIDR: '10.0.0.0/16'
|
|
||||||
PublicOne:
|
|
||||||
CIDR: '10.0.0.0/24'
|
|
||||||
PublicTwo:
|
|
||||||
CIDR: '10.0.1.0/24'
|
|
||||||
|
|
||||||
Resources:
|
|
||||||
# VPC in which containers will be networked.
|
|
||||||
# It has two public subnets, and two private subnets.
|
|
||||||
# We distribute the subnets across the first two available subnets
|
|
||||||
# for the region, for high availability.
|
|
||||||
VPC:
|
|
||||||
Type: AWS::EC2::VPC
|
|
||||||
Properties:
|
|
||||||
EnableDnsSupport: true
|
|
||||||
EnableDnsHostnames: true
|
|
||||||
CidrBlock: !FindInMap ['SubnetConfig', 'VPC', 'CIDR']
|
|
||||||
|
|
||||||
MainBucket:
|
|
||||||
Type: "AWS::S3::Bucket"
|
|
||||||
Properties:
|
|
||||||
BucketName: !Ref EnvironmentName
|
|
||||||
|
|
||||||
EFSServerSecurityGroup:
|
|
||||||
Type: AWS::EC2::SecurityGroup
|
|
||||||
Properties:
|
|
||||||
GroupName: 'efs-server-endpoints'
|
|
||||||
GroupDescription: Which client ip addrs are allowed to access EFS server
|
|
||||||
VpcId: !Ref 'VPC'
|
|
||||||
SecurityGroupIngress:
|
|
||||||
- IpProtocol: tcp
|
|
||||||
FromPort: 2049
|
|
||||||
ToPort: 2049
|
|
||||||
SourceSecurityGroupId: !Ref ContainerSecurityGroup
|
|
||||||
#CidrIp: !FindInMap ['SubnetConfig', 'VPC', 'CIDR']
|
|
||||||
# A security group for the containers we will run in Fargate.
|
|
||||||
# Rules are added to this security group based on what ingress you
|
|
||||||
# add for the cluster.
|
|
||||||
ContainerSecurityGroup:
|
|
||||||
Type: AWS::EC2::SecurityGroup
|
|
||||||
Properties:
|
|
||||||
GroupName: 'task security group'
|
|
||||||
GroupDescription: Access to the Fargate containers
|
|
||||||
VpcId: !Ref 'VPC'
|
|
||||||
# SecurityGroupIngress:
|
|
||||||
# - IpProtocol: tcp
|
|
||||||
# FromPort: !Ref ContainerPort
|
|
||||||
# ToPort: !Ref ContainerPort
|
|
||||||
# CidrIp: 0.0.0.0/0
|
|
||||||
SecurityGroupEgress:
|
|
||||||
- IpProtocol: -1
|
|
||||||
FromPort: 2049
|
|
||||||
ToPort: 2049
|
|
||||||
CidrIp: '0.0.0.0/0'
|
|
||||||
|
|
||||||
# Two public subnets, where containers can have public IP addresses
|
|
||||||
PublicSubnetOne:
|
|
||||||
Type: AWS::EC2::Subnet
|
|
||||||
Properties:
|
|
||||||
AvailabilityZone: !Select
|
|
||||||
- 0
|
|
||||||
- Fn::GetAZs: !Ref 'AWS::Region'
|
|
||||||
VpcId: !Ref 'VPC'
|
|
||||||
CidrBlock: !FindInMap ['SubnetConfig', 'PublicOne', 'CIDR']
|
|
||||||
# MapPublicIpOnLaunch: true
|
|
||||||
|
|
||||||
PublicSubnetTwo:
|
|
||||||
Type: AWS::EC2::Subnet
|
|
||||||
Properties:
|
|
||||||
AvailabilityZone: !Select
|
|
||||||
- 1
|
|
||||||
- Fn::GetAZs: !Ref 'AWS::Region'
|
|
||||||
VpcId: !Ref 'VPC'
|
|
||||||
CidrBlock: !FindInMap ['SubnetConfig', 'PublicTwo', 'CIDR']
|
|
||||||
# MapPublicIpOnLaunch: true
|
|
||||||
|
|
||||||
# Setup networking resources for the public subnets. Containers
|
|
||||||
# in the public subnets have public IP addresses and the routing table
|
|
||||||
# sends network traffic via the internet gateway.
|
|
||||||
InternetGateway:
|
|
||||||
Type: AWS::EC2::InternetGateway
|
|
||||||
GatewayAttachement:
|
|
||||||
Type: AWS::EC2::VPCGatewayAttachment
|
|
||||||
Properties:
|
|
||||||
VpcId: !Ref 'VPC'
|
|
||||||
InternetGatewayId: !Ref 'InternetGateway'
|
|
||||||
|
|
||||||
# Attaching a Internet Gateway to route table makes it public.
|
|
||||||
PublicRouteTable:
|
|
||||||
Type: AWS::EC2::RouteTable
|
|
||||||
Properties:
|
|
||||||
VpcId: !Ref 'VPC'
|
|
||||||
PublicRoute:
|
|
||||||
Type: AWS::EC2::Route
|
|
||||||
DependsOn: GatewayAttachement
|
|
||||||
Properties:
|
|
||||||
RouteTableId: !Ref 'PublicRouteTable'
|
|
||||||
DestinationCidrBlock: '0.0.0.0/0'
|
|
||||||
GatewayId: !Ref 'InternetGateway'
|
|
||||||
|
|
||||||
# Attaching a public route table makes a subnet public.
|
|
||||||
PublicSubnetOneRouteTableAssociation:
|
|
||||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
|
||||||
Properties:
|
|
||||||
SubnetId: !Ref PublicSubnetOne
|
|
||||||
RouteTableId: !Ref PublicRouteTable
|
|
||||||
PublicSubnetTwoRouteTableAssociation:
|
|
||||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
|
||||||
Properties:
|
|
||||||
SubnetId: !Ref PublicSubnetTwo
|
|
||||||
RouteTableId: !Ref PublicRouteTable
|
|
||||||
|
|
||||||
# ECS Resources
|
|
||||||
ECSCluster:
|
|
||||||
Type: AWS::ECS::Cluster
|
|
||||||
|
|
||||||
# A role used to allow AWS Autoscaling to inspect stats and adjust scaleable targets
|
|
||||||
# on your AWS account
|
|
||||||
AutoscalingRole:
|
|
||||||
Type: AWS::IAM::Role
|
|
||||||
Properties:
|
|
||||||
AssumeRolePolicyDocument:
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Principal:
|
|
||||||
Service: [application-autoscaling.amazonaws.com]
|
|
||||||
Action: ['sts:AssumeRole']
|
|
||||||
Path: /
|
|
||||||
Policies:
|
|
||||||
- PolicyName: service-autoscaling
|
|
||||||
PolicyDocument:
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- 'application-autoscaling:*'
|
|
||||||
- 'cloudwatch:DescribeAlarms'
|
|
||||||
- 'cloudwatch:PutMetricAlarm'
|
|
||||||
- 'ecs:DescribeServices'
|
|
||||||
- 'ecs:UpdateService'
|
|
||||||
Resource: '*'
|
|
||||||
|
|
||||||
# This is an IAM role which authorizes ECS to manage resources on your
|
|
||||||
# account on your behalf, such as updating your load balancer with the
|
|
||||||
# details of where your containers are, so that traffic can reach your
|
|
||||||
# containers.
|
|
||||||
ECSRole:
|
|
||||||
Type: AWS::IAM::Role
|
|
||||||
Properties:
|
|
||||||
AssumeRolePolicyDocument:
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Principal:
|
|
||||||
Service: [ecs.amazonaws.com]
|
|
||||||
Action: ['sts:AssumeRole']
|
|
||||||
Path: /
|
|
||||||
Policies:
|
|
||||||
- PolicyName: ecs-service
|
|
||||||
PolicyDocument:
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
# Rules which allow ECS to attach network interfaces to instances
|
|
||||||
# on your behalf in order for awsvpc networking mode to work right
|
|
||||||
- 'ec2:AttachNetworkInterface'
|
|
||||||
- 'ec2:CreateNetworkInterface'
|
|
||||||
- 'ec2:CreateNetworkInterfacePermission'
|
|
||||||
- 'ec2:DeleteNetworkInterface'
|
|
||||||
- 'ec2:DeleteNetworkInterfacePermission'
|
|
||||||
- 'ec2:Describe*'
|
|
||||||
- 'ec2:DetachNetworkInterface'
|
|
||||||
|
|
||||||
# Rules which allow ECS to update load balancers on your behalf
|
|
||||||
# with the information sabout how to send traffic to your containers
|
|
||||||
- 'elasticloadbalancing:DeregisterInstancesFromLoadBalancer'
|
|
||||||
- 'elasticloadbalancing:DeregisterTargets'
|
|
||||||
- 'elasticloadbalancing:Describe*'
|
|
||||||
- 'elasticloadbalancing:RegisterInstancesWithLoadBalancer'
|
|
||||||
- 'elasticloadbalancing:RegisterTargets'
|
|
||||||
Resource: '*'
|
|
||||||
|
|
||||||
# This is a role which is used by the ECS tasks themselves.
|
|
||||||
ECSTaskExecutionRole:
|
|
||||||
Type: AWS::IAM::Role
|
|
||||||
Properties:
|
|
||||||
AssumeRolePolicyDocument:
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Principal:
|
|
||||||
Service: [ecs-tasks.amazonaws.com]
|
|
||||||
Action: ['sts:AssumeRole']
|
|
||||||
Path: /
|
|
||||||
Policies:
|
|
||||||
- PolicyName: AmazonECSTaskExecutionRolePolicy
|
|
||||||
PolicyDocument:
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
# Allow the use of secret manager
|
|
||||||
- 'secretsmanager:GetSecretValue'
|
|
||||||
- 'kms:Decrypt'
|
|
||||||
|
|
||||||
# Allow the ECS Tasks to download images from ECR
|
|
||||||
- 'ecr:GetAuthorizationToken'
|
|
||||||
- 'ecr:BatchCheckLayerAvailability'
|
|
||||||
- 'ecr:GetDownloadUrlForLayer'
|
|
||||||
- 'ecr:BatchGetImage'
|
|
||||||
|
|
||||||
# Allow the ECS tasks to upload logs to CloudWatch
|
|
||||||
- 'logs:CreateLogStream'
|
|
||||||
- 'logs:PutLogEvents'
|
|
||||||
Resource: '*'
|
|
||||||
|
|
||||||
DeleteCFNLambdaExecutionRole:
|
|
||||||
Type: 'AWS::IAM::Role'
|
|
||||||
Properties:
|
|
||||||
AssumeRolePolicyDocument:
|
|
||||||
Version: '2012-10-17'
|
|
||||||
Statement:
|
|
||||||
- Effect: 'Allow'
|
|
||||||
Principal:
|
|
||||||
Service: ['lambda.amazonaws.com']
|
|
||||||
Action: 'sts:AssumeRole'
|
|
||||||
Path: '/'
|
|
||||||
Policies:
|
|
||||||
- PolicyName: DeleteCFNLambdaExecutionRole
|
|
||||||
PolicyDocument:
|
|
||||||
Version: '2012-10-17'
|
|
||||||
Statement:
|
|
||||||
- Effect: 'Allow'
|
|
||||||
Action:
|
|
||||||
- 'logs:CreateLogGroup'
|
|
||||||
- 'logs:CreateLogStream'
|
|
||||||
- 'logs:PutLogEvents'
|
|
||||||
Resource: 'arn:aws:logs:*:*:*'
|
|
||||||
- Effect: 'Allow'
|
|
||||||
Action:
|
|
||||||
- 'cloudformation:DeleteStack'
|
|
||||||
- 'kinesis:DeleteStream'
|
|
||||||
- 'secretsmanager:DeleteSecret'
|
|
||||||
- 'kinesis:DescribeStreamSummary'
|
|
||||||
- 'logs:DeleteLogGroup'
|
|
||||||
- 'logs:DeleteSubscriptionFilter'
|
|
||||||
- 'ecs:DeregisterTaskDefinition'
|
|
||||||
- 'lambda:DeleteFunction'
|
|
||||||
- 'lambda:InvokeFunction'
|
|
||||||
- 'events:RemoveTargets'
|
|
||||||
- 'events:DeleteRule'
|
|
||||||
- 'lambda:RemovePermission'
|
|
||||||
Resource: '*'
|
|
||||||
|
|
||||||
### cloud watch to kinesis role
|
|
||||||
CloudWatchIAMRole:
|
|
||||||
Type: AWS::IAM::Role
|
|
||||||
Properties:
|
|
||||||
AssumeRolePolicyDocument:
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Principal:
|
|
||||||
Service: [logs.amazonaws.com]
|
|
||||||
Action: ['sts:AssumeRole']
|
|
||||||
Path: /
|
|
||||||
Policies:
|
|
||||||
- PolicyName: service-autoscaling
|
|
||||||
PolicyDocument:
|
|
||||||
Statement:
|
|
||||||
- Effect: Allow
|
|
||||||
Action:
|
|
||||||
- 'kinesis:PutRecord'
|
|
||||||
Resource: '*'
|
|
||||||
|
|
||||||
#####################EFS#####################
|
|
||||||
EfsFileStorage:
|
|
||||||
Type: 'AWS::EFS::FileSystem'
|
|
||||||
Properties:
|
|
||||||
BackupPolicy:
|
|
||||||
Status: ENABLED
|
|
||||||
PerformanceMode: maxIO
|
|
||||||
Encrypted: false
|
|
||||||
|
|
||||||
FileSystemPolicy:
|
|
||||||
Version: '2012-10-17'
|
|
||||||
Statement:
|
|
||||||
- Effect: 'Allow'
|
|
||||||
Action:
|
|
||||||
- 'elasticfilesystem:ClientMount'
|
|
||||||
- 'elasticfilesystem:ClientWrite'
|
|
||||||
- 'elasticfilesystem:ClientRootAccess'
|
|
||||||
Principal:
|
|
||||||
AWS: '*'
|
|
||||||
|
|
||||||
MountTargetResource1:
|
|
||||||
Type: AWS::EFS::MountTarget
|
|
||||||
Properties:
|
|
||||||
FileSystemId: !Ref EfsFileStorage
|
|
||||||
SubnetId: !Ref PublicSubnetOne
|
|
||||||
SecurityGroups:
|
|
||||||
- !Ref EFSServerSecurityGroup
|
|
||||||
|
|
||||||
MountTargetResource2:
|
|
||||||
Type: AWS::EFS::MountTarget
|
|
||||||
Properties:
|
|
||||||
FileSystemId: !Ref EfsFileStorage
|
|
||||||
SubnetId: !Ref PublicSubnetTwo
|
|
||||||
SecurityGroups:
|
|
||||||
- !Ref EFSServerSecurityGroup
|
|
||||||
|
|
||||||
Outputs:
|
|
||||||
EfsFileStorageId:
|
|
||||||
Description: 'The connection endpoint for the database.'
|
|
||||||
Value: !Ref EfsFileStorage
|
|
||||||
Export:
|
|
||||||
Name: !Sub ${'${EnvironmentName}'}:EfsFileStorageId
|
|
||||||
ClusterName:
|
|
||||||
Description: The name of the ECS cluster
|
|
||||||
Value: !Ref 'ECSCluster'
|
|
||||||
Export:
|
|
||||||
Name: !Sub${' ${EnvironmentName}'}:ClusterName
|
|
||||||
AutoscalingRole:
|
|
||||||
Description: The ARN of the role used for autoscaling
|
|
||||||
Value: !GetAtt 'AutoscalingRole.Arn'
|
|
||||||
Export:
|
|
||||||
Name: !Sub ${'${EnvironmentName}'}:AutoscalingRole
|
|
||||||
ECSRole:
|
|
||||||
Description: The ARN of the ECS role
|
|
||||||
Value: !GetAtt 'ECSRole.Arn'
|
|
||||||
Export:
|
|
||||||
Name: !Sub ${'${EnvironmentName}'}:ECSRole
|
|
||||||
ECSTaskExecutionRole:
|
|
||||||
Description: The ARN of the ECS role tsk execution role
|
|
||||||
Value: !GetAtt 'ECSTaskExecutionRole.Arn'
|
|
||||||
Export:
|
|
||||||
Name: !Sub ${'${EnvironmentName}'}:ECSTaskExecutionRole
|
|
||||||
|
|
||||||
DeleteCFNLambdaExecutionRole:
|
|
||||||
Description: Lambda execution role for cleaning up cloud formations
|
|
||||||
Value: !GetAtt 'DeleteCFNLambdaExecutionRole.Arn'
|
|
||||||
Export:
|
|
||||||
Name: !Sub ${'${EnvironmentName}'}:DeleteCFNLambdaExecutionRole
|
|
||||||
|
|
||||||
CloudWatchIAMRole:
|
|
||||||
Description: The ARN of the CloudWatch role for subscription filter
|
|
||||||
Value: !GetAtt 'CloudWatchIAMRole.Arn'
|
|
||||||
Export:
|
|
||||||
Name: !Sub ${'${EnvironmentName}'}:CloudWatchIAMRole
|
|
||||||
VpcId:
|
|
||||||
Description: The ID of the VPC that this stack is deployed in
|
|
||||||
Value: !Ref 'VPC'
|
|
||||||
Export:
|
|
||||||
Name: !Sub ${'${EnvironmentName}'}:VpcId
|
|
||||||
PublicSubnetOne:
|
|
||||||
Description: Public subnet one
|
|
||||||
Value: !Ref 'PublicSubnetOne'
|
|
||||||
Export:
|
|
||||||
Name: !Sub ${'${EnvironmentName}'}:PublicSubnetOne
|
|
||||||
PublicSubnetTwo:
|
|
||||||
Description: Public subnet two
|
|
||||||
Value: !Ref 'PublicSubnetTwo'
|
|
||||||
Export:
|
|
||||||
Name: !Sub ${'${EnvironmentName}'}:PublicSubnetTwo
|
|
||||||
ContainerSecurityGroup:
|
|
||||||
Description: A security group used to allow Fargate containers to receive traffic
|
|
||||||
Value: !Ref 'ContainerSecurityGroup'
|
|
||||||
Export:
|
|
||||||
Name: !Sub ${'${EnvironmentName}'}:ContainerSecurityGroup
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
export class CleanupCronFormation {
|
|
||||||
public static readonly formation: string = `AWSTemplateFormatVersion: '2010-09-09'
|
|
||||||
Description: Schedule automatic deletion of CloudFormation stacks
|
|
||||||
Metadata:
|
|
||||||
AWS::CloudFormation::Interface:
|
|
||||||
ParameterGroups:
|
|
||||||
- Label:
|
|
||||||
default: Input configuration
|
|
||||||
Parameters:
|
|
||||||
- StackName
|
|
||||||
- TTL
|
|
||||||
ParameterLabels:
|
|
||||||
StackName:
|
|
||||||
default: Stack name
|
|
||||||
TTL:
|
|
||||||
default: Time-to-live
|
|
||||||
Parameters:
|
|
||||||
EnvironmentName:
|
|
||||||
Type: String
|
|
||||||
Default: development
|
|
||||||
Description: 'Your deployment environment: DEV, QA , PROD'
|
|
||||||
BUILDGUID:
|
|
||||||
Type: String
|
|
||||||
Default: ''
|
|
||||||
StackName:
|
|
||||||
Type: String
|
|
||||||
Description: Stack name that will be deleted.
|
|
||||||
DeleteStackName:
|
|
||||||
Type: String
|
|
||||||
Description: Stack name that will be deleted.
|
|
||||||
TTL:
|
|
||||||
Type: Number
|
|
||||||
Description: Time-to-live in minutes for the stack.
|
|
||||||
Resources:
|
|
||||||
DeleteCFNLambda:
|
|
||||||
Type: "AWS::Lambda::Function"
|
|
||||||
Properties:
|
|
||||||
FunctionName: !Join [ "", [ 'DeleteCFNLambda', !Ref BUILDGUID ] ]
|
|
||||||
Code:
|
|
||||||
ZipFile: |
|
|
||||||
import boto3
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
|
|
||||||
stack_name = os.environ['stackName']
|
|
||||||
delete_stack_name = os.environ['deleteStackName']
|
|
||||||
|
|
||||||
def delete_cfn(stack_name):
|
|
||||||
try:
|
|
||||||
cfn = boto3.resource('cloudformation')
|
|
||||||
stack = cfn.Stack(stack_name)
|
|
||||||
stack.delete()
|
|
||||||
return "SUCCESS"
|
|
||||||
except:
|
|
||||||
return "ERROR"
|
|
||||||
|
|
||||||
def handler(event, context):
|
|
||||||
print("Received event:")
|
|
||||||
print(json.dumps(event))
|
|
||||||
result = delete_cfn(stack_name)
|
|
||||||
delete_cfn(delete_stack_name)
|
|
||||||
return result
|
|
||||||
Environment:
|
|
||||||
Variables:
|
|
||||||
stackName: !Ref 'StackName'
|
|
||||||
deleteStackName: !Ref 'DeleteStackName'
|
|
||||||
Handler: "index.handler"
|
|
||||||
Runtime: "python3.9"
|
|
||||||
Timeout: "5"
|
|
||||||
Role:
|
|
||||||
'Fn::ImportValue': !Sub '\${EnvironmentName}:DeleteCFNLambdaExecutionRole'
|
|
||||||
DeleteStackEventRule:
|
|
||||||
DependsOn:
|
|
||||||
- DeleteCFNLambda
|
|
||||||
- GenerateCronExpression
|
|
||||||
Type: "AWS::Events::Rule"
|
|
||||||
Properties:
|
|
||||||
Name: !Join [ "", [ 'DeleteStackEventRule', !Ref BUILDGUID ] ]
|
|
||||||
Description: Delete stack event
|
|
||||||
ScheduleExpression: !GetAtt GenerateCronExpression.cron_exp
|
|
||||||
State: "ENABLED"
|
|
||||||
Targets:
|
|
||||||
-
|
|
||||||
Arn: !GetAtt DeleteCFNLambda.Arn
|
|
||||||
Id: 'DeleteCFNLambda'
|
|
||||||
PermissionForDeleteCFNLambda:
|
|
||||||
Type: "AWS::Lambda::Permission"
|
|
||||||
DependsOn:
|
|
||||||
- DeleteStackEventRule
|
|
||||||
Properties:
|
|
||||||
FunctionName: !Join [ "", [ 'DeleteCFNLambda', !Ref BUILDGUID ] ]
|
|
||||||
Action: "lambda:InvokeFunction"
|
|
||||||
Principal: "events.amazonaws.com"
|
|
||||||
SourceArn: !GetAtt DeleteStackEventRule.Arn
|
|
||||||
GenerateCronExpLambda:
|
|
||||||
Type: "AWS::Lambda::Function"
|
|
||||||
Properties:
|
|
||||||
FunctionName: !Join [ "", [ 'GenerateCronExpressionLambda', !Ref BUILDGUID ] ]
|
|
||||||
Code:
|
|
||||||
ZipFile: |
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import cfnresponse
|
|
||||||
|
|
||||||
def deletion_time(ttl):
|
|
||||||
delete_at_time = datetime.now() + timedelta(minutes=int(ttl))
|
|
||||||
hh = delete_at_time.hour
|
|
||||||
mm = delete_at_time.minute
|
|
||||||
yyyy = delete_at_time.year
|
|
||||||
month = delete_at_time.month
|
|
||||||
dd = delete_at_time.day
|
|
||||||
# minutes hours day month day-of-week year
|
|
||||||
cron_exp = "cron({} {} {} {} ? {})".format(mm, hh, dd, month, yyyy)
|
|
||||||
return cron_exp
|
|
||||||
|
|
||||||
def handler(event, context):
|
|
||||||
print('Received event: %s' % json.dumps(event))
|
|
||||||
status = cfnresponse.SUCCESS
|
|
||||||
try:
|
|
||||||
if event['RequestType'] == 'Delete':
|
|
||||||
cfnresponse.send(event, context, status, {})
|
|
||||||
else:
|
|
||||||
ttl = event['ResourceProperties']['ttl']
|
|
||||||
responseData = {}
|
|
||||||
responseData['cron_exp'] = deletion_time(ttl)
|
|
||||||
cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error('Exception: %s' % e, exc_info=True)
|
|
||||||
status = cfnresponse.FAILED
|
|
||||||
cfnresponse.send(event, context, status, {}, None)
|
|
||||||
Handler: "index.handler"
|
|
||||||
Runtime: "python3.9"
|
|
||||||
Timeout: "5"
|
|
||||||
Role:
|
|
||||||
'Fn::ImportValue': !Sub '\${EnvironmentName}:DeleteCFNLambdaExecutionRole'
|
|
||||||
GenerateCronExpression:
|
|
||||||
Type: "Custom::GenerateCronExpression"
|
|
||||||
Version: "1.0"
|
|
||||||
Properties:
|
|
||||||
Name: !Join [ "", [ 'GenerateCronExpression', !Ref BUILDGUID ] ]
|
|
||||||
ServiceToken: !GetAtt GenerateCronExpLambda.Arn
|
|
||||||
ttl: !Ref 'TTL'
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import Orchestrator from '../../../orchestrator';
|
|
||||||
|
|
||||||
export class TaskDefinitionFormation {
|
|
||||||
public static readonly description: string = `Game CI Orchestrator Task Stack`;
|
|
||||||
public static get formation(): string {
|
|
||||||
return `AWSTemplateFormatVersion: 2010-09-09
|
|
||||||
Description: ${TaskDefinitionFormation.description}
|
|
||||||
Parameters:
|
|
||||||
EnvironmentName:
|
|
||||||
Type: String
|
|
||||||
Default: development
|
|
||||||
Description: 'Your deployment environment: DEV, QA , PROD'
|
|
||||||
ServiceName:
|
|
||||||
Type: String
|
|
||||||
Default: example
|
|
||||||
Description: A name for the service
|
|
||||||
LogGroupName:
|
|
||||||
Type: String
|
|
||||||
Default: example
|
|
||||||
Description: Name to use for the log group created for this task
|
|
||||||
ImageUrl:
|
|
||||||
Type: String
|
|
||||||
Default: nginx
|
|
||||||
Description: >-
|
|
||||||
The url of a docker image that contains the application process that will
|
|
||||||
handle the traffic for this service
|
|
||||||
ContainerPort:
|
|
||||||
Type: Number
|
|
||||||
Default: 80
|
|
||||||
Description: What port number the application inside the docker container is binding to
|
|
||||||
ContainerCpu:
|
|
||||||
Default: ${Orchestrator.buildParameters.containerCpu}
|
|
||||||
Type: Number
|
|
||||||
Description: How much CPU to give the container. 1024 is 1 CPU
|
|
||||||
ContainerMemory:
|
|
||||||
Default: ${Orchestrator.buildParameters.containerMemory}
|
|
||||||
Type: Number
|
|
||||||
Description: How much memory in megabytes to give the container
|
|
||||||
BUILDGUID:
|
|
||||||
Type: String
|
|
||||||
Default: ''
|
|
||||||
Command:
|
|
||||||
Type: String
|
|
||||||
Default: 'ls'
|
|
||||||
EntryPoint:
|
|
||||||
Type: String
|
|
||||||
Default: '/bin/sh'
|
|
||||||
WorkingDirectory:
|
|
||||||
Type: String
|
|
||||||
Default: '/efsdata/'
|
|
||||||
Role:
|
|
||||||
Type: String
|
|
||||||
Default: ''
|
|
||||||
Description: >-
|
|
||||||
(Optional) An IAM role to give the service's containers if the code within
|
|
||||||
needs to access other AWS resources like S3 buckets, DynamoDB tables, etc
|
|
||||||
EFSMountDirectory:
|
|
||||||
Type: String
|
|
||||||
Default: '/efsdata'
|
|
||||||
# template secrets p1 - input
|
|
||||||
Mappings:
|
|
||||||
SubnetConfig:
|
|
||||||
VPC:
|
|
||||||
CIDR: 10.0.0.0/16
|
|
||||||
PublicOne:
|
|
||||||
CIDR: 10.0.0.0/24
|
|
||||||
PublicTwo:
|
|
||||||
CIDR: 10.0.1.0/24
|
|
||||||
Conditions:
|
|
||||||
HasCustomRole: !Not
|
|
||||||
- !Equals
|
|
||||||
- Ref: Role
|
|
||||||
- ''
|
|
||||||
Resources:
|
|
||||||
LogGroup:
|
|
||||||
Type: 'AWS::Logs::LogGroup'
|
|
||||||
Properties:
|
|
||||||
LogGroupName: !Ref LogGroupName
|
|
||||||
Metadata:
|
|
||||||
'AWS::CloudFormation::Designer':
|
|
||||||
id: aece53ae-b82d-4267-bc16-ed964b05db27
|
|
||||||
# template resources secrets
|
|
||||||
|
|
||||||
# template resources logstream
|
|
||||||
|
|
||||||
TaskDefinition:
|
|
||||||
Type: 'AWS::ECS::TaskDefinition'
|
|
||||||
Properties:
|
|
||||||
Family: !Ref ServiceName
|
|
||||||
Cpu: !Ref ContainerCpu
|
|
||||||
Memory: !Ref ContainerMemory
|
|
||||||
NetworkMode: awsvpc
|
|
||||||
Volumes:
|
|
||||||
- Name: efs-data
|
|
||||||
EFSVolumeConfiguration:
|
|
||||||
FilesystemId:
|
|
||||||
'Fn::ImportValue': !Sub '${'${EnvironmentName}'}:EfsFileStorageId'
|
|
||||||
TransitEncryption: DISABLED
|
|
||||||
RequiresCompatibilities:
|
|
||||||
- FARGATE
|
|
||||||
ExecutionRoleArn:
|
|
||||||
'Fn::ImportValue': !Sub '${'${EnvironmentName}'}:ECSTaskExecutionRole'
|
|
||||||
TaskRoleArn:
|
|
||||||
'Fn::If':
|
|
||||||
- HasCustomRole
|
|
||||||
- !Ref Role
|
|
||||||
- !Ref 'AWS::NoValue'
|
|
||||||
ContainerDefinitions:
|
|
||||||
- Name: !Ref ServiceName
|
|
||||||
Cpu: !Ref ContainerCpu
|
|
||||||
Memory: !Ref ContainerMemory
|
|
||||||
Image: !Ref ImageUrl
|
|
||||||
EntryPoint:
|
|
||||||
Fn::Split:
|
|
||||||
- ','
|
|
||||||
- !Ref EntryPoint
|
|
||||||
Command:
|
|
||||||
Fn::Split:
|
|
||||||
- ','
|
|
||||||
- !Ref Command
|
|
||||||
WorkingDirectory: !Ref WorkingDirectory
|
|
||||||
Environment:
|
|
||||||
- Name: ALLOW_EMPTY_PASSWORD
|
|
||||||
Value: 'yes'
|
|
||||||
# template - env vars
|
|
||||||
MountPoints:
|
|
||||||
- SourceVolume: efs-data
|
|
||||||
ContainerPath: !Ref EFSMountDirectory
|
|
||||||
ReadOnly: false
|
|
||||||
# template secrets p3 - container def
|
|
||||||
LogConfiguration:
|
|
||||||
LogDriver: awslogs
|
|
||||||
Options:
|
|
||||||
awslogs-group: !Ref LogGroupName
|
|
||||||
awslogs-region: !Ref 'AWS::Region'
|
|
||||||
awslogs-stream-prefix: !Ref ServiceName
|
|
||||||
DependsOn:
|
|
||||||
- LogGroup
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
public static streamLogs = `
|
|
||||||
SubscriptionFilter:
|
|
||||||
Type: 'AWS::Logs::SubscriptionFilter'
|
|
||||||
Properties:
|
|
||||||
FilterPattern: ''
|
|
||||||
RoleArn:
|
|
||||||
'Fn::ImportValue': !Sub '${'${EnvironmentName}'}:CloudWatchIAMRole'
|
|
||||||
LogGroupName: !Ref LogGroupName
|
|
||||||
DestinationArn:
|
|
||||||
'Fn::GetAtt':
|
|
||||||
- KinesisStream
|
|
||||||
- Arn
|
|
||||||
Metadata:
|
|
||||||
'AWS::CloudFormation::Designer':
|
|
||||||
id: 7f809e91-9e5d-4678-98c1-c5085956c480
|
|
||||||
DependsOn:
|
|
||||||
- LogGroup
|
|
||||||
- KinesisStream
|
|
||||||
KinesisStream:
|
|
||||||
Type: 'AWS::Kinesis::Stream'
|
|
||||||
Properties:
|
|
||||||
Name: !Ref ServiceName
|
|
||||||
ShardCount: 1
|
|
||||||
Metadata:
|
|
||||||
'AWS::CloudFormation::Designer':
|
|
||||||
id: c6f18447-b879-4696-8873-f981b2cedd2b
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
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 AwsTaskRunner from './aws-task-runner';
|
|
||||||
import { ProviderInterface } from '../provider-interface';
|
|
||||||
import BuildParameters from '../../../build-parameters';
|
|
||||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
|
||||||
import { AWSJobStack as AwsJobStack } from './aws-job-stack';
|
|
||||||
import { AWSBaseStack as AwsBaseStack } from './aws-base-stack';
|
|
||||||
import { Input } from '../../..';
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AWSBuildEnvironment implements ProviderInterface {
|
|
||||||
private baseStackName: string;
|
|
||||||
|
|
||||||
constructor(buildParameters: BuildParameters) {
|
|
||||||
this.baseStackName = buildParameters.awsStackName;
|
|
||||||
}
|
|
||||||
async listResources(): Promise<ProviderResource[]> {
|
|
||||||
await TaskService.getCloudFormationJobStacks();
|
|
||||||
await TaskService.getLogGroups();
|
|
||||||
await TaskService.getTasks();
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
listWorkflow(): Promise<ProviderWorkflow[]> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
async watchWorkflow(): Promise<string> {
|
|
||||||
return await TaskService.watch();
|
|
||||||
}
|
|
||||||
|
|
||||||
async listOtherResources(): Promise<string> {
|
|
||||||
await TaskService.getLogGroups();
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async garbageCollect(
|
|
||||||
filter: string,
|
|
||||||
previewOnly: boolean,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
olderThan: Number,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
fullCache: boolean,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
baseDependencies: boolean,
|
|
||||||
): Promise<string> {
|
|
||||||
await GarbageCollectionService.cleanup(!previewOnly);
|
|
||||||
|
|
||||||
return ``;
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanupWorkflow(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
branchName: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
|
||||||
) {}
|
|
||||||
async setupWorkflow(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildGuid: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
branchName: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
|
||||||
) {
|
|
||||||
process.env.AWS_REGION = Input.region;
|
|
||||||
const CF = AwsClientFactory.getCloudFormation();
|
|
||||||
await new AwsBaseStack(this.baseStackName).setupBaseStack(CF);
|
|
||||||
}
|
|
||||||
|
|
||||||
async runTaskInWorkflow(
|
|
||||||
buildGuid: string,
|
|
||||||
image: string,
|
|
||||||
commands: string,
|
|
||||||
mountdir: string,
|
|
||||||
workingdir: string,
|
|
||||||
environment: OrchestratorEnvironmentVariable[],
|
|
||||||
secrets: OrchestratorSecret[],
|
|
||||||
): 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 entrypoint = ['/bin/sh'];
|
|
||||||
const startTimeMs = Date.now();
|
|
||||||
const taskDef = await new AwsJobStack(this.baseStackName).setupCloudFormations(
|
|
||||||
CF,
|
|
||||||
buildGuid,
|
|
||||||
image,
|
|
||||||
entrypoint,
|
|
||||||
commands,
|
|
||||||
mountdir,
|
|
||||||
workingdir,
|
|
||||||
secrets,
|
|
||||||
);
|
|
||||||
|
|
||||||
let postRunTaskTimeMs;
|
|
||||||
try {
|
|
||||||
const postSetupStacksTimeMs = Date.now();
|
|
||||||
OrchestratorLogger.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`);
|
|
||||||
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`);
|
|
||||||
|
|
||||||
return output;
|
|
||||||
} catch (error) {
|
|
||||||
OrchestratorLogger.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` }));
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default AWSBuildEnvironment;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// eslint-disable-next-line import/named
|
|
||||||
import { StackResource } from '@aws-sdk/client-cloudformation';
|
|
||||||
|
|
||||||
class OrchestratorAWSTaskDef {
|
|
||||||
public taskDefStackName!: string;
|
|
||||||
public taskDefCloudFormation!: string;
|
|
||||||
public taskDefResources: StackResource[] | undefined;
|
|
||||||
public baseResources: StackResource[] | undefined;
|
|
||||||
}
|
|
||||||
export default OrchestratorAWSTaskDef;
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { DeleteStackCommand, DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation';
|
|
||||||
import { DeleteLogGroupCommand } from '@aws-sdk/client-cloudwatch-logs';
|
|
||||||
import { StopTaskCommand } from '@aws-sdk/client-ecs';
|
|
||||||
import Input from '../../../../input';
|
|
||||||
import OrchestratorLogger from '../../../services/core/orchestrator-logger';
|
|
||||||
import { TaskService } from './task-service';
|
|
||||||
import { AwsClientFactory } from '../aws-client-factory';
|
|
||||||
|
|
||||||
export class GarbageCollectionService {
|
|
||||||
static isOlderThan1day(date: Date) {
|
|
||||||
const ageDate = new Date(date.getTime() - Date.now());
|
|
||||||
|
|
||||||
return ageDate.getDay() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 taskDefinitionsInUse = new Array();
|
|
||||||
const tasks = await TaskService.getTasks();
|
|
||||||
|
|
||||||
for (const task of tasks) {
|
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobStacks = await TaskService.getCloudFormationJobStacks();
|
|
||||||
for (const element of jobStacks) {
|
|
||||||
if (
|
|
||||||
(await CF.send(new DescribeStackResourcesCommand({ StackName: element.StackName }))).StackResources?.some(
|
|
||||||
(x) => x.ResourceType === 'AWS::ECS::TaskDefinition' && taskDefinitionsInUse.includes(x.PhysicalResourceId),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
OrchestratorLogger.log(`Skipping ${element.StackName} - active task was running not deleting`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
deleteResources &&
|
|
||||||
(!OneDayOlderOnly || (element.CreationTime && GarbageCollectionService.isOlderThan1day(element.CreationTime)))
|
|
||||||
) {
|
|
||||||
if (element.StackName === 'game-ci' || element.TemplateDescription === 'Game-CI base stack') {
|
|
||||||
OrchestratorLogger.log(`Skipping ${element.StackName} ignore list`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Deleting ${element.StackName}`);
|
|
||||||
await CF.send(new DeleteStackCommand({ StackName: element.StackName }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const logGroups = await TaskService.getLogGroups();
|
|
||||||
for (const element of logGroups) {
|
|
||||||
if (
|
|
||||||
deleteResources &&
|
|
||||||
(!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(new Date(element.creationTime!)))
|
|
||||||
) {
|
|
||||||
OrchestratorLogger.log(`Deleting ${element.logGroupName}`);
|
|
||||||
await cwl.send(new DeleteLogGroupCommand({ logGroupName: element.logGroupName || '' }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const locks = await TaskService.getLocks();
|
|
||||||
for (const element of locks) {
|
|
||||||
OrchestratorLogger.log(`Lock: ${element.Key}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import {
|
|
||||||
DescribeStackResourcesCommand,
|
|
||||||
DescribeStacksCommand,
|
|
||||||
ListStacksCommand,
|
|
||||||
} from '@aws-sdk/client-cloudformation';
|
|
||||||
import type { StackSummary } from '@aws-sdk/client-cloudformation';
|
|
||||||
// eslint-disable-next-line import/named
|
|
||||||
import { DescribeLogGroupsCommand, DescribeLogGroupsCommandInput } from '@aws-sdk/client-cloudwatch-logs';
|
|
||||||
import type { LogGroup } from '@aws-sdk/client-cloudwatch-logs';
|
|
||||||
import { DescribeTasksCommand, ListClustersCommand, ListTasksCommand } from '@aws-sdk/client-ecs';
|
|
||||||
import type { Task } from '@aws-sdk/client-ecs';
|
|
||||||
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
|
|
||||||
import Input from '../../../../input';
|
|
||||||
import OrchestratorLogger from '../../../services/core/orchestrator-logger';
|
|
||||||
import { BaseStackFormation } from '../cloud-formations/base-stack-formation';
|
|
||||||
import AwsTaskRunner from '../aws-task-runner';
|
|
||||||
import Orchestrator from '../../../orchestrator';
|
|
||||||
import { AwsClientFactory } from '../aws-client-factory';
|
|
||||||
import SharedWorkspaceLocking from '../../../services/core/shared-workspace-locking';
|
|
||||||
|
|
||||||
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(): Promise<StackSummary[]> {
|
|
||||||
const result: StackSummary[] = [];
|
|
||||||
OrchestratorLogger.log(``);
|
|
||||||
OrchestratorLogger.log(`List Cloud Formation Stacks`);
|
|
||||||
process.env.AWS_REGION = Input.region;
|
|
||||||
const CF = AwsClientFactory.getCloudFormation();
|
|
||||||
const stacks =
|
|
||||||
(await CF.send(new ListStacksCommand({}))).StackSummaries?.filter(
|
|
||||||
(_x) =>
|
|
||||||
_x.StackStatus !== 'DELETE_COMPLETE' && _x.TemplateDescription !== BaseStackFormation.baseStackDecription,
|
|
||||||
) || [];
|
|
||||||
OrchestratorLogger.log(``);
|
|
||||||
OrchestratorLogger.log(`Cloud Formation Stacks ${stacks.length}`);
|
|
||||||
for (const element of stacks) {
|
|
||||||
if (!element.CreationTime) {
|
|
||||||
OrchestratorLogger.log(`${element.StackName} due to undefined CreationTime`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ageDate: Date = new Date(Date.now() - (element.CreationTime?.getTime() ?? 0));
|
|
||||||
|
|
||||||
OrchestratorLogger.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.send(new ListStacksCommand({}))).StackSummaries?.filter(
|
|
||||||
(_x) =>
|
|
||||||
_x.StackStatus !== 'DELETE_COMPLETE' && _x.TemplateDescription === BaseStackFormation.baseStackDecription,
|
|
||||||
) || [];
|
|
||||||
OrchestratorLogger.log(``);
|
|
||||||
OrchestratorLogger.log(`Base Stacks ${baseStacks.length}`);
|
|
||||||
for (const element of baseStacks) {
|
|
||||||
if (!element.CreationTime) {
|
|
||||||
OrchestratorLogger.log(`${element.StackName} due to undefined CreationTime`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ageDate: Date = new Date(Date.now() - (element.CreationTime?.getTime() ?? 0));
|
|
||||||
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Task Stack ${element.StackName} - Age D${Math.floor(
|
|
||||||
ageDate.getHours() / 24,
|
|
||||||
)} H${ageDate.getHours()} M${ageDate.getMinutes()}`,
|
|
||||||
);
|
|
||||||
result.push(element);
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(``);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
public static async getTasks(): Promise<{ taskElement: Task; element: string }[]> {
|
|
||||||
const result: { taskElement: Task; element: string }[] = [];
|
|
||||||
OrchestratorLogger.log(``);
|
|
||||||
OrchestratorLogger.log(`List Tasks`);
|
|
||||||
process.env.AWS_REGION = Input.region;
|
|
||||||
const ecs = AwsClientFactory.getECS();
|
|
||||||
const clusters: string[] = [];
|
|
||||||
{
|
|
||||||
let nextToken: string | undefined;
|
|
||||||
do {
|
|
||||||
const clusterResponse = await ecs.send(new ListClustersCommand({ nextToken }));
|
|
||||||
clusters.push(...(clusterResponse.clusterArns ?? []));
|
|
||||||
nextToken = clusterResponse.nextToken;
|
|
||||||
} while (nextToken);
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(`Task Clusters ${clusters.length}`);
|
|
||||||
for (const element of clusters) {
|
|
||||||
const taskArns: string[] = [];
|
|
||||||
{
|
|
||||||
let nextToken: string | undefined;
|
|
||||||
do {
|
|
||||||
const taskResponse = await ecs.send(new ListTasksCommand({ cluster: element, nextToken }));
|
|
||||||
taskArns.push(...(taskResponse.taskArns ?? []));
|
|
||||||
nextToken = taskResponse.nextToken;
|
|
||||||
} while (nextToken);
|
|
||||||
}
|
|
||||||
if (taskArns.length > 0) {
|
|
||||||
const describeInput = { tasks: taskArns, cluster: element };
|
|
||||||
const describeList = (await ecs.send(new DescribeTasksCommand(describeInput))).tasks || [];
|
|
||||||
if (describeList.length === 0) {
|
|
||||||
OrchestratorLogger.log(`No Tasks`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(`Tasks ${describeList.length}`);
|
|
||||||
for (const taskElement of describeList) {
|
|
||||||
if (taskElement === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (taskElement.createdAt === undefined) {
|
|
||||||
OrchestratorLogger.log(`Skipping ${taskElement.taskDefinitionArn} no createdAt date`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result.push({ taskElement, element });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(``);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
public static async awsDescribeJob(job: string) {
|
|
||||||
process.env.AWS_REGION = Input.region;
|
|
||||||
const CF = AwsClientFactory.getCloudFormation();
|
|
||||||
try {
|
|
||||||
const stack =
|
|
||||||
(await CF.send(new ListStacksCommand({}))).StackSummaries?.find((_x) => _x.StackName === job) || undefined;
|
|
||||||
const stackInfo = (await CF.send(new DescribeStackResourcesCommand({ StackName: job }))) || undefined;
|
|
||||||
const stackInfo2 = (await CF.send(new DescribeStacksCommand({ StackName: job }))) || undefined;
|
|
||||||
if (stack === undefined) {
|
|
||||||
throw new Error('stack not defined');
|
|
||||||
}
|
|
||||||
if (!stack.CreationTime) {
|
|
||||||
OrchestratorLogger.log(`${stack.StackName} due to undefined CreationTime`);
|
|
||||||
}
|
|
||||||
const ageDate: Date = new Date(Date.now() - (stack.CreationTime?.getTime() ?? 0));
|
|
||||||
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)}
|
|
||||||
`;
|
|
||||||
OrchestratorLogger.log(message);
|
|
||||||
|
|
||||||
return message;
|
|
||||||
} catch (error) {
|
|
||||||
OrchestratorLogger.error(
|
|
||||||
`Failed to describe job ${job}: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public static async getLogGroups(): Promise<LogGroup[]> {
|
|
||||||
const result: LogGroup[] = [];
|
|
||||||
process.env.AWS_REGION = Input.region;
|
|
||||||
const cwl = AwsClientFactory.getCloudWatchLogs();
|
|
||||||
let logStreamInput: DescribeLogGroupsCommandInput = {
|
|
||||||
/* logGroupNamePrefix: 'game-ci' */
|
|
||||||
};
|
|
||||||
let logGroupsDescribe = await cwl.send(new DescribeLogGroupsCommand(logStreamInput));
|
|
||||||
const logGroups = logGroupsDescribe.logGroups || [];
|
|
||||||
while (logGroupsDescribe.nextToken) {
|
|
||||||
logStreamInput = {
|
|
||||||
/* logGroupNamePrefix: 'game-ci',*/
|
|
||||||
nextToken: logGroupsDescribe.nextToken,
|
|
||||||
};
|
|
||||||
logGroupsDescribe = await cwl.send(new DescribeLogGroupsCommand(logStreamInput));
|
|
||||||
logGroups.push(...(logGroupsDescribe?.logGroups || []));
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Log Groups ${logGroups.length}`);
|
|
||||||
for (const element of logGroups) {
|
|
||||||
if (element.creationTime === undefined) {
|
|
||||||
OrchestratorLogger.log(`Skipping ${element.logGroupName} no createdAt date`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const ageDate: Date = new Date(Date.now() - element.creationTime);
|
|
||||||
|
|
||||||
OrchestratorLogger.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(): Promise<Array<{ Key: string }>> {
|
|
||||||
process.env.AWS_REGION = Input.region;
|
|
||||||
if (Orchestrator.buildParameters.storageProvider === 'rclone') {
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
type ListObjectsFunction = (prefix: string) => Promise<string[]>;
|
|
||||||
const objects = await (SharedWorkspaceLocking as unknown as { listObjects: ListObjectsFunction }).listObjects('');
|
|
||||||
|
|
||||||
return objects.map((x: string) => ({ Key: x }));
|
|
||||||
}
|
|
||||||
const s3 = AwsClientFactory.getS3();
|
|
||||||
const listRequest = {
|
|
||||||
Bucket: Orchestrator.buildParameters.awsStackName,
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = await s3.send(new ListObjectsV2Command(listRequest));
|
|
||||||
|
|
||||||
return (results.Contents || []).map((object) => ({ Key: object.Key || '' }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import BuildParameters from '../../../build-parameters';
|
|
||||||
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
|
||||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
|
||||||
import { ProviderInterface } from '../provider-interface';
|
|
||||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
|
||||||
import Docker from '../../../docker';
|
|
||||||
import { Action } from '../../..';
|
|
||||||
import { writeFileSync } from 'node:fs';
|
|
||||||
import Orchestrator from '../../orchestrator';
|
|
||||||
import { ProviderResource } from '../provider-resource';
|
|
||||||
import { ProviderWorkflow } from '../provider-workflow';
|
|
||||||
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
|
|
||||||
import * as fs from 'node:fs';
|
|
||||||
import { CommandHookService } from '../../services/hooks/command-hook-service';
|
|
||||||
import { StringKeyValuePair } from '../../../shared-types';
|
|
||||||
|
|
||||||
class LocalDockerOrchestrator implements ProviderInterface {
|
|
||||||
public buildParameters!: BuildParameters;
|
|
||||||
|
|
||||||
listResources(): Promise<ProviderResource[]> {
|
|
||||||
return new Promise((resolve) => resolve([]));
|
|
||||||
}
|
|
||||||
listWorkflow(): Promise<ProviderWorkflow[]> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
watchWorkflow(): Promise<string> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
garbageCollect(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
filter: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
previewOnly: boolean,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
olderThan: Number,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
fullCache: boolean,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
baseDependencies: boolean,
|
|
||||||
): Promise<string> {
|
|
||||||
return new Promise((result) => result(``));
|
|
||||||
}
|
|
||||||
async cleanupWorkflow(
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
branchName: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
|
||||||
) {
|
|
||||||
const { workspace } = Action;
|
|
||||||
if (
|
|
||||||
fs.existsSync(
|
|
||||||
`${workspace}/orchestrator-cache/cache/build/build-${buildParameters.buildGuid}.tar${
|
|
||||||
Orchestrator.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' : ''
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setupWorkflow(
|
|
||||||
buildGuid: string,
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
branchName: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
|
||||||
) {
|
|
||||||
this.buildParameters = buildParameters;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async runTaskInWorkflow(
|
|
||||||
buildGuid: string,
|
|
||||||
image: string,
|
|
||||||
commands: string,
|
|
||||||
mountdir: string,
|
|
||||||
workingdir: string,
|
|
||||||
environment: OrchestratorEnvironmentVariable[],
|
|
||||||
secrets: OrchestratorSecret[],
|
|
||||||
): Promise<string> {
|
|
||||||
OrchestratorLogger.log(buildGuid);
|
|
||||||
OrchestratorLogger.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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// if (this.buildParameters?.orchestratorIntegrationTests) {
|
|
||||||
// core.info(JSON.stringify(content, undefined, 4));
|
|
||||||
// core.info(JSON.stringify(secrets, undefined, 4));
|
|
||||||
// core.info(JSON.stringify(environment, undefined, 4));
|
|
||||||
// }
|
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/no-for-loop
|
|
||||||
for (let index = 0; index < content.length; index++) {
|
|
||||||
if (content[index] === undefined) {
|
|
||||||
delete content[index];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let myOutput = '';
|
|
||||||
const sharedFolder = `/data/`;
|
|
||||||
|
|
||||||
// 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
|
|
||||||
set -e
|
|
||||||
|
|
||||||
mkdir -p /github/workspace/orchestrator-cache
|
|
||||||
mkdir -p /data/cache
|
|
||||||
cp -a /github/workspace/orchestrator-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
|
|
||||||
`;
|
|
||||||
writeFileSync(`${workspace}/${entrypointFilePath}`, fileContents, {
|
|
||||||
flag: 'w',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Orchestrator.buildParameters.orchestratorDebug) {
|
|
||||||
OrchestratorLogger.log(`Running local-docker: \n ${fileContents}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(`${workspace}/orchestrator-cache`)) {
|
|
||||||
await OrchestratorSystem.Run(`ls ${workspace}/orchestrator-cache && du -sh ${workspace}/orchestrator-cache`);
|
|
||||||
}
|
|
||||||
const exitCode = await Docker.run(
|
|
||||||
image,
|
|
||||||
{ workspace, actionFolder, ...this.buildParameters },
|
|
||||||
false,
|
|
||||||
`chmod +x /github/workspace/${entrypointFilePath} && /github/workspace/${entrypointFilePath}`,
|
|
||||||
content,
|
|
||||||
{
|
|
||||||
listeners: {
|
|
||||||
stdout: (data: Buffer) => {
|
|
||||||
myOutput += data.toString();
|
|
||||||
},
|
|
||||||
stderr: (data: Buffer) => {
|
|
||||||
myOutput += `[LOCAL-DOCKER-ERROR]${data.toString()}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Docker doesn't exit on fail now so adding this to ensure behavior is unchanged
|
|
||||||
// TODO: Is there a helpful way to consume the exit code or is it best to except
|
|
||||||
if (exitCode !== 0) {
|
|
||||||
throw new Error(`Build failed with exit code ${exitCode}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return myOutput;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default LocalDockerOrchestrator;
|
|
||||||
@@ -1,460 +0,0 @@
|
|||||||
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 KubernetesStorage from './kubernetes-storage';
|
|
||||||
import OrchestratorEnvironmentVariable from '../../options/orchestrator-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 { CoreV1Api } from '@kubernetes/client-node';
|
|
||||||
import Orchestrator from '../../orchestrator';
|
|
||||||
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';
|
|
||||||
|
|
||||||
class Kubernetes implements ProviderInterface {
|
|
||||||
public static Instance: Kubernetes;
|
|
||||||
public kubeConfig!: k8s.KubeConfig;
|
|
||||||
public kubeClient!: k8s.CoreV1Api;
|
|
||||||
public kubeClientApps!: k8s.AppsV1Api;
|
|
||||||
public kubeClientBatch!: k8s.BatchV1Api;
|
|
||||||
public rbacAuthorizationV1Api!: k8s.RbacAuthorizationV1Api;
|
|
||||||
public buildGuid: string = '';
|
|
||||||
public buildParameters!: BuildParameters;
|
|
||||||
public pvcName: string = '';
|
|
||||||
public secretName: string = '';
|
|
||||||
public jobName: string = '';
|
|
||||||
public namespace!: string;
|
|
||||||
public podName: string = '';
|
|
||||||
public containerName: string = '';
|
|
||||||
public cleanupCronJobName: string = '';
|
|
||||||
public serviceAccountName: string = '';
|
|
||||||
public ip: string = '';
|
|
||||||
|
|
||||||
constructor(buildParameters: BuildParameters) {
|
|
||||||
Kubernetes.Instance = this;
|
|
||||||
this.kubeConfig = new k8s.KubeConfig();
|
|
||||||
this.kubeConfig.loadFromDefault();
|
|
||||||
this.kubeClient = this.kubeConfig.makeApiClient(k8s.CoreV1Api);
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
async PushLogUpdate(logs: string) {
|
|
||||||
// push logs to nginx file server via 'LOG_SERVICE_IP' env var
|
|
||||||
const ip = process.env[`LOG_SERVICE_IP`];
|
|
||||||
if (ip === undefined) {
|
|
||||||
RemoteClientLogger.logWarning(`LOG_SERVICE_IP not set, skipping log push`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = `http://${ip}/api/log`;
|
|
||||||
RemoteClientLogger.log(`Pushing logs to ${url}`);
|
|
||||||
|
|
||||||
// logs to base64
|
|
||||||
logs = Buffer.from(logs).toString('base64');
|
|
||||||
const response = await OrchestratorSystem.Run(`curl -X POST -d "${logs}" ${url}`, false, true);
|
|
||||||
RemoteClientLogger.log(`Pushed logs to ${url} ${response}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async listResources(): Promise<ProviderResource[]> {
|
|
||||||
const pods = await this.kubeClient.listNamespacedPod(this.namespace);
|
|
||||||
const serviceAccounts = await this.kubeClient.listNamespacedServiceAccount(this.namespace);
|
|
||||||
const secrets = await this.kubeClient.listNamespacedSecret(this.namespace);
|
|
||||||
const jobs = await this.kubeClientBatch.listNamespacedJob(this.namespace);
|
|
||||||
|
|
||||||
return [
|
|
||||||
...pods.body.items.map((x) => {
|
|
||||||
return { Name: x.metadata?.name || `` };
|
|
||||||
}),
|
|
||||||
...serviceAccounts.body.items.map((x) => {
|
|
||||||
return { Name: x.metadata?.name || `` };
|
|
||||||
}),
|
|
||||||
...secrets.body.items.map((x) => {
|
|
||||||
return { Name: x.metadata?.name || `` };
|
|
||||||
}),
|
|
||||||
...jobs.body.items.map((x) => {
|
|
||||||
return { Name: x.metadata?.name || `` };
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
listWorkflow(): Promise<ProviderWorkflow[]> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
watchWorkflow(): Promise<string> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
garbageCollect(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
filter: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
previewOnly: boolean,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
olderThan: Number,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
fullCache: boolean,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
baseDependencies: boolean,
|
|
||||||
): Promise<string> {
|
|
||||||
return new Promise((result) => result(``));
|
|
||||||
}
|
|
||||||
public async setupWorkflow(
|
|
||||||
buildGuid: string,
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
branchName: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
this.buildParameters = buildParameters;
|
|
||||||
this.cleanupCronJobName = `unity-builder-cronjob-${buildParameters.buildGuid}`;
|
|
||||||
this.serviceAccountName = `service-account-${buildParameters.buildGuid}`;
|
|
||||||
|
|
||||||
await KubernetesServiceAccount.createServiceAccount(this.serviceAccountName, this.namespace, this.kubeClient);
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async runTaskInWorkflow(
|
|
||||||
buildGuid: string,
|
|
||||||
image: string,
|
|
||||||
commands: string,
|
|
||||||
mountdir: string,
|
|
||||||
workingdir: string,
|
|
||||||
environment: OrchestratorEnvironmentVariable[],
|
|
||||||
secrets: OrchestratorSecret[],
|
|
||||||
): 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)');
|
|
||||||
|
|
||||||
// Setup
|
|
||||||
const id =
|
|
||||||
BuildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(this.buildParameters)
|
|
||||||
? Orchestrator.lockedWorkspace
|
|
||||||
: this.buildParameters.buildGuid;
|
|
||||||
this.pvcName = `unity-builder-pvc-${id}`;
|
|
||||||
await KubernetesStorage.createPersistentVolumeClaim(
|
|
||||||
this.buildParameters,
|
|
||||||
this.pvcName,
|
|
||||||
this.kubeClient,
|
|
||||||
this.namespace,
|
|
||||||
);
|
|
||||||
this.buildGuid = buildGuid;
|
|
||||||
this.secretName = `build-credentials-${this.buildGuid}`;
|
|
||||||
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');
|
|
||||||
await this.createJob(commands, image, mountdir, workingdir, environment, secrets);
|
|
||||||
OrchestratorLogger.log('Watching pod until running');
|
|
||||||
await KubernetesTaskRunner.watchUntilPodRunning(this.kubeClient, this.podName, this.namespace);
|
|
||||||
|
|
||||||
OrchestratorLogger.log('Pod is running');
|
|
||||||
output += await KubernetesTaskRunner.runTask(
|
|
||||||
this.kubeConfig,
|
|
||||||
this.kubeClient,
|
|
||||||
this.jobName,
|
|
||||||
this.podName,
|
|
||||||
this.containerName,
|
|
||||||
this.namespace,
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.log(`error running k8s workflow ${error}`);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
JSON.stringify(
|
|
||||||
(await this.kubeClient.listNamespacedEvent(this.namespace)).body.items
|
|
||||||
.map((x) => {
|
|
||||||
return {
|
|
||||||
message: x.message || ``,
|
|
||||||
name: x.metadata.name || ``,
|
|
||||||
reason: x.reason || ``,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((x) => x.name.includes(this.podName)),
|
|
||||||
undefined,
|
|
||||||
4,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await this.cleanupTaskResources();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.cleanupTaskResources();
|
|
||||||
|
|
||||||
return output;
|
|
||||||
} catch (error) {
|
|
||||||
OrchestratorLogger.log('Running job failed');
|
|
||||||
core.error(JSON.stringify(error, undefined, 4));
|
|
||||||
|
|
||||||
// await this.cleanupTaskResources();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createJob(
|
|
||||||
commands: string,
|
|
||||||
image: string,
|
|
||||||
mountdir: string,
|
|
||||||
workingdir: string,
|
|
||||||
environment: OrchestratorEnvironmentVariable[],
|
|
||||||
secrets: OrchestratorSecret[],
|
|
||||||
) {
|
|
||||||
await this.createNamespacedJob(commands, image, mountdir, workingdir, environment, secrets);
|
|
||||||
const find = await Kubernetes.findPodFromJob(this.kubeClient, this.jobName, this.namespace);
|
|
||||||
this.setPodNameAndContainerName(find);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async doesJobExist(name: string) {
|
|
||||||
const jobs = await this.kubeClientBatch.listNamespacedJob(this.namespace);
|
|
||||||
|
|
||||||
return jobs.body.items.some((x) => x.metadata?.name === name);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async doesFailedJobExist() {
|
|
||||||
const podStatus = await this.kubeClient.readNamespacedPodStatus(this.podName, this.namespace);
|
|
||||||
|
|
||||||
return podStatus.body.status?.phase === `Failed`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createNamespacedJob(
|
|
||||||
commands: string,
|
|
||||||
image: string,
|
|
||||||
mountdir: string,
|
|
||||||
workingdir: string,
|
|
||||||
environment: OrchestratorEnvironmentVariable[],
|
|
||||||
secrets: OrchestratorSecret[],
|
|
||||||
) {
|
|
||||||
for (let index = 0; index < 3; index++) {
|
|
||||||
try {
|
|
||||||
const jobSpec = KubernetesJobSpecFactory.getJobSpec(
|
|
||||||
commands,
|
|
||||||
image,
|
|
||||||
mountdir,
|
|
||||||
workingdir,
|
|
||||||
environment,
|
|
||||||
secrets,
|
|
||||||
this.buildGuid,
|
|
||||||
this.buildParameters,
|
|
||||||
this.secretName,
|
|
||||||
this.pvcName,
|
|
||||||
this.jobName,
|
|
||||||
k8s,
|
|
||||||
this.containerName,
|
|
||||||
this.ip,
|
|
||||||
);
|
|
||||||
await new Promise((promise) => setTimeout(promise, 15000));
|
|
||||||
|
|
||||||
// await KubernetesRole.createRole(this.serviceAccountName, this.namespace, this.rbacAuthorizationV1Api);
|
|
||||||
|
|
||||||
const result = await this.kubeClientBatch.createNamespacedJob(this.namespace, jobSpec);
|
|
||||||
OrchestratorLogger.log(`Build job created`);
|
|
||||||
await new Promise((promise) => setTimeout(promise, 5000));
|
|
||||||
OrchestratorLogger.log('Job created');
|
|
||||||
|
|
||||||
return result.body.metadata?.name;
|
|
||||||
} catch (error) {
|
|
||||||
OrchestratorLogger.log(`Error occured creating job: ${error}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPodNameAndContainerName(pod: k8s.V1Pod) {
|
|
||||||
this.podName = pod.metadata?.name || '';
|
|
||||||
this.containerName = pod.status?.containerStatuses?.[0].name || this.containerName;
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanupTaskResources() {
|
|
||||||
OrchestratorLogger.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`);
|
|
||||||
if (error.response.body.reason !== `NotFound`) {
|
|
||||||
OrchestratorLogger.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);
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log('cleaned up Secret, Job and Pod');
|
|
||||||
OrchestratorLogger.log('cleaning up finished');
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanupWorkflow(
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
branchName: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
|
||||||
) {
|
|
||||||
if (BuildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
OrchestratorLogger.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');
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.log(`Cleanup failed ${JSON.stringify(error, undefined, 4)}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async findPodFromJob(kubeClient: CoreV1Api, jobName: string, namespace: string) {
|
|
||||||
const namespacedPods = await kubeClient.listNamespacedPod(namespace);
|
|
||||||
const pod = namespacedPods.body.items.find((x) => x.metadata?.labels?.['job-name'] === jobName);
|
|
||||||
if (pod === undefined) {
|
|
||||||
throw new Error("pod with job-name label doesn't exist");
|
|
||||||
}
|
|
||||||
|
|
||||||
return pod;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default Kubernetes;
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import { V1EnvVar, V1EnvVarSource, V1SecretKeySelector } from '@kubernetes/client-node';
|
|
||||||
import BuildParameters from '../../../build-parameters';
|
|
||||||
import { CommandHookService } from '../../services/hooks/command-hook-service';
|
|
||||||
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
|
||||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
|
||||||
import Orchestrator from '../../orchestrator';
|
|
||||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
|
||||||
|
|
||||||
class KubernetesJobSpecFactory {
|
|
||||||
static getJobSpec(
|
|
||||||
command: string,
|
|
||||||
image: string,
|
|
||||||
mountdir: string,
|
|
||||||
workingDirectory: string,
|
|
||||||
environment: OrchestratorEnvironmentVariable[],
|
|
||||||
secrets: OrchestratorSecret[],
|
|
||||||
buildGuid: string,
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
secretName: string,
|
|
||||||
pvcName: string,
|
|
||||||
jobName: string,
|
|
||||||
k8s: any,
|
|
||||||
containerName: string,
|
|
||||||
ip: string = '',
|
|
||||||
) {
|
|
||||||
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',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Determine the LocalStack hostname to use for K8s pods
|
|
||||||
// Priority: K8S_LOCALSTACK_HOST env var > localstack-main (container name on shared network)
|
|
||||||
// Note: Using K8S_LOCALSTACK_HOST instead of LOCALSTACK_HOST to avoid conflict with awslocal CLI
|
|
||||||
const localstackHost = process.env['K8S_LOCALSTACK_HOST'] || 'localstack-main';
|
|
||||||
OrchestratorLogger.log(`K8s pods will use LocalStack host: ${localstackHost}`);
|
|
||||||
|
|
||||||
const adjustedEnvironment = environment.map((x) => {
|
|
||||||
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 the LocalStack container hostname
|
|
||||||
// When k3d and LocalStack are on the same Docker network, pods can reach LocalStack by container name
|
|
||||||
value = value
|
|
||||||
.replace('http://localhost', `http://${localstackHost}`)
|
|
||||||
.replace('http://127.0.0.1', `http://${localstackHost}`);
|
|
||||||
OrchestratorLogger.log(`Replaced localhost with ${localstackHost} for ${x.name}: ${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name: x.name, value } as OrchestratorEnvironmentVariable;
|
|
||||||
});
|
|
||||||
|
|
||||||
const job = new k8s.V1Job();
|
|
||||||
job.apiVersion = 'batch/v1';
|
|
||||||
job.kind = 'Job';
|
|
||||||
job.metadata = {
|
|
||||||
name: jobName,
|
|
||||||
labels: {
|
|
||||||
app: 'unity-builder',
|
|
||||||
buildGuid,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reduce TTL for tests to free up resources faster (default 9999s = ~2.8 hours)
|
|
||||||
// For CI/test environments, use shorter TTL (300s = 5 minutes) to prevent disk pressure
|
|
||||||
const jobTTL = process.env['orchestratorTests'] === 'true' ? 300 : 9999;
|
|
||||||
job.spec = {
|
|
||||||
ttlSecondsAfterFinished: jobTTL,
|
|
||||||
backoffLimit: 0,
|
|
||||||
template: {
|
|
||||||
spec: {
|
|
||||||
terminationGracePeriodSeconds: 90, // Give PreStopHook (60s sleep) time to complete
|
|
||||||
volumes: [
|
|
||||||
{
|
|
||||||
name: 'build-mount',
|
|
||||||
persistentVolumeClaim: {
|
|
||||||
claimName: pvcName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
containers: [
|
|
||||||
{
|
|
||||||
ttlSecondsAfterFinished: 9999,
|
|
||||||
name: containerName,
|
|
||||||
image,
|
|
||||||
imagePullPolicy: process.env['orchestratorTests'] === 'true' ? 'IfNotPresent' : 'Always',
|
|
||||||
command: ['/bin/sh'],
|
|
||||||
args: [
|
|
||||||
'-c',
|
|
||||||
`${CommandHookService.ApplyHooksToCommands(`${command}\nsleep 2m`, Orchestrator.buildParameters)}`,
|
|
||||||
],
|
|
||||||
|
|
||||||
workingDir: `${workingDirectory}`,
|
|
||||||
resources: {
|
|
||||||
requests: (() => {
|
|
||||||
// Use smaller resource requests for lightweight hook containers
|
|
||||||
// Hook containers typically use utility images like aws-cli, rclone, etc.
|
|
||||||
const lightweightImages = ['amazon/aws-cli', 'rclone/rclone', 'steamcmd/steamcmd', 'ubuntu'];
|
|
||||||
const isLightweightContainer = lightweightImages.some((lightImage) => image.includes(lightImage));
|
|
||||||
|
|
||||||
if (isLightweightContainer && process.env['orchestratorTests'] === 'true') {
|
|
||||||
// For test environments, use minimal resources for hook containers
|
|
||||||
return {
|
|
||||||
memory: '128Mi',
|
|
||||||
cpu: '100m', // 0.1 CPU
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// For main build containers, use the configured resources
|
|
||||||
const memoryMB = Number.parseInt(buildParameters.containerMemory);
|
|
||||||
const cpuMB = Number.parseInt(buildParameters.containerCpu);
|
|
||||||
|
|
||||||
return {
|
|
||||||
memory: !Number.isNaN(memoryMB) && memoryMB > 0 ? `${memoryMB / 1024}G` : '750M',
|
|
||||||
cpu: !Number.isNaN(cpuMB) && cpuMB > 0 ? `${cpuMB / 1024}` : '1',
|
|
||||||
};
|
|
||||||
})(),
|
|
||||||
},
|
|
||||||
env: [
|
|
||||||
...adjustedEnvironment.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: [
|
|
||||||
'/bin/sh',
|
|
||||||
'-c',
|
|
||||||
'sleep 60; cd /data/builder/action/steps && chmod +x /steps/return_license.sh 2>/dev/null || true; /steps/return_license.sh 2>/dev/null || true',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
restartPolicy: 'Never',
|
|
||||||
|
|
||||||
// Add tolerations for CI/test environments to allow scheduling even with disk pressure
|
|
||||||
// This is acceptable for CI where we aggressively clean up disk space
|
|
||||||
tolerations: [
|
|
||||||
{
|
|
||||||
key: 'node.kubernetes.io/disk-pressure',
|
|
||||||
operator: 'Exists',
|
|
||||||
effect: 'NoSchedule',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (process.env['ORCHESTRATOR_MINIKUBE']) {
|
|
||||||
job.spec.template.spec.volumes[0] = {
|
|
||||||
name: 'build-mount',
|
|
||||||
hostPath: {
|
|
||||||
path: `/data`,
|
|
||||||
type: `Directory`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set ephemeral-storage request to a reasonable value to prevent evictions
|
|
||||||
// For tests, don't set a request (or use minimal 128Mi) since k3d nodes have very limited disk space
|
|
||||||
// Kubernetes will use whatever is available without a request, which is better for constrained environments
|
|
||||||
// For production, use 2Gi to allow for larger builds
|
|
||||||
// The node needs some free space headroom, so requesting too much causes evictions
|
|
||||||
// With node at 96% usage and only ~2.7GB free, we can't request much without triggering evictions
|
|
||||||
if (process.env['orchestratorTests'] !== 'true') {
|
|
||||||
// Only set ephemeral-storage request for production builds
|
|
||||||
job.spec.template.spec.containers[0].resources.requests[`ephemeral-storage`] = '2Gi';
|
|
||||||
}
|
|
||||||
|
|
||||||
// For tests, don't set ephemeral-storage request - let Kubernetes use available space
|
|
||||||
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default KubernetesJobSpecFactory;
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import OrchestratorLogger from '../../services/core/orchestrator-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';
|
|
||||||
OrchestratorLogger.log(`Getting pod status: ${phase}`);
|
|
||||||
if (phase === `Failed`) {
|
|
||||||
const pod = pods[0];
|
|
||||||
const containerStatuses = pod.status?.containerStatuses || [];
|
|
||||||
const conditions = pod.status?.conditions || [];
|
|
||||||
const events = (await kubeClient.listNamespacedEvent(namespace)).body.items
|
|
||||||
.filter((x) => x.involvedObject?.name === podName)
|
|
||||||
.map((x) => ({
|
|
||||||
message: x.message || '',
|
|
||||||
reason: x.reason || '',
|
|
||||||
type: x.type || '',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const errorDetails: string[] = [];
|
|
||||||
errorDetails.push(`Pod: ${podName}`, `Phase: ${phase}`);
|
|
||||||
|
|
||||||
if (conditions.length > 0) {
|
|
||||||
errorDetails.push(
|
|
||||||
`Conditions: ${JSON.stringify(
|
|
||||||
conditions.map((c) => ({ type: c.type, status: c.status, reason: c.reason, message: c.message })),
|
|
||||||
undefined,
|
|
||||||
2,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let containerExitCode: number | undefined;
|
|
||||||
let containerSucceeded = false;
|
|
||||||
|
|
||||||
if (containerStatuses.length > 0) {
|
|
||||||
for (const [index, cs] of containerStatuses.entries()) {
|
|
||||||
if (cs.state?.waiting) {
|
|
||||||
errorDetails.push(
|
|
||||||
`Container ${index} (${cs.name}) waiting: ${cs.state.waiting.reason} - ${cs.state.waiting.message || ''}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (cs.state?.terminated) {
|
|
||||||
const exitCode = cs.state.terminated.exitCode;
|
|
||||||
containerExitCode = exitCode;
|
|
||||||
if (exitCode === 0) {
|
|
||||||
containerSucceeded = true;
|
|
||||||
}
|
|
||||||
errorDetails.push(
|
|
||||||
`Container ${index} (${cs.name}) terminated: ${cs.state.terminated.reason} - ${
|
|
||||||
cs.state.terminated.message || ''
|
|
||||||
} (exit code: ${exitCode})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (events.length > 0) {
|
|
||||||
errorDetails.push(`Recent events: ${JSON.stringify(events.slice(-5), undefined, 2)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if only PreStopHook failed but container succeeded
|
|
||||||
const hasPreStopHookFailure = events.some((event) => event.reason === 'FailedPreStopHook');
|
|
||||||
const wasKilled = events.some((event) => event.reason === 'Killing');
|
|
||||||
const hasExceededGracePeriod = events.some((event) => event.reason === 'ExceededGracePeriod');
|
|
||||||
|
|
||||||
// If container succeeded (exit code 0), PreStopHook failure is non-critical
|
|
||||||
// Also check if pod was killed but container might have succeeded
|
|
||||||
if (containerSucceeded && containerExitCode === 0) {
|
|
||||||
// Container succeeded - PreStopHook failure is non-critical
|
|
||||||
if (hasPreStopHookFailure) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`Pod ${podName} marked as Failed due to PreStopHook failure, but container exited successfully (exit code 0). This is non-fatal.`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Pod ${podName} container succeeded (exit code 0), but pod phase is Failed. Checking details...`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(`Pod details: ${errorDetails.join('\n')}`);
|
|
||||||
|
|
||||||
// Don't throw error - container succeeded, PreStopHook failure is non-critical
|
|
||||||
return false; // Pod is not running, but we don't treat it as a failure
|
|
||||||
}
|
|
||||||
|
|
||||||
// If pod was killed and we have PreStopHook failure, wait for container status
|
|
||||||
// The container might have succeeded but status hasn't been updated yet
|
|
||||||
if (wasKilled && hasPreStopHookFailure && (containerExitCode === undefined || !containerSucceeded)) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Pod ${podName} was killed with PreStopHook failure. Waiting for container status to determine if container succeeded...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait a bit for container status to become available (up to 30 seconds)
|
|
||||||
for (let index = 0; index < 6; index++) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
||||||
try {
|
|
||||||
const updatedPod = (await kubeClient.listNamespacedPod(namespace)).body.items.find(
|
|
||||||
(x) => podName === x.metadata?.name,
|
|
||||||
);
|
|
||||||
if (updatedPod?.status?.containerStatuses && updatedPod.status.containerStatuses.length > 0) {
|
|
||||||
const updatedContainerStatus = updatedPod.status.containerStatuses[0];
|
|
||||||
if (updatedContainerStatus.state?.terminated) {
|
|
||||||
const updatedExitCode = updatedContainerStatus.state.terminated.exitCode;
|
|
||||||
if (updatedExitCode === 0) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`Pod ${podName} container succeeded (exit code 0) after waiting. PreStopHook failure is non-fatal.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return false; // Pod is not running, but container succeeded
|
|
||||||
} else {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Pod ${podName} container failed with exit code ${updatedExitCode} after waiting.`,
|
|
||||||
);
|
|
||||||
errorDetails.push(`Container terminated after wait: exit code ${updatedExitCode}`);
|
|
||||||
containerExitCode = updatedExitCode;
|
|
||||||
containerSucceeded = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (waitError) {
|
|
||||||
OrchestratorLogger.log(`Error while waiting for container status: ${waitError}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we still don't have container status after waiting, but only PreStopHook failed,
|
|
||||||
// be lenient - the container might have succeeded but status wasn't updated
|
|
||||||
if (containerExitCode === undefined && hasPreStopHookFailure && !hasExceededGracePeriod) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`Pod ${podName} container status not available after waiting, but only PreStopHook failed (no ExceededGracePeriod). Assuming container may have succeeded.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return false; // Be lenient - PreStopHook failure alone is not fatal
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Container status check completed. Exit code: ${containerExitCode}, PreStopHook failure: ${hasPreStopHookFailure}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we only have PreStopHook failure and no actual container failure, be lenient
|
|
||||||
if (hasPreStopHookFailure && !hasExceededGracePeriod && containerExitCode === undefined) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`Pod ${podName} has PreStopHook failure but no container failure detected. Treating as non-fatal.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return false; // PreStopHook failure alone is not fatal if container status is unclear
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if pod was evicted due to disk pressure - this is an infrastructure issue
|
|
||||||
const wasEvicted = errorDetails.some(
|
|
||||||
(detail) => detail.toLowerCase().includes('evicted') || detail.toLowerCase().includes('diskpressure'),
|
|
||||||
);
|
|
||||||
if (wasEvicted) {
|
|
||||||
const evictionMessage = `Pod ${podName} was evicted due to disk pressure. This is a test infrastructure issue - the cluster doesn't have enough disk space.`;
|
|
||||||
OrchestratorLogger.logWarning(evictionMessage);
|
|
||||||
OrchestratorLogger.log(`Pod details: ${errorDetails.join('\n')}`);
|
|
||||||
throw new Error(
|
|
||||||
`${evictionMessage}\nThis indicates the test environment needs more disk space or better cleanup.\n${errorDetails.join(
|
|
||||||
'\n',
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exit code 137 (128 + 9) means SIGKILL - container was killed by system (often OOM)
|
|
||||||
// If this happened with PreStopHook failure, it might be a resource issue, not a real failure
|
|
||||||
// Be lenient if we only have PreStopHook/ExceededGracePeriod issues
|
|
||||||
if (containerExitCode === 137 && (hasPreStopHookFailure || hasExceededGracePeriod)) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`Pod ${podName} was killed (exit code 137 - likely OOM or resource limit) with PreStopHook/grace period issues. This may be a resource constraint issue rather than a build failure.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Still log the details but don't fail the test - the build might have succeeded before being killed
|
|
||||||
OrchestratorLogger.log(`Pod details: ${errorDetails.join('\n')}`);
|
|
||||||
|
|
||||||
return false; // Don't treat system kills as test failures if only PreStopHook issues
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = `K8s pod failed\n${errorDetails.join('\n')}`;
|
|
||||||
OrchestratorLogger.log(errorMessage);
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,53 +0,0 @@
|
|||||||
import { RbacAuthorizationV1Api } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
class KubernetesRole {
|
|
||||||
static async createRole(serviceAccountName: string, namespace: string, rbac: RbacAuthorizationV1Api) {
|
|
||||||
// create admin kubernetes role and role binding
|
|
||||||
const roleBinding = {
|
|
||||||
apiVersion: 'rbac.authorization.k8s.io/v1',
|
|
||||||
kind: 'RoleBinding',
|
|
||||||
metadata: {
|
|
||||||
name: `${serviceAccountName}-admin`,
|
|
||||||
namespace,
|
|
||||||
},
|
|
||||||
subjects: [
|
|
||||||
{
|
|
||||||
kind: 'ServiceAccount',
|
|
||||||
name: serviceAccountName,
|
|
||||||
namespace,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
roleRef: {
|
|
||||||
apiGroup: 'rbac.authorization.k8s.io',
|
|
||||||
kind: 'Role',
|
|
||||||
name: `${serviceAccountName}-admin`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const role = {
|
|
||||||
apiVersion: 'rbac.authorization.k8s.io/v1',
|
|
||||||
kind: 'Role',
|
|
||||||
metadata: {
|
|
||||||
name: `${serviceAccountName}-admin`,
|
|
||||||
namespace,
|
|
||||||
},
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
apiGroups: ['*'],
|
|
||||||
resources: ['*'],
|
|
||||||
verbs: ['*'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const roleBindingResponse = await rbac.createNamespacedRoleBinding(namespace, roleBinding);
|
|
||||||
const roleResponse = await rbac.createNamespacedRole(namespace, role);
|
|
||||||
|
|
||||||
return { roleBindingResponse, roleResponse };
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async deleteRole(serviceAccountName: string, namespace: string, rbac: RbacAuthorizationV1Api) {
|
|
||||||
await rbac.deleteNamespacedRoleBinding(`${serviceAccountName}-admin`, namespace);
|
|
||||||
await rbac.deleteNamespacedRole(`${serviceAccountName}-admin`, namespace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export { KubernetesRole };
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { CoreV1Api } from '@kubernetes/client-node';
|
|
||||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
|
||||||
import * as k8s from '@kubernetes/client-node';
|
|
||||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
|
||||||
import * as base64 from 'base-64';
|
|
||||||
|
|
||||||
class KubernetesSecret {
|
|
||||||
static async createSecret(
|
|
||||||
secrets: OrchestratorSecret[],
|
|
||||||
secretName: string,
|
|
||||||
namespace: string,
|
|
||||||
kubeClient: CoreV1Api,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const secret = new k8s.V1Secret();
|
|
||||||
secret.apiVersion = 'v1';
|
|
||||||
secret.kind = 'Secret';
|
|
||||||
secret.type = 'Opaque';
|
|
||||||
secret.metadata = {
|
|
||||||
name: secretName,
|
|
||||||
};
|
|
||||||
secret.data = {};
|
|
||||||
for (const buildSecret of secrets) {
|
|
||||||
secret.data[buildSecret.ParameterKey] = base64.encode(buildSecret.ParameterValue);
|
|
||||||
}
|
|
||||||
OrchestratorLogger.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(
|
|
||||||
`ExistsAlready: ${mappedSecrets.includes(secretName)} SecretsCount: ${mappedSecrets.length}`,
|
|
||||||
);
|
|
||||||
await new Promise((promise) => setTimeout(promise, 15000));
|
|
||||||
await kubeClient.createNamespacedSecret(namespace, secret);
|
|
||||||
OrchestratorLogger.log('Created secret');
|
|
||||||
} catch (error) {
|
|
||||||
OrchestratorLogger.log(`Created secret failed ${error}`);
|
|
||||||
throw new Error(`Failed to create kubernetes secret`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default KubernetesSecret;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { CoreV1Api } from '@kubernetes/client-node';
|
|
||||||
import * as k8s from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
class KubernetesServiceAccount {
|
|
||||||
static async createServiceAccount(serviceAccountName: string, namespace: string, kubeClient: CoreV1Api) {
|
|
||||||
const serviceAccount = new k8s.V1ServiceAccount();
|
|
||||||
serviceAccount.apiVersion = 'v1';
|
|
||||||
serviceAccount.kind = 'ServiceAccount';
|
|
||||||
serviceAccount.metadata = {
|
|
||||||
name: serviceAccountName,
|
|
||||||
};
|
|
||||||
serviceAccount.automountServiceAccountToken = true;
|
|
||||||
|
|
||||||
return kubeClient.createNamespacedServiceAccount(namespace, serviceAccount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default KubernetesServiceAccount;
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
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 OrchestratorLogger from '../../services/core/orchestrator-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 !== ``) {
|
|
||||||
OrchestratorLogger.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);
|
|
||||||
OrchestratorLogger.log(`Current PVCs in namespace ${namespace}`);
|
|
||||||
OrchestratorLogger.log(JSON.stringify(pvcList, undefined, 4));
|
|
||||||
if (pvcList.includes(pvcName)) {
|
|
||||||
OrchestratorLogger.log(`pvc ${pvcName} already exists`);
|
|
||||||
if (GitHub.githubInputEnabled) {
|
|
||||||
core.setOutput('volume', pvcName);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
OrchestratorLogger.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) {
|
|
||||||
let checkCount = 0;
|
|
||||||
try {
|
|
||||||
OrchestratorLogger.log(`watch Until PVC Not Pending ${name} ${namespace}`);
|
|
||||||
|
|
||||||
// Check if storage class uses WaitForFirstConsumer binding mode
|
|
||||||
// If so, skip waiting - PVC will bind when pod is created
|
|
||||||
let shouldSkipWait = false;
|
|
||||||
try {
|
|
||||||
const pvcBody = (await kubeClient.readNamespacedPersistentVolumeClaim(name, namespace)).body;
|
|
||||||
const storageClassName = pvcBody.spec?.storageClassName;
|
|
||||||
|
|
||||||
if (storageClassName) {
|
|
||||||
const kubeConfig = new k8s.KubeConfig();
|
|
||||||
kubeConfig.loadFromDefault();
|
|
||||||
const storageV1Api = kubeConfig.makeApiClient(k8s.StorageV1Api);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sc = await storageV1Api.readStorageClass(storageClassName);
|
|
||||||
const volumeBindingMode = sc.body.volumeBindingMode;
|
|
||||||
|
|
||||||
if (volumeBindingMode === 'WaitForFirstConsumer') {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`StorageClass "${storageClassName}" uses WaitForFirstConsumer binding mode. PVC will bind when pod is created. Skipping wait.`,
|
|
||||||
);
|
|
||||||
shouldSkipWait = true;
|
|
||||||
}
|
|
||||||
} catch (scError) {
|
|
||||||
// If we can't check the storage class, proceed with normal wait
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Could not check storage class binding mode: ${scError}. Proceeding with normal wait.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (pvcReadError) {
|
|
||||||
// If we can't read PVC, proceed with normal wait
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Could not read PVC to check storage class: ${pvcReadError}. Proceeding with normal wait.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldSkipWait) {
|
|
||||||
OrchestratorLogger.log(`Skipping PVC wait - will bind when pod is created`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialPhase = await this.getPVCPhase(kubeClient, name, namespace);
|
|
||||||
OrchestratorLogger.log(`Initial PVC phase: ${initialPhase}`);
|
|
||||||
|
|
||||||
// Wait until PVC is NOT Pending (i.e., Bound or Available)
|
|
||||||
await waitUntil(
|
|
||||||
async () => {
|
|
||||||
checkCount++;
|
|
||||||
const phase = await this.getPVCPhase(kubeClient, name, namespace);
|
|
||||||
|
|
||||||
// Log progress every 4 checks (every ~60 seconds)
|
|
||||||
if (checkCount % 4 === 0) {
|
|
||||||
OrchestratorLogger.log(`PVC ${name} still ${phase} (check ${checkCount})`);
|
|
||||||
|
|
||||||
// Fetch and log PVC events for diagnostics
|
|
||||||
try {
|
|
||||||
const events = await kubeClient.listNamespacedEvent(namespace);
|
|
||||||
const pvcEvents = events.body.items
|
|
||||||
.filter((x) => x.involvedObject?.kind === 'PersistentVolumeClaim' && x.involvedObject?.name === name)
|
|
||||||
.map((x) => ({
|
|
||||||
message: x.message || '',
|
|
||||||
reason: x.reason || '',
|
|
||||||
type: x.type || '',
|
|
||||||
count: x.count || 0,
|
|
||||||
}))
|
|
||||||
.slice(-5); // Get last 5 events
|
|
||||||
|
|
||||||
if (pvcEvents.length > 0) {
|
|
||||||
OrchestratorLogger.log(`PVC Events: ${JSON.stringify(pvcEvents, undefined, 2)}`);
|
|
||||||
|
|
||||||
// Check if event indicates WaitForFirstConsumer
|
|
||||||
const waitForConsumerEvent = pvcEvents.find(
|
|
||||||
(event) =>
|
|
||||||
event.reason === 'WaitForFirstConsumer' || event.message?.includes('waiting for first consumer'),
|
|
||||||
);
|
|
||||||
if (waitForConsumerEvent) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`PVC is waiting for first consumer. This is normal for WaitForFirstConsumer storage classes. Proceeding without waiting.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return true; // Exit wait loop - PVC will bind when pod is created
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore event fetch errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return phase !== 'Pending';
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timeout: 750000,
|
|
||||||
intervalBetweenAttempts: 15000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const finalPhase = await this.getPVCPhase(kubeClient, name, namespace);
|
|
||||||
OrchestratorLogger.log(`PVC phase after wait: ${finalPhase}`);
|
|
||||||
|
|
||||||
if (finalPhase === 'Pending') {
|
|
||||||
throw new Error(`PVC ${name} is still Pending after timeout`);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
core.error('Failed to watch PVC');
|
|
||||||
core.error(error.toString());
|
|
||||||
try {
|
|
||||||
const pvcBody = (await kubeClient.readNamespacedPersistentVolumeClaim(name, namespace)).body;
|
|
||||||
|
|
||||||
// Fetch PVC events for detailed diagnostics
|
|
||||||
let pvcEvents: any[] = [];
|
|
||||||
try {
|
|
||||||
const events = await kubeClient.listNamespacedEvent(namespace);
|
|
||||||
pvcEvents = events.body.items
|
|
||||||
.filter((x) => x.involvedObject?.kind === 'PersistentVolumeClaim' && x.involvedObject?.name === name)
|
|
||||||
.map((x) => ({
|
|
||||||
message: x.message || '',
|
|
||||||
reason: x.reason || '',
|
|
||||||
type: x.type || '',
|
|
||||||
count: x.count || 0,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
// Ignore event fetch errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if storage class exists
|
|
||||||
let storageClassInfo = '';
|
|
||||||
try {
|
|
||||||
const storageClassName = pvcBody.spec?.storageClassName;
|
|
||||||
if (storageClassName) {
|
|
||||||
// Create StorageV1Api from default config
|
|
||||||
const kubeConfig = new k8s.KubeConfig();
|
|
||||||
kubeConfig.loadFromDefault();
|
|
||||||
const storageV1Api = kubeConfig.makeApiClient(k8s.StorageV1Api);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sc = await storageV1Api.readStorageClass(storageClassName);
|
|
||||||
storageClassInfo = `StorageClass "${storageClassName}" exists. Provisioner: ${
|
|
||||||
sc.body.provisioner || 'unknown'
|
|
||||||
}`;
|
|
||||||
} catch (scError: any) {
|
|
||||||
storageClassInfo =
|
|
||||||
scError.statusCode === 404
|
|
||||||
? `StorageClass "${storageClassName}" does NOT exist! This is likely why the PVC is stuck in Pending.`
|
|
||||||
: `Failed to check StorageClass "${storageClassName}": ${scError.message || scError}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (scCheckError) {
|
|
||||||
// Ignore storage class check errors - not critical for diagnostics
|
|
||||||
storageClassInfo = `Could not check storage class: ${scCheckError}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.error(
|
|
||||||
`PVC Body: ${JSON.stringify(
|
|
||||||
{
|
|
||||||
phase: pvcBody.status?.phase,
|
|
||||||
conditions: pvcBody.status?.conditions,
|
|
||||||
accessModes: pvcBody.spec?.accessModes,
|
|
||||||
storageClassName: pvcBody.spec?.storageClassName,
|
|
||||||
storageRequest: pvcBody.spec?.resources?.requests?.storage,
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
4,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (storageClassInfo) {
|
|
||||||
core.error(storageClassInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pvcEvents.length > 0) {
|
|
||||||
core.error(`PVC Events: ${JSON.stringify(pvcEvents, undefined, 2)}`);
|
|
||||||
} else {
|
|
||||||
core.error('No PVC events found - this may indicate the storage provisioner is not responding');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore PVC read errors
|
|
||||||
}
|
|
||||||
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 || '';
|
|
||||||
OrchestratorLogger.log(`PVC ${name} created`);
|
|
||||||
await this.watchUntilPVCNotPending(kubeClient, name, namespace);
|
|
||||||
OrchestratorLogger.log(`PVC ${name} is ready and not pending`);
|
|
||||||
core.setOutput('volume', pvcName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default KubernetesStorage;
|
|
||||||
@@ -1,763 +0,0 @@
|
|||||||
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
|
||||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
|
||||||
import { waitUntil } from 'async-wait-until';
|
|
||||||
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
|
|
||||||
import Orchestrator from '../../orchestrator';
|
|
||||||
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;
|
|
||||||
let kubectlLogsFailedCount = 0;
|
|
||||||
const maxKubectlLogsFailures = 3;
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Streaming logs from pod: ${podName} container: ${containerName} namespace: ${namespace} ${Orchestrator.buildParameters.kubeVolumeSize}/${Orchestrator.buildParameters.containerCpu}/${Orchestrator.buildParameters.containerMemory}`,
|
|
||||||
);
|
|
||||||
const isRunning = await KubernetesPods.IsPodRunning(podName, namespace, kubeClient);
|
|
||||||
|
|
||||||
const callback = (outputChunk: string) => {
|
|
||||||
// Filter out kubectl error messages about being unable to retrieve container logs
|
|
||||||
// These errors pollute the output and don't contain useful information
|
|
||||||
const lowerChunk = outputChunk.toLowerCase();
|
|
||||||
if (lowerChunk.includes('unable to retrieve container logs')) {
|
|
||||||
OrchestratorLogger.log(`Filtered kubectl error: ${outputChunk.trim()}`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
output += outputChunk;
|
|
||||||
|
|
||||||
// split output chunk and handle per line
|
|
||||||
for (const chunk of outputChunk.split(`\n`)) {
|
|
||||||
// Skip empty chunks and kubectl error messages (case-insensitive)
|
|
||||||
const lowerCaseChunk = chunk.toLowerCase();
|
|
||||||
if (chunk.trim() && !lowerCaseChunk.includes('unable to retrieve container logs')) {
|
|
||||||
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
|
|
||||||
chunk,
|
|
||||||
shouldReadLogs,
|
|
||||||
shouldCleanup,
|
|
||||||
output,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
// Always specify container name explicitly to avoid containerd:// errors
|
|
||||||
// Use -f for running pods, --previous for terminated pods
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`kubectl logs ${podName} -c ${containerName} -n ${namespace}${isRunning ? ' -f' : ' --previous'}`,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
callback,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset failure count on success
|
|
||||||
kubectlLogsFailedCount = 0;
|
|
||||||
} catch (error: any) {
|
|
||||||
kubectlLogsFailedCount++;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
const continueStreaming = await KubernetesPods.IsPodRunning(podName, namespace, kubeClient);
|
|
||||||
OrchestratorLogger.log(`K8s logging error ${error} ${continueStreaming}`);
|
|
||||||
|
|
||||||
// Filter out kubectl error messages from the error output
|
|
||||||
const errorMessage = error?.message || error?.toString() || '';
|
|
||||||
const isKubectlLogsError =
|
|
||||||
errorMessage.includes('unable to retrieve container logs for containerd://') ||
|
|
||||||
errorMessage.toLowerCase().includes('unable to retrieve container logs');
|
|
||||||
|
|
||||||
if (isKubectlLogsError) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Kubectl unable to retrieve logs, attempt ${kubectlLogsFailedCount}/${maxKubectlLogsFailures}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If kubectl logs has failed multiple times, try reading the log file directly from the pod
|
|
||||||
// This works even if the pod is terminated, as long as it hasn't been deleted
|
|
||||||
if (kubectlLogsFailedCount >= maxKubectlLogsFailures && !isRunning && !continueStreaming) {
|
|
||||||
OrchestratorLogger.log(`Attempting to read log file directly from pod as fallback...`);
|
|
||||||
try {
|
|
||||||
// Try to read the log file from the pod
|
|
||||||
// Use kubectl exec for running pods, or try to access via PVC if pod is terminated
|
|
||||||
let logFileContent = '';
|
|
||||||
|
|
||||||
if (isRunning) {
|
|
||||||
// Pod is still running, try exec
|
|
||||||
logFileContent = await OrchestratorSystem.Run(
|
|
||||||
`kubectl exec ${podName} -c ${containerName} -n ${namespace} -- cat /home/job-log.txt 2>/dev/null || echo ""`,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Pod is terminated, try to create a temporary pod to read from the PVC
|
|
||||||
// First, check if we can still access the pod's filesystem
|
|
||||||
OrchestratorLogger.log(`Pod is terminated, attempting to read log file via temporary pod...`);
|
|
||||||
|
|
||||||
// For terminated pods, we might not be able to exec, so we'll skip this fallback
|
|
||||||
// and rely on the log file being written to the PVC (if mounted)
|
|
||||||
OrchestratorLogger.logWarning(`Cannot read log file from terminated pod via exec`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logFileContent && logFileContent.trim()) {
|
|
||||||
OrchestratorLogger.log(`Successfully read log file from pod (${logFileContent.length} chars)`);
|
|
||||||
|
|
||||||
// Process the log file content line by line
|
|
||||||
for (const line of logFileContent.split(`\n`)) {
|
|
||||||
const lowerLine = line.toLowerCase();
|
|
||||||
if (line.trim() && !lowerLine.includes('unable to retrieve container logs')) {
|
|
||||||
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
|
|
||||||
line,
|
|
||||||
shouldReadLogs,
|
|
||||||
shouldCleanup,
|
|
||||||
output,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we got the end of transmission marker
|
|
||||||
if (FollowLogStreamService.DidReceiveEndOfTransmission) {
|
|
||||||
OrchestratorLogger.log('end of log stream (from log file)');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
OrchestratorLogger.logWarning(`Log file read returned empty content, continuing with available logs`);
|
|
||||||
|
|
||||||
// If we can't read the log file, break out of the loop to return whatever logs we have
|
|
||||||
// This prevents infinite retries when kubectl logs consistently fails
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (execError: any) {
|
|
||||||
OrchestratorLogger.logWarning(`Failed to read log file from pod: ${execError}`);
|
|
||||||
|
|
||||||
// If we've exhausted all options, break to return whatever logs we have
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If pod is not running and we tried --previous but it failed, try without --previous
|
|
||||||
if (!isRunning && !continueStreaming && error?.message?.includes('previous terminated container')) {
|
|
||||||
OrchestratorLogger.log(`Previous container not found, trying current container logs...`);
|
|
||||||
try {
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`kubectl logs ${podName} -c ${containerName} -n ${namespace}`,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
callback,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If we successfully got logs, check for end of transmission
|
|
||||||
if (FollowLogStreamService.DidReceiveEndOfTransmission) {
|
|
||||||
OrchestratorLogger.log('end of log stream');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we got logs but no end marker, continue trying (might be more logs)
|
|
||||||
if (retriesAfterFinish < KubernetesTaskRunner.maxRetry) {
|
|
||||||
retriesAfterFinish++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we've exhausted retries, break
|
|
||||||
break;
|
|
||||||
} catch (fallbackError: any) {
|
|
||||||
OrchestratorLogger.log(`Fallback log fetch also failed: ${fallbackError}`);
|
|
||||||
|
|
||||||
// If both fail, continue retrying if we haven't exhausted retries
|
|
||||||
if (retriesAfterFinish < KubernetesTaskRunner.maxRetry) {
|
|
||||||
retriesAfterFinish++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only break if we've exhausted all retries
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`Could not fetch any container logs after ${KubernetesTaskRunner.maxRetry} retries`,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (continueStreaming) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (retriesAfterFinish < KubernetesTaskRunner.maxRetry) {
|
|
||||||
retriesAfterFinish++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we've exhausted retries and it's not a previous container issue, throw
|
|
||||||
if (!error?.message?.includes('previous terminated container')) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For previous container errors, we've already tried fallback, so just break
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`Could not fetch previous container logs after retries, but continuing with available logs`,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (FollowLogStreamService.DidReceiveEndOfTransmission) {
|
|
||||||
OrchestratorLogger.log('end of log stream');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// After kubectl logs loop ends, read log file as fallback to capture any messages
|
|
||||||
// written after kubectl stopped reading (e.g., "Collected Logs" from post-build)
|
|
||||||
// This ensures all log messages are included in BuildResults for test assertions
|
|
||||||
// If output is empty, we need to be more aggressive about getting logs
|
|
||||||
const needsFallback = output.trim().length === 0;
|
|
||||||
const missingCollectedLogs = !output.includes('Collected Logs');
|
|
||||||
|
|
||||||
if (needsFallback) {
|
|
||||||
OrchestratorLogger.log('Output is empty, attempting aggressive log collection fallback...');
|
|
||||||
|
|
||||||
// Give the pod a moment to finish writing logs before we try to read them
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always try fallback if output is empty, if pod is terminated, or if "Collected Logs" is missing
|
|
||||||
// The "Collected Logs" check ensures we try to get post-build messages even if we have some output
|
|
||||||
try {
|
|
||||||
const isPodStillRunning = await KubernetesPods.IsPodRunning(podName, namespace, kubeClient);
|
|
||||||
const shouldTryFallback = !isPodStillRunning || needsFallback || missingCollectedLogs;
|
|
||||||
|
|
||||||
if (shouldTryFallback) {
|
|
||||||
const reason = needsFallback
|
|
||||||
? 'output is empty'
|
|
||||||
: missingCollectedLogs
|
|
||||||
? 'Collected Logs missing from output'
|
|
||||||
: 'pod is terminated';
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Pod is ${isPodStillRunning ? 'running' : 'terminated'} and ${reason}, reading log file as fallback...`,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
// Try to read the log file from the pod
|
|
||||||
// For killed pods (OOM), kubectl exec might not work, so we try multiple approaches
|
|
||||||
// First try --previous flag for terminated containers, then try without it
|
|
||||||
let logFileContent = '';
|
|
||||||
|
|
||||||
// Try multiple approaches to get the log file
|
|
||||||
// Order matters: try terminated container first, then current, then PVC, then kubectl logs as last resort
|
|
||||||
// For K8s, the PVC is mounted at /data, so try reading from there too
|
|
||||||
const attempts = [
|
|
||||||
// For terminated pods, try --previous first
|
|
||||||
`kubectl exec ${podName} -c ${containerName} -n ${namespace} --previous -- cat /home/job-log.txt 2>/dev/null || echo ""`,
|
|
||||||
|
|
||||||
// Try current container
|
|
||||||
`kubectl exec ${podName} -c ${containerName} -n ${namespace} -- cat /home/job-log.txt 2>/dev/null || echo ""`,
|
|
||||||
|
|
||||||
// Try reading from PVC (/data) in case log was copied there
|
|
||||||
`kubectl exec ${podName} -c ${containerName} -n ${namespace} --previous -- cat /data/job-log.txt 2>/dev/null || echo ""`,
|
|
||||||
`kubectl exec ${podName} -c ${containerName} -n ${namespace} -- cat /data/job-log.txt 2>/dev/null || echo ""`,
|
|
||||||
|
|
||||||
// Try kubectl logs as fallback (might capture stdout even if exec fails)
|
|
||||||
`kubectl logs ${podName} -c ${containerName} -n ${namespace} --previous 2>/dev/null || echo ""`,
|
|
||||||
`kubectl logs ${podName} -c ${containerName} -n ${namespace} 2>/dev/null || echo ""`,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const attempt of attempts) {
|
|
||||||
// If we already have content with "Collected Logs", no need to try more
|
|
||||||
if (logFileContent && logFileContent.trim() && logFileContent.includes('Collected Logs')) {
|
|
||||||
OrchestratorLogger.log('Found "Collected Logs" in fallback content, stopping attempts.');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
OrchestratorLogger.log(`Trying fallback method: ${attempt.slice(0, 80)}...`);
|
|
||||||
const result = await OrchestratorSystem.Run(attempt, true, true);
|
|
||||||
if (result && result.trim()) {
|
|
||||||
// Prefer content that has "Collected Logs" over content that doesn't
|
|
||||||
if (!logFileContent || !logFileContent.includes('Collected Logs')) {
|
|
||||||
logFileContent = result;
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Successfully read logs using fallback method (${logFileContent.length} chars): ${attempt.slice(
|
|
||||||
0,
|
|
||||||
50,
|
|
||||||
)}...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If this content has "Collected Logs", we're done
|
|
||||||
if (logFileContent.includes('Collected Logs')) {
|
|
||||||
OrchestratorLogger.log('Fallback method successfully captured "Collected Logs".');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
OrchestratorLogger.log(`Skipping this result - already have content with "Collected Logs".`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
OrchestratorLogger.log(`Fallback method returned empty result: ${attempt.slice(0, 50)}...`);
|
|
||||||
}
|
|
||||||
} catch (attemptError: any) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Fallback method failed: ${attempt.slice(0, 50)}... Error: ${attemptError?.message || attemptError}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Continue to next attempt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!logFileContent || !logFileContent.trim()) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
'Could not read log file from pod after all fallback attempts (may be OOM-killed or pod not accessible).',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logFileContent && logFileContent.trim()) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Read log file from pod as fallback (${logFileContent.length} chars) to capture missing messages`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get the lines we already have in output to avoid duplicates
|
|
||||||
const existingLines = new Set(output.split('\n').map((line) => line.trim()));
|
|
||||||
|
|
||||||
// Process the log file content line by line and add missing lines
|
|
||||||
for (const line of logFileContent.split(`\n`)) {
|
|
||||||
const trimmedLine = line.trim();
|
|
||||||
const lowerLine = trimmedLine.toLowerCase();
|
|
||||||
|
|
||||||
// Skip empty lines, kubectl errors, and lines we already have
|
|
||||||
if (
|
|
||||||
trimmedLine &&
|
|
||||||
!lowerLine.includes('unable to retrieve container logs') &&
|
|
||||||
!existingLines.has(trimmedLine)
|
|
||||||
) {
|
|
||||||
// Process through FollowLogStreamService - it will append to output
|
|
||||||
// Don't add to output manually since handleIteration does it
|
|
||||||
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
|
|
||||||
trimmedLine,
|
|
||||||
shouldReadLogs,
|
|
||||||
shouldCleanup,
|
|
||||||
output,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (logFileError: any) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`Could not read log file from pod as fallback: ${logFileError?.message || logFileError}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Continue with existing output - this is a best-effort fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If output is still empty or missing "Collected Logs" after fallback attempts, add a warning message
|
|
||||||
// This ensures BuildResults is not completely empty, which would cause test failures
|
|
||||||
if ((needsFallback && output.trim().length === 0) || (!output.includes('Collected Logs') && shouldTryFallback)) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
'Could not retrieve "Collected Logs" from pod after all attempts. Pod may have been killed before logs were written.',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add a minimal message so BuildResults is not completely empty
|
|
||||||
// This helps with debugging and prevents test failures due to empty results
|
|
||||||
if (output.trim().length === 0) {
|
|
||||||
output = 'Pod logs unavailable - pod may have been terminated before logs could be collected.\n';
|
|
||||||
} else if (!output.includes('Collected Logs')) {
|
|
||||||
// We have some output but missing "Collected Logs" - append the fallback message
|
|
||||||
output +=
|
|
||||||
'\nPod logs incomplete - "Collected Logs" marker not found. Pod may have been terminated before post-build completed.\n';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (fallbackError: any) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`Error checking pod status for log file fallback: ${fallbackError?.message || fallbackError}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If output is empty and we hit an error, still add a message so BuildResults isn't empty
|
|
||||||
if (needsFallback && output.trim().length === 0) {
|
|
||||||
output = `Error retrieving logs: ${fallbackError?.message || fallbackError}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue with existing output - this is a best-effort fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out kubectl error messages from the final output
|
|
||||||
// These errors can be added via stderr even when kubectl fails
|
|
||||||
// We filter them out so they don't pollute the BuildResults
|
|
||||||
const lines = output.split('\n');
|
|
||||||
const filteredLines = lines.filter((line) => !line.toLowerCase().includes('unable to retrieve container logs'));
|
|
||||||
const filteredOutput = filteredLines.join('\n');
|
|
||||||
|
|
||||||
// Log if we filtered out significant content
|
|
||||||
const originalLineCount = lines.length;
|
|
||||||
const filteredLineCount = filteredLines.length;
|
|
||||||
if (originalLineCount > filteredLineCount) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Filtered out ${originalLineCount - filteredLineCount} kubectl error message(s) from output`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredOutput;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async watchUntilPodRunning(kubeClient: CoreV1Api, podName: string, namespace: string) {
|
|
||||||
let waitComplete: boolean = false;
|
|
||||||
let message = ``;
|
|
||||||
let lastPhase = '';
|
|
||||||
let consecutivePendingCount = 0;
|
|
||||||
OrchestratorLogger.log(`Watching ${podName} ${namespace}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitUntil(
|
|
||||||
async () => {
|
|
||||||
const status = await kubeClient.readNamespacedPodStatus(podName, namespace);
|
|
||||||
const phase = status?.body.status?.phase || 'Unknown';
|
|
||||||
const conditions = status?.body.status?.conditions || [];
|
|
||||||
const containerStatuses = status?.body.status?.containerStatuses || [];
|
|
||||||
|
|
||||||
// Log phase changes
|
|
||||||
if (phase !== lastPhase) {
|
|
||||||
OrchestratorLogger.log(`Pod ${podName} phase changed: ${lastPhase} -> ${phase}`);
|
|
||||||
lastPhase = phase;
|
|
||||||
consecutivePendingCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for failure conditions that mean the pod will never start (permanent failures)
|
|
||||||
// Note: We don't treat "Failed" phase as a permanent failure because the pod might have
|
|
||||||
// completed its work before being killed (OOM), and we should still try to get logs
|
|
||||||
const permanentFailureReasons = [
|
|
||||||
'Unschedulable',
|
|
||||||
'ImagePullBackOff',
|
|
||||||
'ErrImagePull',
|
|
||||||
'CreateContainerError',
|
|
||||||
'CreateContainerConfigError',
|
|
||||||
];
|
|
||||||
|
|
||||||
const hasPermanentFailureCondition = conditions.some((condition: any) =>
|
|
||||||
permanentFailureReasons.some((reason) => condition.reason?.includes(reason)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasPermanentFailureContainerStatus = containerStatuses.some((containerStatus: any) =>
|
|
||||||
permanentFailureReasons.some((reason) => containerStatus.state?.waiting?.reason?.includes(reason)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only treat permanent failures as errors - pods that completed (Failed/Succeeded) should continue
|
|
||||||
if (hasPermanentFailureCondition || hasPermanentFailureContainerStatus) {
|
|
||||||
// Get detailed failure information
|
|
||||||
const failureCondition = conditions.find((condition: any) =>
|
|
||||||
permanentFailureReasons.some((reason) => condition.reason?.includes(reason)),
|
|
||||||
);
|
|
||||||
const failureContainer = containerStatuses.find((containerStatus: any) =>
|
|
||||||
permanentFailureReasons.some((reason) => containerStatus.state?.waiting?.reason?.includes(reason)),
|
|
||||||
);
|
|
||||||
|
|
||||||
message = `Pod ${podName} failed to start (permanent failure):\nPhase: ${phase}\n`;
|
|
||||||
if (failureCondition) {
|
|
||||||
message += `Condition Reason: ${failureCondition.reason}\nCondition Message: ${failureCondition.message}\n`;
|
|
||||||
}
|
|
||||||
if (failureContainer) {
|
|
||||||
message += `Container Reason: ${failureContainer.state?.waiting?.reason}\nContainer Message: ${failureContainer.state?.waiting?.message}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log pod events for additional context
|
|
||||||
try {
|
|
||||||
const events = await kubeClient.listNamespacedEvent(namespace);
|
|
||||||
const podEvents = events.body.items
|
|
||||||
.filter((x) => x.involvedObject?.name === podName)
|
|
||||||
.map((x) => ({
|
|
||||||
message: x.message || ``,
|
|
||||||
reason: x.reason || ``,
|
|
||||||
type: x.type || ``,
|
|
||||||
}));
|
|
||||||
if (podEvents.length > 0) {
|
|
||||||
message += `\nRecent Events:\n${JSON.stringify(podEvents.slice(-5), undefined, 2)}`;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore event fetch errors
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.logWarning(message);
|
|
||||||
|
|
||||||
// For permanent failures, mark as incomplete and store the error message
|
|
||||||
// We'll throw an error after the wait loop exits
|
|
||||||
waitComplete = false;
|
|
||||||
|
|
||||||
return true; // Return true to exit wait loop
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pod is complete if it's not Pending or Unknown - it might be Running, Succeeded, or Failed
|
|
||||||
// For Failed/Succeeded pods, we still want to try to get logs, so we mark as complete
|
|
||||||
waitComplete = phase !== 'Pending' && phase !== 'Unknown';
|
|
||||||
|
|
||||||
// If pod completed (Succeeded/Failed), log it but don't throw - we'll try to get logs
|
|
||||||
if (waitComplete && phase !== 'Running') {
|
|
||||||
OrchestratorLogger.log(`Pod ${podName} completed with phase: ${phase}. Will attempt to retrieve logs.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phase === 'Pending') {
|
|
||||||
consecutivePendingCount++;
|
|
||||||
|
|
||||||
// Check for scheduling failures in events (faster than waiting for conditions)
|
|
||||||
try {
|
|
||||||
const events = await kubeClient.listNamespacedEvent(namespace);
|
|
||||||
const podEvents = events.body.items.filter((x) => x.involvedObject?.name === podName);
|
|
||||||
const failedSchedulingEvents = podEvents.filter(
|
|
||||||
(x) => x.reason === 'FailedScheduling' || x.reason === 'SchedulingGated',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (failedSchedulingEvents.length > 0) {
|
|
||||||
const schedulingMessage = failedSchedulingEvents
|
|
||||||
.map((x) => `${x.reason}: ${x.message || ''}`)
|
|
||||||
.join('; ');
|
|
||||||
message = `Pod ${podName} cannot be scheduled:\n${schedulingMessage}`;
|
|
||||||
OrchestratorLogger.logWarning(message);
|
|
||||||
waitComplete = false;
|
|
||||||
|
|
||||||
return true; // Exit wait loop to throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if pod is actively pulling an image - if so, allow more time
|
|
||||||
const isPullingImage = podEvents.some(
|
|
||||||
(x) => x.reason === 'Pulling' || x.reason === 'Pulled' || x.message?.includes('Pulling image'),
|
|
||||||
);
|
|
||||||
const hasImagePullError = podEvents.some(
|
|
||||||
(x) => x.reason === 'Failed' && (x.message?.includes('pull') || x.message?.includes('image')),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasImagePullError) {
|
|
||||||
message = `Pod ${podName} failed to pull image. Check image availability and credentials.`;
|
|
||||||
OrchestratorLogger.logWarning(message);
|
|
||||||
waitComplete = false;
|
|
||||||
|
|
||||||
return true; // Exit wait loop to throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
// If actively pulling image, reset pending count to allow more time
|
|
||||||
// Large images (like Unity 3.9GB) can take 3-5 minutes to pull
|
|
||||||
if (isPullingImage && consecutivePendingCount > 4) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Pod ${podName} is pulling image (check ${consecutivePendingCount}). This may take several minutes for large images.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Don't increment consecutivePendingCount if we're actively pulling
|
|
||||||
consecutivePendingCount = Math.max(4, consecutivePendingCount - 1);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore event fetch errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// For tests, allow more time if image is being pulled (large images need 5+ minutes)
|
|
||||||
// Otherwise fail faster if stuck in Pending (2 minutes = 8 checks at 15s interval)
|
|
||||||
const isTest = process.env['orchestratorTests'] === 'true';
|
|
||||||
const isPullingImage =
|
|
||||||
containerStatuses.some(
|
|
||||||
(cs: any) => cs.state?.waiting?.reason === 'ImagePull' || cs.state?.waiting?.reason === 'ErrImagePull',
|
|
||||||
) || conditions.some((c: any) => c.reason?.includes('Pulling'));
|
|
||||||
|
|
||||||
// Allow up to 20 minutes for image pulls in tests (80 checks), 2 minutes otherwise
|
|
||||||
const maxPendingChecks = isTest && isPullingImage ? 80 : isTest ? 8 : 80;
|
|
||||||
|
|
||||||
if (consecutivePendingCount >= maxPendingChecks) {
|
|
||||||
message = `Pod ${podName} stuck in Pending state for too long (${consecutivePendingCount} checks). This indicates a scheduling problem.`;
|
|
||||||
|
|
||||||
// Get events for context
|
|
||||||
try {
|
|
||||||
const events = await kubeClient.listNamespacedEvent(namespace);
|
|
||||||
const podEvents = events.body.items
|
|
||||||
.filter((x) => x.involvedObject?.name === podName)
|
|
||||||
.slice(-10)
|
|
||||||
.map((x) => `${x.type}: ${x.reason} - ${x.message}`);
|
|
||||||
if (podEvents.length > 0) {
|
|
||||||
message += `\n\nRecent Events:\n${podEvents.join('\n')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get pod details to check for scheduling issues
|
|
||||||
try {
|
|
||||||
const podStatus = await kubeClient.readNamespacedPodStatus(podName, namespace);
|
|
||||||
const podSpec = podStatus.body.spec;
|
|
||||||
const podStatusDetails = podStatus.body.status;
|
|
||||||
|
|
||||||
// Check container resource requests
|
|
||||||
if (podSpec?.containers?.[0]?.resources?.requests) {
|
|
||||||
const requests = podSpec.containers[0].resources.requests;
|
|
||||||
message += `\n\nContainer Resource Requests:\n CPU: ${requests.cpu || 'not set'}\n Memory: ${
|
|
||||||
requests.memory || 'not set'
|
|
||||||
}\n Ephemeral Storage: ${requests['ephemeral-storage'] || 'not set'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check node selector and tolerations
|
|
||||||
if (podSpec?.nodeSelector && Object.keys(podSpec.nodeSelector).length > 0) {
|
|
||||||
message += `\n\nNode Selector: ${JSON.stringify(podSpec.nodeSelector)}`;
|
|
||||||
}
|
|
||||||
if (podSpec?.tolerations && podSpec.tolerations.length > 0) {
|
|
||||||
message += `\n\nTolerations: ${JSON.stringify(podSpec.tolerations)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check pod conditions for scheduling issues
|
|
||||||
if (podStatusDetails?.conditions) {
|
|
||||||
const allConditions = podStatusDetails.conditions.map(
|
|
||||||
(c: any) =>
|
|
||||||
`${c.type}: ${c.status}${c.reason ? ` (${c.reason})` : ''}${
|
|
||||||
c.message ? ` - ${c.message}` : ''
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
message += `\n\nPod Conditions:\n${allConditions.join('\n')}`;
|
|
||||||
|
|
||||||
const unschedulable = podStatusDetails.conditions.find(
|
|
||||||
(c: any) => c.type === 'PodScheduled' && c.status === 'False',
|
|
||||||
);
|
|
||||||
if (unschedulable) {
|
|
||||||
message += `\n\nScheduling Issue: ${unschedulable.reason || 'Unknown'} - ${
|
|
||||||
unschedulable.message || 'No message'
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if pod is assigned to a node
|
|
||||||
message += podStatusDetails?.hostIP
|
|
||||||
? `\n\nPod assigned to node: ${podStatusDetails.hostIP}`
|
|
||||||
: `\n\nPod not yet assigned to a node (scheduling pending)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check node resources if pod is assigned
|
|
||||||
if (podStatusDetails?.hostIP) {
|
|
||||||
try {
|
|
||||||
const nodes = await kubeClient.listNode();
|
|
||||||
const hostIP = podStatusDetails.hostIP;
|
|
||||||
const assignedNode = nodes.body.items.find((n: any) =>
|
|
||||||
n.status?.addresses?.some((a: any) => a.address === hostIP),
|
|
||||||
);
|
|
||||||
if (assignedNode?.status && assignedNode.metadata?.name) {
|
|
||||||
const allocatable = assignedNode.status.allocatable || {};
|
|
||||||
message += `\n\nNode Resources (${assignedNode.metadata.name}):\n Allocatable CPU: ${
|
|
||||||
allocatable.cpu || 'unknown'
|
|
||||||
}\n Allocatable Memory: ${allocatable.memory || 'unknown'}\n Allocatable Ephemeral Storage: ${
|
|
||||||
allocatable['ephemeral-storage'] || 'unknown'
|
|
||||||
}`;
|
|
||||||
|
|
||||||
// Check for taints that might prevent scheduling
|
|
||||||
if (assignedNode.spec?.taints && assignedNode.spec.taints.length > 0) {
|
|
||||||
const taints = assignedNode.spec.taints
|
|
||||||
.map((t: any) => `${t.key}=${t.value}:${t.effect}`)
|
|
||||||
.join(', ');
|
|
||||||
message += `\n Node Taints: ${taints}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore node check errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore pod status fetch errors
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore event fetch errors
|
|
||||||
}
|
|
||||||
OrchestratorLogger.logWarning(message);
|
|
||||||
waitComplete = false;
|
|
||||||
|
|
||||||
return true; // Exit wait loop to throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log diagnostic info every 4 checks (1 minute) if still pending
|
|
||||||
if (consecutivePendingCount % 4 === 0) {
|
|
||||||
const pendingMessage = `Pod ${podName} still Pending (check ${consecutivePendingCount}/${maxPendingChecks}). Phase: ${phase}`;
|
|
||||||
const conditionMessages = conditions
|
|
||||||
.map((c: any) => `${c.type}: ${c.reason || 'N/A'} - ${c.message || 'N/A'}`)
|
|
||||||
.join('; ');
|
|
||||||
OrchestratorLogger.log(`${pendingMessage}. Conditions: ${conditionMessages || 'None'}`);
|
|
||||||
|
|
||||||
// Log events periodically to help diagnose
|
|
||||||
if (consecutivePendingCount % 8 === 0) {
|
|
||||||
try {
|
|
||||||
const events = await kubeClient.listNamespacedEvent(namespace);
|
|
||||||
const podEvents = events.body.items
|
|
||||||
.filter((x) => x.involvedObject?.name === podName)
|
|
||||||
.slice(-3)
|
|
||||||
.map((x) => `${x.type}: ${x.reason} - ${x.message}`)
|
|
||||||
.join('; ');
|
|
||||||
if (podEvents) {
|
|
||||||
OrchestratorLogger.log(`Recent pod events: ${podEvents}`);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore event fetch errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
message = `Phase:${phase} \n Reason:${conditions[0]?.reason || ''} \n Message:${
|
|
||||||
conditions[0]?.message || ''
|
|
||||||
}`;
|
|
||||||
|
|
||||||
if (waitComplete || phase !== 'Pending') return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timeout: process.env['orchestratorTests'] === 'true' ? 300000 : 2000000, // 5 minutes for tests, ~33 minutes for production
|
|
||||||
intervalBetweenAttempts: 15000, // 15 seconds
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (waitError: any) {
|
|
||||||
// If waitUntil times out or throws, get final pod status
|
|
||||||
try {
|
|
||||||
const finalStatus = await kubeClient.readNamespacedPodStatus(podName, namespace);
|
|
||||||
const phase = finalStatus?.body.status?.phase || 'Unknown';
|
|
||||||
const conditions = finalStatus?.body.status?.conditions || [];
|
|
||||||
message = `Pod ${podName} timed out waiting to start.\nFinal Phase: ${phase}\n`;
|
|
||||||
message += conditions.map((c: any) => `${c.type}: ${c.reason} - ${c.message}`).join('\n');
|
|
||||||
|
|
||||||
// Get events for context
|
|
||||||
try {
|
|
||||||
const events = await kubeClient.listNamespacedEvent(namespace);
|
|
||||||
const podEvents = events.body.items
|
|
||||||
.filter((x) => x.involvedObject?.name === podName)
|
|
||||||
.slice(-5)
|
|
||||||
.map((x) => `${x.type}: ${x.reason} - ${x.message}`);
|
|
||||||
if (podEvents.length > 0) {
|
|
||||||
message += `\n\nRecent Events:\n${podEvents.join('\n')}`;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore event fetch errors
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.logWarning(message);
|
|
||||||
} catch {
|
|
||||||
message = `Pod ${podName} timed out and could not retrieve final status: ${waitError?.message || waitError}`;
|
|
||||||
OrchestratorLogger.logWarning(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Pod ${podName} failed to start within timeout. ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only throw if we detected a permanent failure condition
|
|
||||||
// If the pod completed (Failed/Succeeded), we should still try to get logs
|
|
||||||
if (!waitComplete) {
|
|
||||||
// Check the final phase to see if it's a permanent failure or just completed
|
|
||||||
try {
|
|
||||||
const finalStatus = await kubeClient.readNamespacedPodStatus(podName, namespace);
|
|
||||||
const finalPhase = finalStatus?.body.status?.phase || 'Unknown';
|
|
||||||
if (finalPhase === 'Failed' || finalPhase === 'Succeeded') {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`Pod ${podName} completed with phase ${finalPhase} before reaching Running state. Will attempt to retrieve logs.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return true; // Allow workflow to continue and try to get logs
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If we can't check status, fall through to throw error
|
|
||||||
}
|
|
||||||
OrchestratorLogger.logWarning(`Pod ${podName} did not reach running state: ${message}`);
|
|
||||||
throw new Error(`Pod ${podName} did not start successfully: ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return waitComplete;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default KubernetesTaskRunner;
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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 { ProviderInterface } from '../provider-interface';
|
|
||||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
|
||||||
import { ProviderResource } from '../provider-resource';
|
|
||||||
import { ProviderWorkflow } from '../provider-workflow';
|
|
||||||
import { quote } from 'shell-quote';
|
|
||||||
|
|
||||||
class LocalOrchestrator implements ProviderInterface {
|
|
||||||
listResources(): Promise<ProviderResource[]> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
listWorkflow(): Promise<ProviderWorkflow[]> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
watchWorkflow(): Promise<string> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
garbageCollect(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
filter: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
previewOnly: boolean,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
olderThan: Number,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
fullCache: boolean,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
baseDependencies: boolean,
|
|
||||||
): Promise<string> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
cleanupWorkflow(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
branchName: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
|
||||||
) {}
|
|
||||||
public setupWorkflow(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildGuid: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
branchName: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
|
||||||
) {}
|
|
||||||
public async runTaskInWorkflow(
|
|
||||||
buildGuid: string,
|
|
||||||
image: string,
|
|
||||||
commands: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
mountdir: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
workingdir: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
environment: OrchestratorEnvironmentVariable[],
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
secrets: OrchestratorSecret[],
|
|
||||||
): Promise<string> {
|
|
||||||
OrchestratorLogger.log(image);
|
|
||||||
OrchestratorLogger.log(buildGuid);
|
|
||||||
OrchestratorLogger.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default LocalOrchestrator;
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import OrchestratorLogger from '../services/core/orchestrator-logger';
|
|
||||||
import { GitHubUrlInfo, generateCacheKey } from './provider-url-parser';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
export interface GitCloneResult {
|
|
||||||
success: boolean;
|
|
||||||
localPath: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GitUpdateResult {
|
|
||||||
success: boolean;
|
|
||||||
updated: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages git operations for provider repositories
|
|
||||||
*/
|
|
||||||
export class ProviderGitManager {
|
|
||||||
private static readonly CACHE_DIR = path.join(process.cwd(), '.provider-cache');
|
|
||||||
private static readonly GIT_TIMEOUT = 30000; // 30 seconds
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures the cache directory exists
|
|
||||||
*/
|
|
||||||
private static ensureCacheDir(): void {
|
|
||||||
if (!fs.existsSync(this.CACHE_DIR)) {
|
|
||||||
fs.mkdirSync(this.CACHE_DIR, { recursive: true });
|
|
||||||
OrchestratorLogger.log(`Created provider cache directory: ${this.CACHE_DIR}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the local path for a cached repository
|
|
||||||
* @param urlInfo GitHub URL information
|
|
||||||
* @returns Local path to the repository
|
|
||||||
*/
|
|
||||||
private static getLocalPath(urlInfo: GitHubUrlInfo): string {
|
|
||||||
const cacheKey = generateCacheKey(urlInfo);
|
|
||||||
|
|
||||||
return path.join(this.CACHE_DIR, cacheKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a repository is already cloned locally
|
|
||||||
* @param urlInfo GitHub URL information
|
|
||||||
* @returns True if repository exists locally
|
|
||||||
*/
|
|
||||||
private static isRepositoryCloned(urlInfo: GitHubUrlInfo): boolean {
|
|
||||||
const localPath = this.getLocalPath(urlInfo);
|
|
||||||
|
|
||||||
return fs.existsSync(localPath) && fs.existsSync(path.join(localPath, '.git'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clones a GitHub repository to the local cache
|
|
||||||
* @param urlInfo GitHub URL information
|
|
||||||
* @returns Clone result with success status and local path
|
|
||||||
*/
|
|
||||||
static async cloneRepository(urlInfo: GitHubUrlInfo): Promise<GitCloneResult> {
|
|
||||||
this.ensureCacheDir();
|
|
||||||
const localPath = this.getLocalPath(urlInfo);
|
|
||||||
|
|
||||||
// Remove existing directory if it exists
|
|
||||||
if (fs.existsSync(localPath)) {
|
|
||||||
OrchestratorLogger.log(`Removing existing directory: ${localPath}`);
|
|
||||||
fs.rmSync(localPath, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
OrchestratorLogger.log(`Cloning repository: ${urlInfo.url} to ${localPath}`);
|
|
||||||
|
|
||||||
const cloneCommand = `git clone --depth 1 --branch ${urlInfo.branch} ${urlInfo.url} "${localPath}"`;
|
|
||||||
OrchestratorLogger.log(`Executing: ${cloneCommand}`);
|
|
||||||
|
|
||||||
const { stderr } = await execAsync(cloneCommand, {
|
|
||||||
timeout: this.GIT_TIMEOUT,
|
|
||||||
cwd: this.CACHE_DIR,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (stderr && !stderr.includes('warning')) {
|
|
||||||
OrchestratorLogger.log(`Git clone stderr: ${stderr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Successfully cloned repository to: ${localPath}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
localPath,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
const errorMessage = `Failed to clone repository ${urlInfo.url}: ${error.message}`;
|
|
||||||
OrchestratorLogger.log(`Error: ${errorMessage}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
localPath,
|
|
||||||
error: errorMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a locally cloned repository
|
|
||||||
* @param urlInfo GitHub URL information
|
|
||||||
* @returns Update result with success status and whether it was updated
|
|
||||||
*/
|
|
||||||
static async updateRepository(urlInfo: GitHubUrlInfo): Promise<GitUpdateResult> {
|
|
||||||
const localPath = this.getLocalPath(urlInfo);
|
|
||||||
|
|
||||||
if (!this.isRepositoryCloned(urlInfo)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
updated: false,
|
|
||||||
error: 'Repository not found locally',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
OrchestratorLogger.log(`Updating repository: ${localPath}`);
|
|
||||||
|
|
||||||
// Fetch latest changes
|
|
||||||
await execAsync('git fetch origin', {
|
|
||||||
timeout: this.GIT_TIMEOUT,
|
|
||||||
cwd: localPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if there are updates
|
|
||||||
const { stdout: statusOutput } = await execAsync(`git status -uno`, {
|
|
||||||
timeout: this.GIT_TIMEOUT,
|
|
||||||
cwd: localPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasUpdates =
|
|
||||||
statusOutput.includes('Your branch is behind') || statusOutput.includes('can be fast-forwarded');
|
|
||||||
|
|
||||||
if (hasUpdates) {
|
|
||||||
OrchestratorLogger.log(`Updates available, pulling latest changes...`);
|
|
||||||
|
|
||||||
// Reset to origin/branch to get latest changes
|
|
||||||
await execAsync(`git reset --hard origin/${urlInfo.branch}`, {
|
|
||||||
timeout: this.GIT_TIMEOUT,
|
|
||||||
cwd: localPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Repository updated successfully`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
updated: true,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
OrchestratorLogger.log(`Repository is already up to date`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
updated: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
const errorMessage = `Failed to update repository ${localPath}: ${error.message}`;
|
|
||||||
OrchestratorLogger.log(`Error: ${errorMessage}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
updated: false,
|
|
||||||
error: errorMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures a repository is available locally (clone if needed, update if exists)
|
|
||||||
* @param urlInfo GitHub URL information
|
|
||||||
* @returns Local path to the repository
|
|
||||||
*/
|
|
||||||
static async ensureRepositoryAvailable(urlInfo: GitHubUrlInfo): Promise<string> {
|
|
||||||
this.ensureCacheDir();
|
|
||||||
|
|
||||||
if (this.isRepositoryCloned(urlInfo)) {
|
|
||||||
OrchestratorLogger.log(`Repository already exists locally, checking for updates...`);
|
|
||||||
const updateResult = await this.updateRepository(urlInfo);
|
|
||||||
|
|
||||||
if (!updateResult.success) {
|
|
||||||
OrchestratorLogger.log(`Failed to update repository, attempting fresh clone...`);
|
|
||||||
const cloneResult = await this.cloneRepository(urlInfo);
|
|
||||||
if (!cloneResult.success) {
|
|
||||||
throw new Error(`Failed to ensure repository availability: ${cloneResult.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return cloneResult.localPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.getLocalPath(urlInfo);
|
|
||||||
} else {
|
|
||||||
OrchestratorLogger.log(`Repository not found locally, cloning...`);
|
|
||||||
const cloneResult = await this.cloneRepository(urlInfo);
|
|
||||||
|
|
||||||
if (!cloneResult.success) {
|
|
||||||
throw new Error(`Failed to clone repository: ${cloneResult.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return cloneResult.localPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the path to the provider module within a repository
|
|
||||||
* @param urlInfo GitHub URL information
|
|
||||||
* @param localPath Local path to the repository
|
|
||||||
* @returns Path to the provider module
|
|
||||||
*/
|
|
||||||
static getProviderModulePath(urlInfo: GitHubUrlInfo, localPath: string): string {
|
|
||||||
if (urlInfo.path) {
|
|
||||||
return path.join(localPath, urlInfo.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look for common provider entry points
|
|
||||||
const commonEntryPoints = [
|
|
||||||
'index.js',
|
|
||||||
'index.ts',
|
|
||||||
'src/index.js',
|
|
||||||
'src/index.ts',
|
|
||||||
'lib/index.js',
|
|
||||||
'lib/index.ts',
|
|
||||||
'dist/index.js',
|
|
||||||
'dist/index.js.map',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const entryPoint of commonEntryPoints) {
|
|
||||||
const fullPath = path.join(localPath, entryPoint);
|
|
||||||
if (fs.existsSync(fullPath)) {
|
|
||||||
OrchestratorLogger.log(`Found provider entry point: ${entryPoint}`);
|
|
||||||
|
|
||||||
return fullPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to repository root
|
|
||||||
OrchestratorLogger.log(`No specific entry point found, using repository root`);
|
|
||||||
|
|
||||||
return localPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans up old cached repositories (optional maintenance)
|
|
||||||
* @param maxAgeDays Maximum age in days for cached repositories
|
|
||||||
*/
|
|
||||||
static async cleanupOldRepositories(maxAgeDays: number = 30): Promise<void> {
|
|
||||||
this.ensureCacheDir();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(this.CACHE_DIR, { withFileTypes: true });
|
|
||||||
const now = Date.now();
|
|
||||||
const maxAge = maxAgeDays * 24 * 60 * 60 * 1000; // Convert to milliseconds
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const entryPath = path.join(this.CACHE_DIR, entry.name);
|
|
||||||
const stats = fs.statSync(entryPath);
|
|
||||||
|
|
||||||
if (now - stats.mtime.getTime() > maxAge) {
|
|
||||||
OrchestratorLogger.log(`Cleaning up old repository: ${entry.name}`);
|
|
||||||
fs.rmSync(entryPath, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.log(`Error during cleanup: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import BuildParameters from '../../build-parameters';
|
|
||||||
import OrchestratorEnvironmentVariable from '../options/orchestrator-environment-variable';
|
|
||||||
import OrchestratorSecret from '../options/orchestrator-secret';
|
|
||||||
import { ProviderResource } from './provider-resource';
|
|
||||||
import { ProviderWorkflow } from './provider-workflow';
|
|
||||||
|
|
||||||
export interface ProviderInterface {
|
|
||||||
cleanupWorkflow(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
branchName: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
|
||||||
): any;
|
|
||||||
setupWorkflow(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildGuid: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
branchName: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
|
||||||
): any;
|
|
||||||
runTaskInWorkflow(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildGuid: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
image: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
commands: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
mountdir: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
workingdir: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
environment: OrchestratorEnvironmentVariable[],
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
secrets: OrchestratorSecret[],
|
|
||||||
): Promise<string>;
|
|
||||||
garbageCollect(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
filter: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
previewOnly: boolean,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
olderThan: Number,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
fullCache: boolean,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
baseDependencies: boolean,
|
|
||||||
): Promise<string>;
|
|
||||||
listResources(): Promise<ProviderResource[]>;
|
|
||||||
listWorkflow(): Promise<ProviderWorkflow[]>;
|
|
||||||
watchWorkflow(): Promise<string>;
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { ProviderInterface } from './provider-interface';
|
|
||||||
import BuildParameters from '../../build-parameters';
|
|
||||||
import OrchestratorLogger from '../services/core/orchestrator-logger';
|
|
||||||
import { parseProviderSource, logProviderSource, ProviderSourceInfo } from './provider-url-parser';
|
|
||||||
import { ProviderGitManager } from './provider-git-manager';
|
|
||||||
|
|
||||||
// import path from 'path'; // Not currently used
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dynamically load a provider package by name, URL, or path.
|
|
||||||
* @param providerSource Provider source (name, URL, or path)
|
|
||||||
* @param buildParameters Build parameters passed to the provider constructor
|
|
||||||
* @throws Error when the provider cannot be loaded or does not implement ProviderInterface
|
|
||||||
*/
|
|
||||||
export default async function loadProvider(
|
|
||||||
providerSource: string,
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
): Promise<ProviderInterface> {
|
|
||||||
OrchestratorLogger.log(`Loading provider: ${providerSource}`);
|
|
||||||
|
|
||||||
// Parse the provider source to determine its type
|
|
||||||
const sourceInfo = parseProviderSource(providerSource);
|
|
||||||
logProviderSource(providerSource, sourceInfo);
|
|
||||||
|
|
||||||
let modulePath: string;
|
|
||||||
let importedModule: any;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Handle different source types
|
|
||||||
switch (sourceInfo.type) {
|
|
||||||
case 'github': {
|
|
||||||
OrchestratorLogger.log(`Processing GitHub repository: ${sourceInfo.owner}/${sourceInfo.repo}`);
|
|
||||||
|
|
||||||
// Ensure the repository is available locally
|
|
||||||
const localRepoPath = await ProviderGitManager.ensureRepositoryAvailable(sourceInfo);
|
|
||||||
|
|
||||||
// Get the path to the provider module within the repository
|
|
||||||
modulePath = ProviderGitManager.getProviderModulePath(sourceInfo, localRepoPath);
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Loading provider from: ${modulePath}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'local': {
|
|
||||||
modulePath = sourceInfo.path;
|
|
||||||
OrchestratorLogger.log(`Loading provider from local path: ${modulePath}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'npm': {
|
|
||||||
modulePath = sourceInfo.packageName;
|
|
||||||
OrchestratorLogger.log(`Loading provider from NPM package: ${modulePath}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
// Fallback to built-in providers or direct import
|
|
||||||
const providerModuleMap: Record<string, string> = {
|
|
||||||
aws: './aws',
|
|
||||||
k8s: './k8s',
|
|
||||||
test: './test',
|
|
||||||
'local-docker': './docker',
|
|
||||||
'local-system': './local',
|
|
||||||
local: './local',
|
|
||||||
};
|
|
||||||
|
|
||||||
modulePath = providerModuleMap[providerSource] || providerSource;
|
|
||||||
OrchestratorLogger.log(`Loading provider from module path: ${modulePath}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import the module
|
|
||||||
importedModule = await import(modulePath);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to load provider package '${providerSource}': ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the provider class/function
|
|
||||||
const Provider = importedModule.default || importedModule;
|
|
||||||
|
|
||||||
// Validate that we have a constructor
|
|
||||||
if (typeof Provider !== 'function') {
|
|
||||||
throw new TypeError(`Provider package '${providerSource}' does not export a constructor function`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instantiate the provider
|
|
||||||
let instance: any;
|
|
||||||
try {
|
|
||||||
instance = new Provider(buildParameters);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to instantiate provider '${providerSource}': ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that the instance implements the required interface
|
|
||||||
const requiredMethods = [
|
|
||||||
'cleanupWorkflow',
|
|
||||||
'setupWorkflow',
|
|
||||||
'runTaskInWorkflow',
|
|
||||||
'garbageCollect',
|
|
||||||
'listResources',
|
|
||||||
'listWorkflow',
|
|
||||||
'watchWorkflow',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const method of requiredMethods) {
|
|
||||||
if (typeof instance[method] !== 'function') {
|
|
||||||
throw new TypeError(
|
|
||||||
`Provider package '${providerSource}' does not implement ProviderInterface. Missing method '${method}'.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Successfully loaded provider: ${providerSource}`);
|
|
||||||
|
|
||||||
return instance as ProviderInterface;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ProviderLoader class for backward compatibility and additional utilities
|
|
||||||
*/
|
|
||||||
export class ProviderLoader {
|
|
||||||
/**
|
|
||||||
* Dynamically loads a provider by name, URL, or path (wrapper around loadProvider function)
|
|
||||||
* @param providerSource - The provider source (name, URL, or path) to load
|
|
||||||
* @param buildParameters - Build parameters to pass to the provider constructor
|
|
||||||
* @returns Promise<ProviderInterface> - The loaded provider instance
|
|
||||||
* @throws Error if provider package is missing or doesn't implement ProviderInterface
|
|
||||||
*/
|
|
||||||
static async loadProvider(providerSource: string, buildParameters: BuildParameters): Promise<ProviderInterface> {
|
|
||||||
return loadProvider(providerSource, buildParameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a list of available provider names
|
|
||||||
* @returns string[] - Array of available provider names
|
|
||||||
*/
|
|
||||||
static getAvailableProviders(): string[] {
|
|
||||||
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans up old cached repositories
|
|
||||||
* @param maxAgeDays Maximum age in days for cached repositories (default: 30)
|
|
||||||
*/
|
|
||||||
static async cleanupCache(maxAgeDays: number = 30): Promise<void> {
|
|
||||||
await ProviderGitManager.cleanupOldRepositories(maxAgeDays);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets information about a provider source without loading it
|
|
||||||
* @param providerSource The provider source to analyze
|
|
||||||
* @returns ProviderSourceInfo object with parsed details
|
|
||||||
*/
|
|
||||||
static analyzeProviderSource(providerSource: string): ProviderSourceInfo {
|
|
||||||
return parseProviderSource(providerSource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export class ProviderResource {
|
|
||||||
public Name!: string;
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import OrchestratorLogger from '../services/core/orchestrator-logger';
|
|
||||||
|
|
||||||
export interface GitHubUrlInfo {
|
|
||||||
type: 'github';
|
|
||||||
owner: string;
|
|
||||||
repo: string;
|
|
||||||
branch?: string;
|
|
||||||
path?: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalPathInfo {
|
|
||||||
type: 'local';
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NpmPackageInfo {
|
|
||||||
type: 'npm';
|
|
||||||
packageName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProviderSourceInfo = GitHubUrlInfo | LocalPathInfo | NpmPackageInfo;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a provider source string and determines its type and details
|
|
||||||
* @param source The provider source string (URL, path, or package name)
|
|
||||||
* @returns ProviderSourceInfo object with parsed details
|
|
||||||
*/
|
|
||||||
export function parseProviderSource(source: string): ProviderSourceInfo {
|
|
||||||
// Check if it's a GitHub URL
|
|
||||||
const githubMatch = source.match(
|
|
||||||
/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?(?:tree\/([^/]+))?(?:\/(.+))?$/,
|
|
||||||
);
|
|
||||||
if (githubMatch) {
|
|
||||||
const [, owner, repo, branch, path] = githubMatch;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'github',
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
branch: branch || 'main',
|
|
||||||
path: path || '',
|
|
||||||
url: `https://github.com/${owner}/${repo}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a GitHub SSH URL
|
|
||||||
const githubSshMatch = source.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?\/?(?:tree\/([^/]+))?(?:\/(.+))?$/);
|
|
||||||
if (githubSshMatch) {
|
|
||||||
const [, owner, repo, branch, path] = githubSshMatch;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'github',
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
branch: branch || 'main',
|
|
||||||
path: path || '',
|
|
||||||
url: `https://github.com/${owner}/${repo}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a shorthand GitHub reference (owner/repo)
|
|
||||||
const shorthandMatch = source.match(/^([^/@]+)\/([^/@]+)(?:@([^/]+))?(?:\/(.+))?$/);
|
|
||||||
if (shorthandMatch && !source.startsWith('.') && !source.startsWith('/') && !source.includes('\\')) {
|
|
||||||
const [, owner, repo, branch, path] = shorthandMatch;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'github',
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
branch: branch || 'main',
|
|
||||||
path: path || '',
|
|
||||||
url: `https://github.com/${owner}/${repo}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a local path
|
|
||||||
if (source.startsWith('./') || source.startsWith('../') || source.startsWith('/') || source.includes('\\')) {
|
|
||||||
return {
|
|
||||||
type: 'local',
|
|
||||||
path: source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to npm package
|
|
||||||
return {
|
|
||||||
type: 'npm',
|
|
||||||
packageName: source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a cache key for a GitHub repository
|
|
||||||
* @param urlInfo GitHub URL information
|
|
||||||
* @returns Cache key string
|
|
||||||
*/
|
|
||||||
export function generateCacheKey(urlInfo: GitHubUrlInfo): string {
|
|
||||||
return `github_${urlInfo.owner}_${urlInfo.repo}_${urlInfo.branch}`.replace(/[^\w-]/g, '_');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates if a string looks like a valid GitHub URL or reference
|
|
||||||
* @param source The source string to validate
|
|
||||||
* @returns True if it looks like a GitHub reference
|
|
||||||
*/
|
|
||||||
export function isGitHubSource(source: string): boolean {
|
|
||||||
const parsed = parseProviderSource(source);
|
|
||||||
|
|
||||||
return parsed.type === 'github';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs the parsed provider source information
|
|
||||||
* @param source The original source string
|
|
||||||
* @param parsed The parsed source information
|
|
||||||
*/
|
|
||||||
export function logProviderSource(source: string, parsed: ProviderSourceInfo): void {
|
|
||||||
OrchestratorLogger.log(`Provider source: ${source}`);
|
|
||||||
switch (parsed.type) {
|
|
||||||
case 'github':
|
|
||||||
OrchestratorLogger.log(` Type: GitHub repository`);
|
|
||||||
OrchestratorLogger.log(` Owner: ${parsed.owner}`);
|
|
||||||
OrchestratorLogger.log(` Repository: ${parsed.repo}`);
|
|
||||||
OrchestratorLogger.log(` Branch: ${parsed.branch}`);
|
|
||||||
if (parsed.path) {
|
|
||||||
OrchestratorLogger.log(` Path: ${parsed.path}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'local':
|
|
||||||
OrchestratorLogger.log(` Type: Local path`);
|
|
||||||
OrchestratorLogger.log(` Path: ${parsed.path}`);
|
|
||||||
break;
|
|
||||||
case 'npm':
|
|
||||||
OrchestratorLogger.log(` Type: NPM package`);
|
|
||||||
OrchestratorLogger.log(` Package: ${parsed.packageName}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export class ProviderWorkflow {
|
|
||||||
public Name!: string;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import BuildParameters from '../../../build-parameters';
|
|
||||||
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
|
||||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
|
||||||
import { ProviderInterface } from '../provider-interface';
|
|
||||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
|
||||||
import { ProviderResource } from '../provider-resource';
|
|
||||||
import { ProviderWorkflow } from '../provider-workflow';
|
|
||||||
|
|
||||||
class TestOrchestrator implements ProviderInterface {
|
|
||||||
listResources(): Promise<ProviderResource[]> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
listWorkflow(): Promise<ProviderWorkflow[]> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
watchWorkflow(): Promise<string> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
garbageCollect(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
filter: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
previewOnly: boolean,
|
|
||||||
): Promise<string> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
cleanupWorkflow(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
branchName: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
|
||||||
) {}
|
|
||||||
setupWorkflow(
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildGuid: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
branchName: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
|
||||||
) {}
|
|
||||||
public async runTaskInWorkflow(
|
|
||||||
commands: string,
|
|
||||||
buildGuid: string,
|
|
||||||
image: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
mountdir: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
workingdir: string,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
environment: OrchestratorEnvironmentVariable[],
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
secrets: OrchestratorSecret[],
|
|
||||||
): Promise<string> {
|
|
||||||
OrchestratorLogger.log(image);
|
|
||||||
OrchestratorLogger.log(buildGuid);
|
|
||||||
OrchestratorLogger.log(commands);
|
|
||||||
|
|
||||||
return await new Promise((result) => {
|
|
||||||
result(commands);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default TestOrchestrator;
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
import { assert } from 'node:console';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import Orchestrator from '../orchestrator';
|
|
||||||
import OrchestratorLogger from '../services/core/orchestrator-logger';
|
|
||||||
import { OrchestratorFolders } from '../options/orchestrator-folders';
|
|
||||||
import { OrchestratorSystem } from '../services/core/orchestrator-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 || '{}');
|
|
||||||
Orchestrator.buildParameters = buildParameter;
|
|
||||||
await Caching.PushToCache(
|
|
||||||
Cli.options!['cachePushTo'],
|
|
||||||
Cli.options!['cachePushFrom'],
|
|
||||||
Cli.options!['artifactName'] || '',
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.log(`${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@CliFunction(`cache-pull`, `pull from cache`)
|
|
||||||
static async cachePull() {
|
|
||||||
try {
|
|
||||||
const buildParameter = JSON.parse(process.env.BUILD_PARAMETERS || '{}');
|
|
||||||
Orchestrator.buildParameters = buildParameter;
|
|
||||||
await Caching.PullFromCache(
|
|
||||||
Cli.options!['cachePushFrom'],
|
|
||||||
Cli.options!['cachePushTo'],
|
|
||||||
Cli.options!['artifactName'] || '',
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.log(`${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async PushToCache(cacheFolder: string, sourceFolder: string, cacheArtifactName: string) {
|
|
||||||
OrchestratorLogger.log(`Pushing to cache ${sourceFolder}`);
|
|
||||||
cacheArtifactName = cacheArtifactName.replace(' ', '');
|
|
||||||
const startPath = process.cwd();
|
|
||||||
let compressionSuffix = '';
|
|
||||||
if (Orchestrator.buildParameters.useCompressionStrategy === true) {
|
|
||||||
compressionSuffix = `.lz4`;
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(`Compression: ${Orchestrator.buildParameters.useCompressionStrategy} ${compressionSuffix}`);
|
|
||||||
try {
|
|
||||||
if (!(await fileExists(cacheFolder))) {
|
|
||||||
await OrchestratorSystem.Run(`mkdir -p ${cacheFolder}`);
|
|
||||||
}
|
|
||||||
process.chdir(path.resolve(sourceFolder, '..'));
|
|
||||||
|
|
||||||
if (Orchestrator.buildParameters.orchestratorDebug === true) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Hashed cache folder ${await LfsHashing.hashAllFiles(sourceFolder)} ${sourceFolder} ${path.basename(
|
|
||||||
sourceFolder,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const contents = await fs.promises.readdir(path.basename(sourceFolder));
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`There is ${contents.length} files/dir in the source folder ${path.basename(sourceFolder)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (contents.length === 0) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Did not push source folder to cache because it was empty ${path.basename(sourceFolder)}`,
|
|
||||||
);
|
|
||||||
process.chdir(`${startPath}`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check disk space before creating tar archive and clean up if needed
|
|
||||||
let diskUsagePercent = 0;
|
|
||||||
try {
|
|
||||||
const diskCheckOutput = await OrchestratorSystem.Run(`df . 2>/dev/null || df /data 2>/dev/null || true`);
|
|
||||||
OrchestratorLogger.log(`Disk space before tar: ${diskCheckOutput}`);
|
|
||||||
|
|
||||||
// Parse disk usage percentage (e.g., "72G 72G 196M 100%")
|
|
||||||
const usageMatch = diskCheckOutput.match(/(\d+)%/);
|
|
||||||
if (usageMatch) {
|
|
||||||
diskUsagePercent = Number.parseInt(usageMatch[1], 10);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore disk check errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// If disk usage is high (>90%), proactively clean up old cache files
|
|
||||||
if (diskUsagePercent > 90) {
|
|
||||||
OrchestratorLogger.log(`Disk usage is ${diskUsagePercent}% - cleaning up old cache files before tar operation`);
|
|
||||||
try {
|
|
||||||
const cacheParent = path.dirname(cacheFolder);
|
|
||||||
if (await fileExists(cacheParent)) {
|
|
||||||
// Try to fix permissions first to avoid permission denied errors
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`chmod -R u+w ${cacheParent} 2>/dev/null || chown -R $(whoami) ${cacheParent} 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove cache files older than 6 hours (more aggressive than 1 day)
|
|
||||||
// Use multiple methods to handle permission issues
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`find ${cacheParent} -name "*.tar*" -type f -mmin +360 -delete 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Try with sudo if available
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`sudo find ${cacheParent} -name "*.tar*" -type f -mmin +360 -delete 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// As last resort, try to remove files one by one
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`find ${cacheParent} -name "*.tar*" -type f -mmin +360 -exec rm -f {} + 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also try to remove old cache directories
|
|
||||||
await OrchestratorSystem.Run(`find ${cacheParent} -type d -empty -delete 2>/dev/null || true`);
|
|
||||||
|
|
||||||
// If disk is still very high (>95%), be even more aggressive
|
|
||||||
if (diskUsagePercent > 95) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Disk usage is very high (${diskUsagePercent}%), performing aggressive cleanup...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove files older than 1 hour
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`sudo find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Cleanup completed. Checking disk space again...`);
|
|
||||||
const diskCheckAfter = await OrchestratorSystem.Run(`df . 2>/dev/null || df /data 2>/dev/null || true`);
|
|
||||||
OrchestratorLogger.log(`Disk space after cleanup: ${diskCheckAfter}`);
|
|
||||||
|
|
||||||
// Check disk usage again after cleanup
|
|
||||||
let diskUsageAfterCleanup = 0;
|
|
||||||
try {
|
|
||||||
const usageMatchAfter = diskCheckAfter.match(/(\d+)%/);
|
|
||||||
if (usageMatchAfter) {
|
|
||||||
diskUsageAfterCleanup = Number.parseInt(usageMatchAfter[1], 10);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parsing errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// If disk is still at 100% after cleanup, skip tar operation to prevent hang.
|
|
||||||
// Do NOT fail the build here – it's better to skip caching than to fail the job
|
|
||||||
// due to shared CI disk pressure.
|
|
||||||
if (diskUsageAfterCleanup >= 100) {
|
|
||||||
const message = `Cannot create cache archive: disk is still at ${diskUsageAfterCleanup}% after cleanup. Tar operation would hang. Skipping cache push; please free up disk space manually if this persists.`;
|
|
||||||
OrchestratorLogger.logWarning(message);
|
|
||||||
RemoteClientLogger.log(message);
|
|
||||||
|
|
||||||
// Restore working directory before early return
|
|
||||||
process.chdir(`${startPath}`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (cleanupError) {
|
|
||||||
// If cleanupError is our disk space error, rethrow it
|
|
||||||
if (cleanupError instanceof Error && cleanupError.message.includes('Cannot create cache archive')) {
|
|
||||||
throw cleanupError;
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(`Proactive cleanup failed: ${cleanupError}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up any existing incomplete tar files
|
|
||||||
try {
|
|
||||||
await OrchestratorSystem.Run(`rm -f ${cacheArtifactName}.tar${compressionSuffix} 2>/dev/null || true`);
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Add timeout to tar command to prevent hanging when disk is full
|
|
||||||
// Use timeout command with 10 minute limit (600 seconds) if available
|
|
||||||
// Check if timeout command exists, otherwise use regular tar
|
|
||||||
const tarCommand = `tar -cf ${cacheArtifactName}.tar${compressionSuffix} "${path.basename(sourceFolder)}"`;
|
|
||||||
let tarCommandToRun = tarCommand;
|
|
||||||
try {
|
|
||||||
// Check if timeout command is available
|
|
||||||
await OrchestratorSystem.Run(`which timeout > /dev/null 2>&1`, true, true);
|
|
||||||
|
|
||||||
// Use timeout if available (600 seconds = 10 minutes)
|
|
||||||
tarCommandToRun = `timeout 600 ${tarCommand}`;
|
|
||||||
} catch {
|
|
||||||
// timeout command not available, use regular tar
|
|
||||||
// Note: This could still hang if disk is full, but the disk space check above should prevent this
|
|
||||||
tarCommandToRun = tarCommand;
|
|
||||||
}
|
|
||||||
|
|
||||||
await OrchestratorSystem.Run(tarCommandToRun);
|
|
||||||
} catch (error: any) {
|
|
||||||
// Check if error is due to disk space or timeout
|
|
||||||
const errorMessage = error?.message || error?.toString() || '';
|
|
||||||
if (
|
|
||||||
errorMessage.includes('No space left') ||
|
|
||||||
errorMessage.includes('Wrote only') ||
|
|
||||||
errorMessage.includes('timeout') ||
|
|
||||||
errorMessage.includes('Terminated')
|
|
||||||
) {
|
|
||||||
OrchestratorLogger.log(`Disk space error detected. Attempting aggressive cleanup...`);
|
|
||||||
|
|
||||||
// Try to clean up old cache files more aggressively
|
|
||||||
try {
|
|
||||||
const cacheParent = path.dirname(cacheFolder);
|
|
||||||
if (await fileExists(cacheParent)) {
|
|
||||||
// Try to fix permissions first to avoid permission denied errors
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`chmod -R u+w ${cacheParent} 2>/dev/null || chown -R $(whoami) ${cacheParent} 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove cache files older than 1 hour (very aggressive)
|
|
||||||
// Use multiple methods to handle permission issues
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`sudo find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// As last resort, try to remove files one by one
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`find ${cacheParent} -name "*.tar*" -type f -mmin +60 -exec rm -f {} + 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove empty cache directories
|
|
||||||
await OrchestratorSystem.Run(`find ${cacheParent} -type d -empty -delete 2>/dev/null || true`);
|
|
||||||
|
|
||||||
// Also try to clean up the entire cache folder if it's getting too large
|
|
||||||
const cacheRoot = path.resolve(cacheParent, '..');
|
|
||||||
if (await fileExists(cacheRoot)) {
|
|
||||||
// Try to fix permissions for cache root too
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`chmod -R u+w ${cacheRoot} 2>/dev/null || chown -R $(whoami) ${cacheRoot} 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove cache entries older than 30 minutes
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`find ${cacheRoot} -name "*.tar*" -type f -mmin +30 -delete 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`sudo find ${cacheRoot} -name "*.tar*" -type f -mmin +30 -delete 2>/dev/null || true`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(`Aggressive cleanup completed. Retrying tar operation...`);
|
|
||||||
|
|
||||||
// Retry the tar operation once after cleanup
|
|
||||||
let retrySucceeded = false;
|
|
||||||
try {
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`tar -cf ${cacheArtifactName}.tar${compressionSuffix} "${path.basename(sourceFolder)}"`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If retry succeeds, mark it - we'll continue normally without throwing
|
|
||||||
retrySucceeded = true;
|
|
||||||
} catch (retryError: any) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to create cache archive after cleanup. Original error: ${errorMessage}. Retry error: ${
|
|
||||||
retryError?.message || retryError
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If retry succeeded, don't throw the original error - let execution continue after catch block
|
|
||||||
if (!retrySucceeded) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we get here, retry succeeded - execution will continue after the catch block
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to create cache archive due to insufficient disk space. Error: ${errorMessage}. Cleanup not possible - cache folder missing.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (cleanupError: any) {
|
|
||||||
OrchestratorLogger.log(`Cleanup attempt failed: ${cleanupError}`);
|
|
||||||
throw new Error(
|
|
||||||
`Failed to create cache archive due to insufficient disk space. Error: ${errorMessage}. Cleanup failed: ${
|
|
||||||
cleanupError?.message || cleanupError
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await OrchestratorSystem.Run(`du ${cacheArtifactName}.tar${compressionSuffix}`);
|
|
||||||
assert(await fileExists(`${cacheArtifactName}.tar${compressionSuffix}`), 'cache archive exists');
|
|
||||||
assert(await fileExists(path.basename(sourceFolder)), 'source folder exists');
|
|
||||||
|
|
||||||
// Ensure the cache folder directory exists before moving the file
|
|
||||||
// (it might have been deleted by cleanup if it was empty)
|
|
||||||
if (!(await fileExists(cacheFolder))) {
|
|
||||||
await OrchestratorSystem.Run(`mkdir -p ${cacheFolder}`);
|
|
||||||
}
|
|
||||||
await OrchestratorSystem.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 = ``) {
|
|
||||||
OrchestratorLogger.log(`Pulling from cache ${destinationFolder} ${Orchestrator.buildParameters.skipCache}`);
|
|
||||||
if (`${Orchestrator.buildParameters.skipCache}` === `true`) {
|
|
||||||
OrchestratorLogger.log(`Skipping cache debugSkipCache is true`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cacheArtifactName = cacheArtifactName.replace(' ', '');
|
|
||||||
let compressionSuffix = '';
|
|
||||||
if (Orchestrator.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 OrchestratorSystem.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 OrchestratorLogger.log(`cache key ${cacheArtifactName} selection ${cacheSelection}`);
|
|
||||||
|
|
||||||
if (await fileExists(`${cacheSelection}.tar${compressionSuffix}`)) {
|
|
||||||
// Check disk space before extraction to prevent hangs
|
|
||||||
let diskUsagePercent = 0;
|
|
||||||
try {
|
|
||||||
const diskCheckOutput = await OrchestratorSystem.Run(`df . 2>/dev/null || df /data 2>/dev/null || true`);
|
|
||||||
const usageMatch = diskCheckOutput.match(/(\d+)%/);
|
|
||||||
if (usageMatch) {
|
|
||||||
diskUsagePercent = Number.parseInt(usageMatch[1], 10);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore disk check errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// If disk is at 100%, skip cache extraction to prevent hangs
|
|
||||||
if (diskUsagePercent >= 100) {
|
|
||||||
const message = `Disk is at ${diskUsagePercent}% - skipping cache extraction to prevent hang. Cache may be incomplete or corrupted.`;
|
|
||||||
OrchestratorLogger.logWarning(message);
|
|
||||||
RemoteClientLogger.logWarning(message);
|
|
||||||
|
|
||||||
// Continue without cache - build will proceed without cached Library
|
|
||||||
process.chdir(startPath);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate tar file integrity before extraction
|
|
||||||
try {
|
|
||||||
// Use tar -t to test the archive without extracting (fast check)
|
|
||||||
// This will fail if the archive is corrupted
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`tar -tf ${cacheSelection}.tar${compressionSuffix} > /dev/null 2>&1 || (echo "Tar file validation failed" && exit 1)`,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
const message = `Cache archive ${cacheSelection}.tar${compressionSuffix} appears to be corrupted or incomplete. Skipping cache extraction.`;
|
|
||||||
OrchestratorLogger.logWarning(message);
|
|
||||||
RemoteClientLogger.logWarning(message);
|
|
||||||
|
|
||||||
// Continue without cache - build will proceed without cached Library
|
|
||||||
process.chdir(startPath);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultsFolder = `results${Orchestrator.buildParameters.buildGuid}`;
|
|
||||||
await OrchestratorSystem.Run(`mkdir -p ${resultsFolder}`);
|
|
||||||
RemoteClientLogger.log(`cache item exists ${cacheFolder}/${cacheSelection}.tar${compressionSuffix}`);
|
|
||||||
const fullResultsFolder = path.join(cacheFolder, resultsFolder);
|
|
||||||
|
|
||||||
// Extract with timeout to prevent infinite hangs
|
|
||||||
try {
|
|
||||||
let tarExtractCommand = `tar -xf ${cacheSelection}.tar${compressionSuffix} -C ${fullResultsFolder}`;
|
|
||||||
|
|
||||||
// Add timeout if available (600 seconds = 10 minutes)
|
|
||||||
try {
|
|
||||||
await OrchestratorSystem.Run(`which timeout > /dev/null 2>&1`, true, true);
|
|
||||||
tarExtractCommand = `timeout 600 ${tarExtractCommand}`;
|
|
||||||
} catch {
|
|
||||||
// timeout command not available, use regular tar
|
|
||||||
}
|
|
||||||
|
|
||||||
await OrchestratorSystem.Run(tarExtractCommand);
|
|
||||||
} catch (extractError: any) {
|
|
||||||
const errorMessage = extractError?.message || extractError?.toString() || '';
|
|
||||||
|
|
||||||
// Check for common tar errors that indicate corruption or disk issues
|
|
||||||
if (
|
|
||||||
errorMessage.includes('Unexpected EOF') ||
|
|
||||||
errorMessage.includes('rmtlseek') ||
|
|
||||||
errorMessage.includes('No space left') ||
|
|
||||||
errorMessage.includes('timeout') ||
|
|
||||||
errorMessage.includes('Terminated')
|
|
||||||
) {
|
|
||||||
const message = `Cache extraction failed (likely due to corrupted archive or disk space): ${errorMessage}. Continuing without cache.`;
|
|
||||||
OrchestratorLogger.logWarning(message);
|
|
||||||
RemoteClientLogger.logWarning(message);
|
|
||||||
|
|
||||||
// Continue without cache - build will proceed without cached Library
|
|
||||||
process.chdir(startPath);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-throw other errors
|
|
||||||
throw extractError;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 OrchestratorSystem.Run(
|
|
||||||
`mv "${path.join(fullResultsFolder, path.basename(destinationFolder))}" "${destinationParentFolder}"`,
|
|
||||||
);
|
|
||||||
const contents = await fs.promises.readdir(
|
|
||||||
path.join(destinationParentFolder, path.basename(destinationFolder)),
|
|
||||||
);
|
|
||||||
OrchestratorLogger.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 ${OrchestratorFolders.purgeRemoteCaching}`);
|
|
||||||
fs.promises.rmdir(OrchestratorFolders.cacheFolder, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,504 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import Orchestrator from '../orchestrator';
|
|
||||||
import { OrchestratorFolders } from '../options/orchestrator-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 OrchestratorLogger from '../services/core/orchestrator-logger';
|
|
||||||
import { CliFunction } from '../../cli/cli-functions-repository';
|
|
||||||
import { OrchestratorSystem } from '../services/core/orchestrator-system';
|
|
||||||
import YAML from 'yaml';
|
|
||||||
import GitHub from '../../github';
|
|
||||||
import BuildParameters from '../../build-parameters';
|
|
||||||
import { Cli } from '../../cli/cli';
|
|
||||||
import OrchestratorOptions from '../options/orchestrator-options';
|
|
||||||
import ResourceTracking from '../services/core/resource-tracking';
|
|
||||||
|
|
||||||
export class RemoteClient {
|
|
||||||
@CliFunction(`remote-cli-pre-build`, `sets up a repository, usually before a game-ci build`)
|
|
||||||
static async setupRemoteClient() {
|
|
||||||
OrchestratorLogger.log(`bootstrap game ci orchestrator...`);
|
|
||||||
await ResourceTracking.logDiskUsageSnapshot('remote-cli-pre-build (start)');
|
|
||||||
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');
|
|
||||||
|
|
||||||
// For K8s, ensure stdout is unbuffered so messages are captured immediately
|
|
||||||
if (OrchestratorOptions.providerStrategy === 'k8s') {
|
|
||||||
process.stdout.setDefaultEncoding('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) {
|
|
||||||
// Always write to log file so output can be collected by providers
|
|
||||||
if (element.trim()) {
|
|
||||||
fs.appendFileSync(logFile, `${element}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For K8s, also write to stdout so kubectl logs can capture it
|
|
||||||
if (OrchestratorOptions.providerStrategy === 'k8s') {
|
|
||||||
// Write to stdout so kubectl logs can capture it - ensure newline is included
|
|
||||||
// Stdout flushes automatically on newline, so no explicit flush needed
|
|
||||||
process.stdout.write(`${element}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
process.stdin.on('end', () => {
|
|
||||||
if (lingeringLine) {
|
|
||||||
// Always write to log file so output can be collected by providers
|
|
||||||
fs.appendFileSync(logFile, `${lingeringLine}\n`);
|
|
||||||
|
|
||||||
// For K8s, also write to stdout so kubectl logs can capture it
|
|
||||||
if (OrchestratorOptions.providerStrategy === 'k8s') {
|
|
||||||
// Stdout flushes automatically on newline
|
|
||||||
process.stdout.write(`${lingeringLine}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(lingeringLine);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@CliFunction(`remote-cli-post-build`, `runs a orchestrator build`)
|
|
||||||
public static async remoteClientPostBuild(): Promise<string> {
|
|
||||||
try {
|
|
||||||
RemoteClientLogger.log(`Running POST build tasks`);
|
|
||||||
|
|
||||||
// Ensure cache key is present in logs for assertions
|
|
||||||
RemoteClientLogger.log(`CACHE_KEY=${Orchestrator.buildParameters.cacheKey}`);
|
|
||||||
OrchestratorLogger.log(`${Orchestrator.buildParameters.cacheKey}`);
|
|
||||||
|
|
||||||
// Guard: only push Library cache if the folder exists and has contents
|
|
||||||
try {
|
|
||||||
const libraryFolderHost = OrchestratorFolders.libraryFolderAbsolute;
|
|
||||||
if (fs.existsSync(libraryFolderHost)) {
|
|
||||||
let libraryEntries: string[] = [];
|
|
||||||
try {
|
|
||||||
libraryEntries = await fs.promises.readdir(libraryFolderHost);
|
|
||||||
} catch {
|
|
||||||
libraryEntries = [];
|
|
||||||
}
|
|
||||||
if (libraryEntries.length > 0) {
|
|
||||||
await Caching.PushToCache(
|
|
||||||
OrchestratorFolders.ToLinuxFolder(`${OrchestratorFolders.cacheFolderForCacheKeyFull}/Library`),
|
|
||||||
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.libraryFolderAbsolute),
|
|
||||||
`lib-${Orchestrator.buildParameters.buildGuid}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
RemoteClientLogger.log(`Skipping Library cache push (folder is empty)`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RemoteClientLogger.log(`Skipping Library cache push (folder missing)`);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
RemoteClientLogger.logWarning(`Library cache push skipped with error: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard: only push Build cache if the folder exists and has contents
|
|
||||||
try {
|
|
||||||
const buildFolderHost = OrchestratorFolders.projectBuildFolderAbsolute;
|
|
||||||
if (fs.existsSync(buildFolderHost)) {
|
|
||||||
let buildEntries: string[] = [];
|
|
||||||
try {
|
|
||||||
buildEntries = await fs.promises.readdir(buildFolderHost);
|
|
||||||
} catch {
|
|
||||||
buildEntries = [];
|
|
||||||
}
|
|
||||||
if (buildEntries.length > 0) {
|
|
||||||
await Caching.PushToCache(
|
|
||||||
OrchestratorFolders.ToLinuxFolder(`${OrchestratorFolders.cacheFolderForCacheKeyFull}/build`),
|
|
||||||
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.projectBuildFolderAbsolute),
|
|
||||||
`build-${Orchestrator.buildParameters.buildGuid}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
RemoteClientLogger.log(`Skipping Build cache push (folder is empty)`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RemoteClientLogger.log(`Skipping Build cache push (folder missing)`);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
RemoteClientLogger.logWarning(`Build cache push skipped with error: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!BuildParameters.shouldUseRetainedWorkspaceMode(Orchestrator.buildParameters)) {
|
|
||||||
const uniqueJobFolderLinux = OrchestratorFolders.ToLinuxFolder(
|
|
||||||
OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
fs.existsSync(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute) ||
|
|
||||||
fs.existsSync(uniqueJobFolderLinux)
|
|
||||||
) {
|
|
||||||
await OrchestratorSystem.Run(`rm -r ${uniqueJobFolderLinux} || true`);
|
|
||||||
} else {
|
|
||||||
RemoteClientLogger.log(`Skipping cleanup; unique job folder missing`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await RemoteClient.runCustomHookFiles(`after-build`);
|
|
||||||
|
|
||||||
// WIP - need to give the pod permissions to create config map
|
|
||||||
await RemoteClientLogger.handleLogManagementPostJob();
|
|
||||||
} catch (error: any) {
|
|
||||||
// Log error but don't fail - post-build tasks are best-effort
|
|
||||||
RemoteClientLogger.logWarning(`Post-build task error: ${error.message}`);
|
|
||||||
OrchestratorLogger.log(`Post-build task error: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure success marker is always present in logs for tests, even if post-build tasks failed
|
|
||||||
// For K8s, kubectl logs reads from stdout/stderr, so we must write to stdout
|
|
||||||
// For all providers, we write to stdout so it gets piped through the log stream
|
|
||||||
// The log stream will capture it and add it to BuildResults
|
|
||||||
const successMessage = `Activation successful`;
|
|
||||||
|
|
||||||
// Write directly to log file first to ensure it's captured even if pipe fails
|
|
||||||
// This is critical for all providers, especially K8s where timing matters
|
|
||||||
try {
|
|
||||||
const logFilePath = Orchestrator.isOrchestratorEnvironment
|
|
||||||
? `/home/job-log.txt`
|
|
||||||
: path.join(process.cwd(), 'temp', 'job-log.txt');
|
|
||||||
if (fs.existsSync(path.dirname(logFilePath))) {
|
|
||||||
fs.appendFileSync(logFilePath, `${successMessage}\n`);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If direct file write fails, continue with other methods
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to stdout so it gets piped through remote-cli-log-stream when invoked via pipe
|
|
||||||
// This ensures the message is captured in BuildResults for all providers
|
|
||||||
// Use synchronous write and ensure newline is included for proper flushing
|
|
||||||
process.stdout.write(`${successMessage}\n`, 'utf8');
|
|
||||||
|
|
||||||
// For K8s, also write to stderr as a backup since kubectl logs reads from both stdout and stderr
|
|
||||||
// This ensures the message is captured even if stdout pipe has issues
|
|
||||||
if (OrchestratorOptions.providerStrategy === 'k8s') {
|
|
||||||
process.stderr.write(`${successMessage}\n`, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure stdout is flushed before process exits (critical for K8s where process might exit quickly)
|
|
||||||
// For non-TTY streams, we need to explicitly ensure the write completes
|
|
||||||
if (!process.stdout.isTTY) {
|
|
||||||
// Give the pipe a moment to process the write
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also log via OrchestratorLogger and RemoteClientLogger for GitHub Actions and log file
|
|
||||||
// This ensures the message appears in log files for providers that read from log files
|
|
||||||
// RemoteClientLogger.log writes directly to the log file, which is important for providers
|
|
||||||
// that read from the log file rather than stdout
|
|
||||||
RemoteClientLogger.log(successMessage);
|
|
||||||
OrchestratorLogger.log(successMessage);
|
|
||||||
await ResourceTracking.logDiskUsageSnapshot('remote-cli-post-build (end)');
|
|
||||||
|
|
||||||
return new Promise((result) => result(``));
|
|
||||||
}
|
|
||||||
static async runCustomHookFiles(hookLifecycle: string) {
|
|
||||||
RemoteClientLogger.log(`RunCustomHookFiles: ${hookLifecycle}`);
|
|
||||||
const gameCiCustomHooksPath = path.join(OrchestratorFolders.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 OrchestratorSystem.Run(fileContentsObject.commands);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
RemoteClientLogger.log(JSON.stringify(error, undefined, 4));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public static async bootstrapRepository() {
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`mkdir -p ${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute)}`,
|
|
||||||
);
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`mkdir -p ${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.cacheFolderForCacheKeyFull)}`,
|
|
||||||
);
|
|
||||||
await RemoteClient.cloneRepoWithoutLFSFiles();
|
|
||||||
await RemoteClient.sizeOfFolder(
|
|
||||||
'repo before lfs cache pull',
|
|
||||||
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.repoPathAbsolute),
|
|
||||||
);
|
|
||||||
const lfsHashes = await LfsHashing.createLFSHashFiles();
|
|
||||||
if (fs.existsSync(OrchestratorFolders.libraryFolderAbsolute)) {
|
|
||||||
RemoteClientLogger.logWarning(`!Warning!: The Unity library was included in the git repository`);
|
|
||||||
}
|
|
||||||
await Caching.PullFromCache(
|
|
||||||
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.lfsCacheFolderFull),
|
|
||||||
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.lfsFolderAbsolute),
|
|
||||||
`${lfsHashes.lfsGuidSum}`,
|
|
||||||
);
|
|
||||||
await RemoteClient.sizeOfFolder('repo after lfs cache pull', OrchestratorFolders.repoPathAbsolute);
|
|
||||||
await RemoteClient.pullLatestLFS();
|
|
||||||
await RemoteClient.sizeOfFolder('repo before lfs git pull', OrchestratorFolders.repoPathAbsolute);
|
|
||||||
await Caching.PushToCache(
|
|
||||||
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.lfsCacheFolderFull),
|
|
||||||
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.lfsFolderAbsolute),
|
|
||||||
`${lfsHashes.lfsGuidSum}`,
|
|
||||||
);
|
|
||||||
await Caching.PullFromCache(
|
|
||||||
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.libraryCacheFolderFull),
|
|
||||||
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.libraryFolderAbsolute),
|
|
||||||
);
|
|
||||||
await RemoteClient.sizeOfFolder('repo after library cache pull', OrchestratorFolders.repoPathAbsolute);
|
|
||||||
await Caching.handleCachePurging();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async sizeOfFolder(message: string, folder: string) {
|
|
||||||
if (Orchestrator.buildParameters.orchestratorDebug) {
|
|
||||||
OrchestratorLogger.log(`Size of ${message}`);
|
|
||||||
await OrchestratorSystem.Run(`du -sh ${folder}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async cloneRepoWithoutLFSFiles() {
|
|
||||||
process.chdir(`${OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute}`);
|
|
||||||
if (
|
|
||||||
fs.existsSync(OrchestratorFolders.repoPathAbsolute) &&
|
|
||||||
!fs.existsSync(path.join(OrchestratorFolders.repoPathAbsolute, `.git`))
|
|
||||||
) {
|
|
||||||
await OrchestratorSystem.Run(`rm -r ${OrchestratorFolders.repoPathAbsolute}`);
|
|
||||||
OrchestratorLogger.log(`${OrchestratorFolders.repoPathAbsolute} repo exists, but no git folder, cleaning up`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
BuildParameters.shouldUseRetainedWorkspaceMode(Orchestrator.buildParameters) &&
|
|
||||||
fs.existsSync(path.join(OrchestratorFolders.repoPathAbsolute, `.git`))
|
|
||||||
) {
|
|
||||||
process.chdir(OrchestratorFolders.repoPathAbsolute);
|
|
||||||
RemoteClientLogger.log(
|
|
||||||
`${
|
|
||||||
OrchestratorFolders.repoPathAbsolute
|
|
||||||
} repo exists - skipping clone - retained workspace mode ${BuildParameters.shouldUseRetainedWorkspaceMode(
|
|
||||||
Orchestrator.buildParameters,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
await OrchestratorSystem.Run(`git fetch && git reset --hard ${Orchestrator.buildParameters.gitSha}`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
|
|
||||||
await OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
|
|
||||||
RemoteClientLogger.log(`Cloning the repository being built:`);
|
|
||||||
await OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
|
|
||||||
await OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
|
|
||||||
try {
|
|
||||||
const depthArgument = OrchestratorOptions.cloneDepth !== '0' ? `--depth ${OrchestratorOptions.cloneDepth}` : '';
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`git clone ${depthArgument} ${OrchestratorFolders.targetBuildRepoUrl} ${path.basename(
|
|
||||||
OrchestratorFolders.repoPathAbsolute,
|
|
||||||
)}`.trim(),
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
process.chdir(OrchestratorFolders.repoPathAbsolute);
|
|
||||||
await OrchestratorSystem.Run(`git lfs install`);
|
|
||||||
assert(fs.existsSync(`.git`), 'git folder exists');
|
|
||||||
RemoteClientLogger.log(`${Orchestrator.buildParameters.branch}`);
|
|
||||||
|
|
||||||
// Ensure refs exist (tags and PR refs)
|
|
||||||
await OrchestratorSystem.Run(`git fetch --all --tags || true`);
|
|
||||||
const branchForPrFetch = Orchestrator.buildParameters.branch || '';
|
|
||||||
if (branchForPrFetch.startsWith('pull/')) {
|
|
||||||
// Extract PR number and fetch only that specific ref (e.g., pull/731/merge -> 731)
|
|
||||||
const prNumber = branchForPrFetch.split('/')[1];
|
|
||||||
if (prNumber) {
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`git fetch origin +refs/pull/${prNumber}/merge:refs/remotes/origin/pull/${prNumber}/merge +refs/pull/${prNumber}/head:refs/remotes/origin/pull/${prNumber}/head || true`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const targetSha = Orchestrator.buildParameters.gitSha;
|
|
||||||
const targetBranch = Orchestrator.buildParameters.branch;
|
|
||||||
if (targetSha) {
|
|
||||||
try {
|
|
||||||
await OrchestratorSystem.Run(`git checkout ${targetSha}`);
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
await OrchestratorSystem.Run(`git fetch origin ${targetSha} || true`);
|
|
||||||
await OrchestratorSystem.Run(`git checkout ${targetSha}`);
|
|
||||||
} catch (error) {
|
|
||||||
RemoteClientLogger.logWarning(`Falling back to branch checkout; SHA not found: ${targetSha}`);
|
|
||||||
try {
|
|
||||||
await OrchestratorSystem.Run(`git checkout ${targetBranch}`);
|
|
||||||
} catch {
|
|
||||||
if ((targetBranch || '').startsWith('pull/')) {
|
|
||||||
await OrchestratorSystem.Run(`git checkout origin/${targetBranch}`);
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await OrchestratorSystem.Run(`git checkout ${targetBranch}`);
|
|
||||||
} catch (_error) {
|
|
||||||
if ((targetBranch || '').startsWith('pull/')) {
|
|
||||||
await OrchestratorSystem.Run(`git checkout origin/${targetBranch}`);
|
|
||||||
} else {
|
|
||||||
throw _error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 ${Orchestrator.buildParameters.branch}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async replaceLargePackageReferencesWithSharedReferences() {
|
|
||||||
OrchestratorLogger.log(`Use Shared Pkgs ${Orchestrator.buildParameters.useLargePackages}`);
|
|
||||||
GitHub.updateGitHubCheck(`Use Shared Pkgs ${Orchestrator.buildParameters.useLargePackages}`, ``);
|
|
||||||
if (Orchestrator.buildParameters.useLargePackages) {
|
|
||||||
const filePath = path.join(OrchestratorFolders.projectPathAbsolute, `Packages/manifest.json`);
|
|
||||||
let manifest = fs.readFileSync(filePath, 'utf8');
|
|
||||||
manifest = manifest.replace(/LargeContent/g, '../../../LargeContent');
|
|
||||||
fs.writeFileSync(filePath, manifest);
|
|
||||||
OrchestratorLogger.log(`Package Manifest \n ${manifest}`);
|
|
||||||
GitHub.updateGitHubCheck(`Package Manifest \n ${manifest}`, ``);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async pullLatestLFS() {
|
|
||||||
process.chdir(OrchestratorFolders.repoPathAbsolute);
|
|
||||||
await OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge -- %f"`);
|
|
||||||
await OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process"`);
|
|
||||||
if (Orchestrator.buildParameters.skipLfs) {
|
|
||||||
RemoteClientLogger.log(`Skipping LFS pull (skipLfs=true)`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Best effort: try plain pull first (works for public repos or pre-configured auth)
|
|
||||||
try {
|
|
||||||
await OrchestratorSystem.Run(`git lfs pull`, true);
|
|
||||||
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
|
||||||
RemoteClientLogger.log(`Pulled LFS files without explicit token configuration`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
/* no-op: best-effort git lfs pull without tokens may fail */
|
|
||||||
void 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try with GIT_PRIVATE_TOKEN
|
|
||||||
try {
|
|
||||||
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
|
|
||||||
if (gitPrivateToken) {
|
|
||||||
RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
|
|
||||||
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
|
|
||||||
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
|
|
||||||
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`git config --global url."https://${gitPrivateToken}@github.com/".insteadOf "https://github.com/"`,
|
|
||||||
);
|
|
||||||
await OrchestratorSystem.Run(`git lfs pull`, true);
|
|
||||||
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
|
||||||
RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
RemoteClientLogger.logCliError(`Failed with GIT_PRIVATE_TOKEN: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try with GITHUB_TOKEN
|
|
||||||
try {
|
|
||||||
const githubToken = process.env.GITHUB_TOKEN;
|
|
||||||
if (githubToken) {
|
|
||||||
RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
|
|
||||||
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
|
|
||||||
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
|
|
||||||
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`git config --global url."https://${githubToken}@github.com/".insteadOf "https://github.com/"`,
|
|
||||||
);
|
|
||||||
await OrchestratorSystem.Run(`git lfs pull`, true);
|
|
||||||
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
|
||||||
RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
RemoteClientLogger.logCliError(`Failed with GITHUB_TOKEN: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we get here, all strategies failed; continue without failing the build
|
|
||||||
RemoteClientLogger.logWarning(`Proceeding without LFS files (no tokens or pull failed)`);
|
|
||||||
}
|
|
||||||
static async handleRetainedWorkspace() {
|
|
||||||
RemoteClientLogger.log(
|
|
||||||
`Retained Workspace: ${BuildParameters.shouldUseRetainedWorkspaceMode(Orchestrator.buildParameters)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Log cache key explicitly to aid debugging and assertions
|
|
||||||
OrchestratorLogger.log(`Cache Key: ${Orchestrator.buildParameters.cacheKey}`);
|
|
||||||
if (
|
|
||||||
BuildParameters.shouldUseRetainedWorkspaceMode(Orchestrator.buildParameters) &&
|
|
||||||
fs.existsSync(OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute)) &&
|
|
||||||
fs.existsSync(OrchestratorFolders.ToLinuxFolder(path.join(OrchestratorFolders.repoPathAbsolute, `.git`)))
|
|
||||||
) {
|
|
||||||
OrchestratorLogger.log(`Retained Workspace Already Exists!`);
|
|
||||||
process.chdir(OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.repoPathAbsolute));
|
|
||||||
await OrchestratorSystem.Run(`git fetch --all --tags || true`);
|
|
||||||
const retainedBranchForPrFetch = Orchestrator.buildParameters.branch || '';
|
|
||||||
if (retainedBranchForPrFetch.startsWith('pull/')) {
|
|
||||||
// Extract PR number and fetch only that specific ref (e.g., pull/731/merge -> 731)
|
|
||||||
const prNumber = retainedBranchForPrFetch.split('/')[1];
|
|
||||||
if (prNumber) {
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`git fetch origin +refs/pull/${prNumber}/merge:refs/remotes/origin/pull/${prNumber}/merge +refs/pull/${prNumber}/head:refs/remotes/origin/pull/${prNumber}/head || true`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await OrchestratorSystem.Run(`git lfs pull`);
|
|
||||||
await OrchestratorSystem.Run(`git lfs checkout || true`);
|
|
||||||
const sha = Orchestrator.buildParameters.gitSha;
|
|
||||||
const branch = Orchestrator.buildParameters.branch;
|
|
||||||
try {
|
|
||||||
await OrchestratorSystem.Run(`git reset --hard "${sha}"`);
|
|
||||||
await OrchestratorSystem.Run(`git checkout ${sha}`);
|
|
||||||
} catch {
|
|
||||||
RemoteClientLogger.logWarning(`Retained workspace: SHA not found, falling back to branch ${branch}`);
|
|
||||||
try {
|
|
||||||
await OrchestratorSystem.Run(`git checkout ${branch}`);
|
|
||||||
} catch (error) {
|
|
||||||
if ((branch || '').startsWith('pull/')) {
|
|
||||||
await OrchestratorSystem.Run(`git checkout origin/${branch}`);
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import OrchestratorLogger from '../services/core/orchestrator-logger';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import Orchestrator from '../orchestrator';
|
|
||||||
import OrchestratorOptions from '../options/orchestrator-options';
|
|
||||||
|
|
||||||
export class RemoteClientLogger {
|
|
||||||
private static get LogFilePath() {
|
|
||||||
// Use a cross-platform temporary directory for local development
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return path.join(process.cwd(), 'temp', 'job-log.txt');
|
|
||||||
}
|
|
||||||
|
|
||||||
return path.join(`/home`, `job-log.txt`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static log(message: string) {
|
|
||||||
const finalMessage = `[Client] ${message}`;
|
|
||||||
this.appendToFile(finalMessage);
|
|
||||||
OrchestratorLogger.log(finalMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static logCliError(message: string) {
|
|
||||||
OrchestratorLogger.log(`[Client][Error] ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static logCliDiagnostic(message: string) {
|
|
||||||
OrchestratorLogger.log(`[Client][Diagnostic] ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static logWarning(message: string) {
|
|
||||||
OrchestratorLogger.logWarning(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static appendToFile(message: string) {
|
|
||||||
if (Orchestrator.isOrchestratorEnvironment) {
|
|
||||||
// Ensure the directory exists before writing
|
|
||||||
const logDirectory = path.dirname(RemoteClientLogger.LogFilePath);
|
|
||||||
if (!fs.existsSync(logDirectory)) {
|
|
||||||
fs.mkdirSync(logDirectory, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.appendFileSync(RemoteClientLogger.LogFilePath, `${message}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async handleLogManagementPostJob() {
|
|
||||||
if (OrchestratorOptions.providerStrategy !== 'k8s') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const collectedLogsMessage = `Collected Logs`;
|
|
||||||
|
|
||||||
// Write to log file first so it's captured even if kubectl has issues
|
|
||||||
// This ensures the message is available in BuildResults when logs are read from the file
|
|
||||||
RemoteClientLogger.appendToFile(collectedLogsMessage);
|
|
||||||
|
|
||||||
// For K8s, write to stdout/stderr so kubectl logs can capture it
|
|
||||||
// This is critical because kubectl logs reads from stdout/stderr, not from GitHub Actions logs
|
|
||||||
// Write multiple times to increase chance of capture if kubectl is having issues
|
|
||||||
if (OrchestratorOptions.providerStrategy === 'k8s') {
|
|
||||||
// Write to stdout multiple times to increase chance of capture
|
|
||||||
for (let index = 0; index < 3; index++) {
|
|
||||||
process.stdout.write(`${collectedLogsMessage}\n`, 'utf8');
|
|
||||||
process.stderr.write(`${collectedLogsMessage}\n`, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure stdout/stderr are flushed
|
|
||||||
if (!process.stdout.isTTY) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also log via OrchestratorLogger for GitHub Actions
|
|
||||||
OrchestratorLogger.log(collectedLogsMessage);
|
|
||||||
|
|
||||||
// check for log file not existing
|
|
||||||
if (!fs.existsSync(RemoteClientLogger.LogFilePath)) {
|
|
||||||
const logFileMissingMessage = `Log file does not exist`;
|
|
||||||
if (OrchestratorOptions.providerStrategy === 'k8s') {
|
|
||||||
process.stdout.write(`${logFileMissingMessage}\n`, 'utf8');
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(logFileMissingMessage);
|
|
||||||
|
|
||||||
// check if Orchestrator.isOrchestratorEnvironment is true, log
|
|
||||||
if (!Orchestrator.isOrchestratorEnvironment) {
|
|
||||||
const notCloudEnvironmentMessage = `Orchestrator is not running in a cloud environment, not collecting logs`;
|
|
||||||
if (OrchestratorOptions.providerStrategy === 'k8s') {
|
|
||||||
process.stdout.write(`${notCloudEnvironmentMessage}\n`, 'utf8');
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(notCloudEnvironmentMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const logFileExistsMessage = `Log file exist`;
|
|
||||||
if (OrchestratorOptions.providerStrategy === 'k8s') {
|
|
||||||
process.stdout.write(`${logFileExistsMessage}\n`, 'utf8');
|
|
||||||
}
|
|
||||||
OrchestratorLogger.log(logFileExistsMessage);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
||||||
|
|
||||||
// let hashedLogs = fs.readFileSync(RemoteClientLogger.LogFilePath).toString();
|
|
||||||
//
|
|
||||||
// hashedLogs = md5(hashedLogs);
|
|
||||||
//
|
|
||||||
// for (let index = 0; index < 3; index++) {
|
|
||||||
// OrchestratorLogger.log(`LOGHASH: ${hashedLogs}`);
|
|
||||||
// const logs = fs.readFileSync(RemoteClientLogger.LogFilePath).toString();
|
|
||||||
// OrchestratorLogger.log(`LOGS: ${Buffer.from(logs).toString('base64')}`);
|
|
||||||
// OrchestratorLogger.log(
|
|
||||||
// `Game CI's "Orchestrator 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,5 +0,0 @@
|
|||||||
# Hot Runner Protocol
|
|
||||||
|
|
||||||
Extensible runner registration and persistent Unity editor provider protocol.
|
|
||||||
|
|
||||||
See GitHub Issue for full specification.
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import GitHub from '../../../github';
|
|
||||||
import Orchestrator from '../../orchestrator';
|
|
||||||
import { OrchestratorStatics } from '../../options/orchestrator-statics';
|
|
||||||
import OrchestratorLogger from './orchestrator-logger';
|
|
||||||
import * as core from '@actions/core';
|
|
||||||
|
|
||||||
export class FollowLogStreamService {
|
|
||||||
static Reset() {
|
|
||||||
FollowLogStreamService.DidReceiveEndOfTransmission = false;
|
|
||||||
}
|
|
||||||
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');
|
|
||||||
FollowLogStreamService.DidReceiveEndOfTransmission = true;
|
|
||||||
shouldReadLogs = false;
|
|
||||||
} else if (message.includes('Rebuilding Library because the asset database could not be found!')) {
|
|
||||||
GitHub.updateGitHubCheck(`Library was not found, importing new Library`, ``);
|
|
||||||
core.warning('LIBRARY NOT FOUND!');
|
|
||||||
core.setOutput('library-found', 'false');
|
|
||||||
} else if (message.includes('Build succeeded')) {
|
|
||||||
GitHub.updateGitHubCheck(`Build succeeded`, `Build succeeded`);
|
|
||||||
core.setOutput('build-result', 'success');
|
|
||||||
} else if (message.includes('Build fail')) {
|
|
||||||
GitHub.updateGitHubCheck(
|
|
||||||
`Build failed\n${FollowLogStreamService.errors}`,
|
|
||||||
`Build failed`,
|
|
||||||
`failure`,
|
|
||||||
`completed`,
|
|
||||||
);
|
|
||||||
core.setOutput('build-result', 'failed');
|
|
||||||
core.setFailed('unity build failed');
|
|
||||||
core.error('BUILD FAILED!');
|
|
||||||
} else if (message.toLowerCase().includes('error ')) {
|
|
||||||
core.error(message);
|
|
||||||
FollowLogStreamService.errors += `\n${message}`;
|
|
||||||
} else if (message.toLowerCase().includes('error: ')) {
|
|
||||||
core.error(message);
|
|
||||||
FollowLogStreamService.errors += `\n${message}`;
|
|
||||||
} else if (message.toLowerCase().includes('command failed: ')) {
|
|
||||||
FollowLogStreamService.errors += `\n${message}`;
|
|
||||||
} else if (message.toLowerCase().includes('invalid ')) {
|
|
||||||
FollowLogStreamService.errors += `\n${message}`;
|
|
||||||
} else if (message.toLowerCase().includes('incompatible ')) {
|
|
||||||
FollowLogStreamService.errors += `\n${message}`;
|
|
||||||
} 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}`);
|
|
||||||
|
|
||||||
return { shouldReadLogs, shouldCleanup, output };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import * as core from '@actions/core';
|
|
||||||
|
|
||||||
class OrchestratorLogger {
|
|
||||||
private static timestamp: number;
|
|
||||||
private static globalTimestamp: number;
|
|
||||||
|
|
||||||
public static setup() {
|
|
||||||
this.timestamp = this.createTimestamp();
|
|
||||||
this.globalTimestamp = this.timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static log(message: string) {
|
|
||||||
core.info(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static logWarning(message: string) {
|
|
||||||
core.warning(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static logLine(message: string) {
|
|
||||||
core.info(`${message}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static error(message: string) {
|
|
||||||
core.error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static logWithTime(message: string) {
|
|
||||||
const newTimestamp = this.createTimestamp();
|
|
||||||
core.info(
|
|
||||||
`${message} (Since previous: ${this.calculateTimeDiff(
|
|
||||||
newTimestamp,
|
|
||||||
this.timestamp,
|
|
||||||
)}, Total time: ${this.calculateTimeDiff(newTimestamp, this.globalTimestamp)})`,
|
|
||||||
);
|
|
||||||
this.timestamp = newTimestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static calculateTimeDiff(x: number, y: number) {
|
|
||||||
return Math.floor((x - y) / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static createTimestamp() {
|
|
||||||
return Date.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default OrchestratorLogger;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import BuildParameters from '../../../build-parameters';
|
|
||||||
|
|
||||||
class OrchestratorResult {
|
|
||||||
public BuildParameters: BuildParameters;
|
|
||||||
public BuildResults: string;
|
|
||||||
public BuildSucceeded: boolean;
|
|
||||||
public BuildFinished: boolean;
|
|
||||||
public LibraryCacheUsed: boolean;
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
buildResults: string,
|
|
||||||
buildSucceeded: boolean,
|
|
||||||
buildFinished: boolean,
|
|
||||||
libraryCacheUsed: boolean,
|
|
||||||
) {
|
|
||||||
this.BuildParameters = buildParameters;
|
|
||||||
this.BuildResults = buildResults;
|
|
||||||
this.BuildSucceeded = buildSucceeded;
|
|
||||||
this.BuildFinished = buildFinished;
|
|
||||||
this.LibraryCacheUsed = libraryCacheUsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default OrchestratorResult;
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { exec } from 'child_process';
|
|
||||||
import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
|
|
||||||
|
|
||||||
export class OrchestratorSystem {
|
|
||||||
public static async RunAndReadLines(command: string): Promise<string[]> {
|
|
||||||
const result = await OrchestratorSystem.Run(command, false, true);
|
|
||||||
|
|
||||||
return result
|
|
||||||
.split(`\n`)
|
|
||||||
.map((x) => x.replace(`\r`, ``))
|
|
||||||
.filter((x) => x !== ``)
|
|
||||||
.map((x) => {
|
|
||||||
const lineValues = x.split(` `);
|
|
||||||
|
|
||||||
return lineValues[lineValues.length - 1];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Run(
|
|
||||||
command: string,
|
|
||||||
suppressError = false,
|
|
||||||
suppressLogs = false,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
outputCallback?: (output: string) => void,
|
|
||||||
) {
|
|
||||||
for (const element of command.split(`\n`)) {
|
|
||||||
if (!suppressLogs) {
|
|
||||||
RemoteClientLogger.log(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return await new Promise<string>((promise, throwError) => {
|
|
||||||
let output = '';
|
|
||||||
const child = exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout, stderr) => {
|
|
||||||
if (!suppressError && error) {
|
|
||||||
RemoteClientLogger.log(error.toString());
|
|
||||||
throwError(error);
|
|
||||||
}
|
|
||||||
if (stderr) {
|
|
||||||
const diagnosticOutput = `${stderr.toString()}`;
|
|
||||||
if (!suppressLogs) {
|
|
||||||
RemoteClientLogger.logCliDiagnostic(diagnosticOutput);
|
|
||||||
}
|
|
||||||
output += diagnosticOutput;
|
|
||||||
}
|
|
||||||
const outputChunk = `${stdout}`;
|
|
||||||
if (outputCallback) {
|
|
||||||
outputCallback(outputChunk);
|
|
||||||
}
|
|
||||||
output += outputChunk;
|
|
||||||
});
|
|
||||||
child.on('close', (code) => {
|
|
||||||
if (!suppressLogs) {
|
|
||||||
RemoteClientLogger.log(`[${code}]`);
|
|
||||||
}
|
|
||||||
if (code !== 0 && !suppressError) {
|
|
||||||
throwError(output);
|
|
||||||
}
|
|
||||||
const outputLines = output.split(`\n`);
|
|
||||||
for (const element of outputLines) {
|
|
||||||
if (!suppressLogs) {
|
|
||||||
RemoteClientLogger.log(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
promise(output);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import OrchestratorLogger from './orchestrator-logger';
|
|
||||||
import OrchestratorOptions from '../../options/orchestrator-options';
|
|
||||||
import Orchestrator from '../../orchestrator';
|
|
||||||
import { OrchestratorSystem } from './orchestrator-system';
|
|
||||||
|
|
||||||
class ResourceTracking {
|
|
||||||
static isEnabled(): boolean {
|
|
||||||
return (
|
|
||||||
OrchestratorOptions.resourceTracking ||
|
|
||||||
OrchestratorOptions.orchestratorDebug ||
|
|
||||||
process.env['orchestratorTests'] === 'true'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static logAllocationSummary(context: string) {
|
|
||||||
if (!ResourceTracking.isEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildParameters = Orchestrator.buildParameters;
|
|
||||||
const allocations = {
|
|
||||||
providerStrategy: buildParameters.providerStrategy,
|
|
||||||
containerCpu: buildParameters.containerCpu,
|
|
||||||
containerMemory: buildParameters.containerMemory,
|
|
||||||
dockerCpuLimit: buildParameters.dockerCpuLimit,
|
|
||||||
dockerMemoryLimit: buildParameters.dockerMemoryLimit,
|
|
||||||
kubeVolumeSize: buildParameters.kubeVolumeSize,
|
|
||||||
kubeStorageClass: buildParameters.kubeStorageClass,
|
|
||||||
kubeVolume: buildParameters.kubeVolume,
|
|
||||||
containerNamespace: buildParameters.containerNamespace,
|
|
||||||
storageProvider: buildParameters.storageProvider,
|
|
||||||
rcloneRemote: buildParameters.rcloneRemote,
|
|
||||||
dockerWorkspacePath: buildParameters.dockerWorkspacePath,
|
|
||||||
cacheKey: buildParameters.cacheKey,
|
|
||||||
maxRetainedWorkspaces: buildParameters.maxRetainedWorkspaces,
|
|
||||||
useCompressionStrategy: buildParameters.useCompressionStrategy,
|
|
||||||
useLargePackages: buildParameters.useLargePackages,
|
|
||||||
ephemeralStorageRequest: process.env['orchestratorTests'] === 'true' ? 'not set' : '2Gi',
|
|
||||||
};
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`[ResourceTracking] Allocation summary (${context}):`);
|
|
||||||
OrchestratorLogger.log(JSON.stringify(allocations, undefined, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
static async logDiskUsageSnapshot(context: string) {
|
|
||||||
if (!ResourceTracking.isEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`[ResourceTracking] Disk usage snapshot (${context})`);
|
|
||||||
await ResourceTracking.runAndLog('df -h', 'df -h');
|
|
||||||
await ResourceTracking.runAndLog('du -sh .', 'du -sh .');
|
|
||||||
await ResourceTracking.runAndLog('du -sh ./orchestrator-cache', 'du -sh ./orchestrator-cache');
|
|
||||||
await ResourceTracking.runAndLog('du -sh ./temp', 'du -sh ./temp');
|
|
||||||
await ResourceTracking.runAndLog('du -sh ./logs', 'du -sh ./logs');
|
|
||||||
}
|
|
||||||
|
|
||||||
static async logK3dNodeDiskUsage(context: string) {
|
|
||||||
if (!ResourceTracking.isEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes = ['k3d-unity-builder-agent-0', 'k3d-unity-builder-server-0'];
|
|
||||||
OrchestratorLogger.log(`[ResourceTracking] K3d node disk usage (${context})`);
|
|
||||||
for (const node of nodes) {
|
|
||||||
await ResourceTracking.runAndLog(
|
|
||||||
`k3d node ${node}`,
|
|
||||||
`docker exec ${node} sh -c "df -h /var/lib/rancher/k3s 2>/dev/null || df -h / 2>/dev/null || true" || true`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async runAndLog(label: string, command: string) {
|
|
||||||
try {
|
|
||||||
const output = await OrchestratorSystem.Run(command, true, true);
|
|
||||||
const trimmed = output.trim();
|
|
||||||
OrchestratorLogger.log(`[ResourceTracking] ${label}:\n${trimmed || 'no output'}`);
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.log(`[ResourceTracking] ${label} failed: ${error?.message || error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ResourceTracking;
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
import OrchestratorLogger from './orchestrator-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);
|
|
||||||
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}/`;
|
|
||||||
}
|
|
||||||
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}/`,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.map((x) => x.replace(`/`, ``))
|
|
||||||
.filter((x) => x.endsWith(`_workspace`))
|
|
||||||
.map((x) => x.split(`_`)[1]);
|
|
||||||
}
|
|
||||||
public static async DoesCacheKeyTopLevelExist(buildParametersContext: BuildParameters) {
|
|
||||||
try {
|
|
||||||
const rootLines = await SharedWorkspaceLocking.listObjects('');
|
|
||||||
const lockFolderExists = rootLines.map((x) => x.replace(`/`, ``)).includes(`locks`);
|
|
||||||
|
|
||||||
if (lockFolderExists) {
|
|
||||||
const lines = await SharedWorkspaceLocking.listObjects(SharedWorkspaceLocking.workspacePrefix);
|
|
||||||
|
|
||||||
return lines.map((x) => x.replace(`/`, ``)).includes(buildParametersContext.cacheKey);
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static NewWorkspaceName() {
|
|
||||||
return `${Orchestrator.retainedWorkspacePrefix}-${Orchestrator.buildParameters.buildGuid}`;
|
|
||||||
}
|
|
||||||
public static async GetAllLocksForWorkspace(
|
|
||||||
workspace: string,
|
|
||||||
buildParametersContext: BuildParameters,
|
|
||||||
): Promise<string[]> {
|
|
||||||
if (!(await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext))) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
await SharedWorkspaceLocking.listObjects(
|
|
||||||
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.map((x) => x.replace(`/`, ``))
|
|
||||||
.filter((x) => x.includes(workspace) && x.endsWith(`_lock`));
|
|
||||||
}
|
|
||||||
public static async GetLockedWorkspace(workspace: string, runId: string, buildParametersContext: BuildParameters) {
|
|
||||||
if (buildParametersContext.maxRetainedWorkspaces === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)}`);
|
|
||||||
for (const element of workspaces) {
|
|
||||||
const lockResult = await SharedWorkspaceLocking.LockWorkspace(element, runId, buildParametersContext);
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`run agent: ${runId} try lock workspace: ${element} locking attempt result: ${lockResult}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (lockResult) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext)) {
|
|
||||||
workspace = SharedWorkspaceLocking.NewWorkspaceName();
|
|
||||||
Orchestrator.lockedWorkspace = workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createResult = await SharedWorkspaceLocking.CreateWorkspace(workspace, buildParametersContext);
|
|
||||||
const lockResult = await SharedWorkspaceLocking.LockWorkspace(workspace, runId, buildParametersContext);
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`run agent ${runId} didn't find a free workspace so created: ${workspace} createWorkspaceSuccess: ${createResult} Lock:${lockResult}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return createResult && lockResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async DoesWorkspaceExist(workspace: string, buildParametersContext: BuildParameters) {
|
|
||||||
return (
|
|
||||||
(await SharedWorkspaceLocking.GetAllWorkspaces(buildParametersContext)).filter((x) => x.includes(workspace))
|
|
||||||
.length > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
public static async HasWorkspaceLock(
|
|
||||||
workspace: string,
|
|
||||||
runId: string,
|
|
||||||
buildParametersContext: BuildParameters,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const locks = (await SharedWorkspaceLocking.GetAllLocksForWorkspace(workspace, buildParametersContext))
|
|
||||||
.map((x) => {
|
|
||||||
return {
|
|
||||||
name: x,
|
|
||||||
timestamp: Number(x.split(`_`)[0]),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.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(
|
|
||||||
`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`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return includesRunLock;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async GetFreeWorkspaces(buildParametersContext: BuildParameters): Promise<string[]> {
|
|
||||||
const result: string[] = [];
|
|
||||||
const workspaces = await SharedWorkspaceLocking.GetAllWorkspaces(buildParametersContext);
|
|
||||||
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}`);
|
|
||||||
if (!isLocked && isBelowMax) {
|
|
||||||
result.push(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async IsWorkspaceBelowMax(
|
|
||||||
workspace: string,
|
|
||||||
buildParametersContext: BuildParameters,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const workspaces = await SharedWorkspaceLocking.GetAllWorkspaces(buildParametersContext);
|
|
||||||
if (workspace === ``) {
|
|
||||||
return (
|
|
||||||
workspaces.length < buildParametersContext.maxRetainedWorkspaces ||
|
|
||||||
buildParametersContext.maxRetainedWorkspaces === 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const ordered: any[] = [];
|
|
||||||
for (const ws of workspaces) {
|
|
||||||
ordered.push({
|
|
||||||
name: ws,
|
|
||||||
timestamp: await SharedWorkspaceLocking.GetWorkspaceTimestamp(ws, buildParametersContext),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ordered.sort((x) => x.timestamp);
|
|
||||||
const matches = ordered.filter((x) => x.name.includes(workspace));
|
|
||||||
const isWorkspaceBelowMax =
|
|
||||||
matches.length > 0 &&
|
|
||||||
(ordered.indexOf(matches[0]) < buildParametersContext.maxRetainedWorkspaces ||
|
|
||||||
buildParametersContext.maxRetainedWorkspaces === 0);
|
|
||||||
|
|
||||||
return isWorkspaceBelowMax;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async GetWorkspaceTimestamp(
|
|
||||||
workspace: string,
|
|
||||||
buildParametersContext: BuildParameters,
|
|
||||||
): Promise<Number> {
|
|
||||||
if (workspace.split(`_`).length > 0) {
|
|
||||||
return Number(workspace.split(`_`)[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext))) {
|
|
||||||
throw new Error("Workspace doesn't exist, can't call get all locks");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
await SharedWorkspaceLocking.listObjects(
|
|
||||||
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.map((x) => x.replace(`/`, ``))
|
|
||||||
.filter((x) => x.includes(workspace) && x.endsWith(`_workspace`))
|
|
||||||
.map((x) => Number(x))[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async IsWorkspaceLocked(workspace: string, buildParametersContext: BuildParameters): Promise<boolean> {
|
|
||||||
if (!(await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext))) {
|
|
||||||
throw new Error(`workspace doesn't exist ${workspace}`);
|
|
||||||
}
|
|
||||||
const files = await SharedWorkspaceLocking.listObjects(
|
|
||||||
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const lockFilesExist =
|
|
||||||
files.filter((x) => {
|
|
||||||
return x.includes(workspace) && x.endsWith(`_lock`);
|
|
||||||
}).length > 0;
|
|
||||||
|
|
||||||
return lockFilesExist;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async CreateWorkspace(workspace: string, buildParametersContext: BuildParameters): Promise<boolean> {
|
|
||||||
if (await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext)) {
|
|
||||||
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 workspaces = await SharedWorkspaceLocking.GetAllWorkspaces(buildParametersContext);
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`All workspaces ${workspaces}`);
|
|
||||||
if (!(await SharedWorkspaceLocking.IsWorkspaceBelowMax(workspace, buildParametersContext))) {
|
|
||||||
OrchestratorLogger.log(`Workspace is above max ${workspaces} ${buildParametersContext.maxRetainedWorkspaces}`);
|
|
||||||
await SharedWorkspaceLocking.CleanupWorkspace(workspace, buildParametersContext);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async LockWorkspace(
|
|
||||||
workspace: string,
|
|
||||||
runId: string,
|
|
||||||
buildParametersContext: BuildParameters,
|
|
||||||
): 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 hasLock = await SharedWorkspaceLocking.HasWorkspaceLock(workspace, runId, buildParametersContext);
|
|
||||||
|
|
||||||
if (hasLock) {
|
|
||||||
Orchestrator.lockedWorkspace = workspace;
|
|
||||||
} else {
|
|
||||||
await (SharedWorkspaceLocking.useRclone
|
|
||||||
? SharedWorkspaceLocking.rclone(`delete ${SharedWorkspaceLocking.bucket}/${key}`)
|
|
||||||
: SharedWorkspaceLocking.s3.send(new DeleteObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: key })));
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasLock;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async ReleaseWorkspace(
|
|
||||||
workspace: string,
|
|
||||||
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}`,
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
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}` }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SharedWorkspaceLocking;
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
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 { CommandHookService } from '../hooks/command-hook-service';
|
|
||||||
|
|
||||||
export class TaskParameterSerializer {
|
|
||||||
static readonly blockedParameterNames: Set<string> = new Set([
|
|
||||||
'0',
|
|
||||||
'length',
|
|
||||||
'prototype',
|
|
||||||
'',
|
|
||||||
'unityVersion',
|
|
||||||
'CACHE_UNITY_INSTALLATION_ON_MAC',
|
|
||||||
'RUNNER_TEMP_PATH',
|
|
||||||
'NAME',
|
|
||||||
'CUSTOM_JOB',
|
|
||||||
]);
|
|
||||||
public static createOrchestratorEnvironmentVariables(
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
): OrchestratorEnvironmentVariable[] {
|
|
||||||
const result: OrchestratorEnvironmentVariable[] = this.uniqBy(
|
|
||||||
[
|
|
||||||
...[
|
|
||||||
{ name: 'BUILD_TARGET', value: buildParameters.targetPlatform },
|
|
||||||
{ name: 'UNITY_VERSION', value: buildParameters.editorVersion },
|
|
||||||
{ name: 'GITHUB_TOKEN', value: process.env.GITHUB_TOKEN },
|
|
||||||
],
|
|
||||||
...TaskParameterSerializer.serializeFromObject(buildParameters),
|
|
||||||
...TaskParameterSerializer.serializeInput(),
|
|
||||||
...TaskParameterSerializer.serializeOrchestratorOptions(),
|
|
||||||
...CommandHookService.getSecrets(CommandHookService.getHooks(buildParameters.commandHooks)),
|
|
||||||
|
|
||||||
// Include AWS environment variables for LocalStack compatibility
|
|
||||||
...TaskParameterSerializer.serializeAwsEnvironmentVariables(),
|
|
||||||
]
|
|
||||||
.filter(
|
|
||||||
(x) =>
|
|
||||||
!TaskParameterSerializer.blockedParameterNames.has(x.name) &&
|
|
||||||
x.value !== '' &&
|
|
||||||
x.value !== undefined &&
|
|
||||||
x.value !== `undefined`,
|
|
||||||
)
|
|
||||||
.map((x) => {
|
|
||||||
x.name = `${TaskParameterSerializer.ToEnvVarFormat(x.name)}`;
|
|
||||||
x.value = `${x.value}`;
|
|
||||||
|
|
||||||
return x;
|
|
||||||
}),
|
|
||||||
(item: OrchestratorEnvironmentVariable) => item.name,
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
static uniqBy(a: OrchestratorEnvironmentVariable[], key: (parameters: OrchestratorEnvironmentVariable) => string) {
|
|
||||||
const seen: { [key: string]: boolean } = {};
|
|
||||||
|
|
||||||
return a.filter(function (item) {
|
|
||||||
const k = key(item);
|
|
||||||
|
|
||||||
return seen.hasOwnProperty(k) ? false : (seen[k] = true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readBuildParameterFromEnvironment(): BuildParameters {
|
|
||||||
const buildParameters = new BuildParameters();
|
|
||||||
const keys = [
|
|
||||||
...new Set(
|
|
||||||
Object.getOwnPropertyNames(process.env)
|
|
||||||
.filter((x) => !this.blockedParameterNames.has(x) && x.startsWith(''))
|
|
||||||
.map((x) => TaskParameterSerializer.UndoEnvVarFormat(x)),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const element of keys) {
|
|
||||||
if (element !== `customJob`) {
|
|
||||||
buildParameters[element] = process.env[`${TaskParameterSerializer.ToEnvVarFormat(element)}`];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildParameters;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static serializeInput() {
|
|
||||||
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] || '',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ToEnvVarFormat(input: string): string {
|
|
||||||
return OrchestratorOptions.ToEnvVarFormat(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static UndoEnvVarFormat(element: string): string {
|
|
||||||
return this.camelize(element.toLowerCase().replace(/_+/g, ' '));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static camelize(string: string) {
|
|
||||||
return TaskParameterSerializer.uncapitalizeFirstLetter(
|
|
||||||
string
|
|
||||||
.replace(/(^\w)|([A-Z])|(\b\w)/g, function (word: string, index: number) {
|
|
||||||
return index === 0 ? word.toLowerCase() : word.toUpperCase();
|
|
||||||
})
|
|
||||||
.replace(/\s+/g, ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static uncapitalizeFirstLetter(string: string) {
|
|
||||||
return string.charAt(0).toLowerCase() + string.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static serializeFromObject(buildParameters: any) {
|
|
||||||
const array: any[] = [];
|
|
||||||
const keys = Object.getOwnPropertyNames(buildParameters).filter((x) => !this.blockedParameterNames.has(x));
|
|
||||||
for (const element of keys) {
|
|
||||||
array.push({
|
|
||||||
name: TaskParameterSerializer.ToEnvVarFormat(element),
|
|
||||||
value: buildParameters[element],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return array;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static serializeFromType(type: any) {
|
|
||||||
const array: any[] = [];
|
|
||||||
const input = OrchestratorOptionsReader.GetProperties();
|
|
||||||
for (const element of input) {
|
|
||||||
if (typeof type[element] !== 'function' && array.filter((x) => x.name === element).length === 0) {
|
|
||||||
array.push({
|
|
||||||
name: element,
|
|
||||||
value: `${type[element]}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readDefaultSecrets(): OrchestratorSecret[] {
|
|
||||||
let array = new Array();
|
|
||||||
array = TaskParameterSerializer.tryAddInput(array, 'UNITY_SERIAL');
|
|
||||||
array = TaskParameterSerializer.tryAddInput(array, 'UNITY_EMAIL');
|
|
||||||
array = TaskParameterSerializer.tryAddInput(array, 'UNITY_PASSWORD');
|
|
||||||
|
|
||||||
// array = TaskParameterSerializer.tryAddInput(array, 'UNITY_LICENSE');
|
|
||||||
array = TaskParameterSerializer.tryAddInput(array, 'GIT_PRIVATE_TOKEN');
|
|
||||||
|
|
||||||
return array;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getValue(key: string) {
|
|
||||||
return OrchestratorQueryOverride.queryOverrides !== undefined &&
|
|
||||||
OrchestratorQueryOverride.queryOverrides[key] !== undefined
|
|
||||||
? OrchestratorQueryOverride.queryOverrides[key]
|
|
||||||
: process.env[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static tryAddInput(array: OrchestratorSecret[], key: string): OrchestratorSecret[] {
|
|
||||||
const value = TaskParameterSerializer.getValue(key);
|
|
||||||
if (value !== undefined && value !== '' && value !== 'null') {
|
|
||||||
array.push({
|
|
||||||
ParameterKey: key,
|
|
||||||
EnvironmentVariable: key,
|
|
||||||
ParameterValue: value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return array;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
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 * as fs from 'node:fs';
|
|
||||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
|
||||||
import { CommandHook } from './command-hook';
|
|
||||||
|
|
||||||
// import OrchestratorLogger from './orchestrator-logger';
|
|
||||||
|
|
||||||
export class CommandHookService {
|
|
||||||
public static ApplyHooksToCommands(commands: string, buildParameters: BuildParameters): string {
|
|
||||||
const hooks = CommandHookService.getHooks(buildParameters.commandHooks);
|
|
||||||
OrchestratorLogger.log(`Applying hooks ${hooks.length}`);
|
|
||||||
|
|
||||||
return `echo "---"
|
|
||||||
echo "start orchestrator init"
|
|
||||||
${OrchestratorOptions.orchestratorDebug ? `printenv` : `#`}
|
|
||||||
echo "start of orchestrator 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 "---${buildParameters.logId}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getHooks(customCommandHooks: string): CommandHook[] {
|
|
||||||
const experimentHooks = customCommandHooks;
|
|
||||||
let output = new Array<CommandHook>();
|
|
||||||
if (experimentHooks && experimentHooks !== '') {
|
|
||||||
try {
|
|
||||||
output = YAML.parse(experimentHooks);
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
...output.filter((x) => x.hook !== undefined && x.hook.length > 0),
|
|
||||||
...CommandHookService.GetCustomHooksFromFiles(`before`),
|
|
||||||
...CommandHookService.GetCustomHooksFromFiles(`after`),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
static GetCustomHooksFromFiles(hookLifecycle: string): CommandHook[] {
|
|
||||||
const results: CommandHook[] = [];
|
|
||||||
|
|
||||||
// RemoteClientLogger.log(`GetCustomHookFiles: ${hookLifecycle}`);
|
|
||||||
try {
|
|
||||||
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`, ``))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const fileContents = fs.readFileSync(path.join(gameCiCustomHooksPath, file), `utf8`);
|
|
||||||
const fileContentsObject = CommandHookService.ParseHooks(fileContents)[0];
|
|
||||||
if (fileContentsObject.hook.includes(hookLifecycle)) {
|
|
||||||
results.push(fileContentsObject);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
RemoteClientLogger.log(`Failed Getting: ${hookLifecycle} \n ${JSON.stringify(error, undefined, 4)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoteClientLogger.log(`Active Steps From Hooks: \n ${JSON.stringify(results, undefined, 4)}`);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ConvertYamlSecrets(object: CommandHook) {
|
|
||||||
if (object.secrets === undefined) {
|
|
||||||
object.secrets = [];
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
object.secrets = object.secrets.map((x: any) => {
|
|
||||||
return {
|
|
||||||
ParameterKey: x.name,
|
|
||||||
EnvironmentVariable: Input.ToEnvVarFormat(x.name),
|
|
||||||
ParameterValue: x.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ParseHooks(hooks: string): CommandHook[] {
|
|
||||||
if (hooks === '') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// if (Orchestrator.buildParameters?.orchestratorIntegrationTests) {
|
|
||||||
|
|
||||||
// OrchestratorLogger.log(`Parsing build hooks: ${steps}`);
|
|
||||||
|
|
||||||
// }
|
|
||||||
const isArray = hooks.replace(/\s/g, ``)[0] === `-`;
|
|
||||||
const object: CommandHook[] = isArray ? YAML.parse(hooks) : [YAML.parse(hooks)];
|
|
||||||
for (const hook of object) {
|
|
||||||
CommandHookService.ConvertYamlSecrets(hook);
|
|
||||||
if (hook.secrets === undefined) {
|
|
||||||
hook.secrets = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (object === undefined) {
|
|
||||||
throw new Error(`Failed to parse ${hooks}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return object;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getSecrets(hooks: any) {
|
|
||||||
const secrets = hooks.map((x: any) => x.secrets).filter((x: any) => x !== undefined && x.length > 0);
|
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/no-array-reduce
|
|
||||||
return secrets.length > 0 ? secrets.reduce((x: any, y: any) => [...x, ...y]) : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
|
||||||
|
|
||||||
export class CommandHook {
|
|
||||||
public commands: string[] = new Array<string>();
|
|
||||||
public secrets: OrchestratorSecret[] = new Array<OrchestratorSecret>();
|
|
||||||
public name!: string;
|
|
||||||
public hook!: string[];
|
|
||||||
public step!: string[];
|
|
||||||
}
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
import YAML from 'yaml';
|
|
||||||
import Orchestrator from '../../orchestrator';
|
|
||||||
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 OrchestratorOptions from '../../options/orchestrator-options';
|
|
||||||
import { ContainerHook as ContainerHook } from './container-hook';
|
|
||||||
import { OrchestratorStepParameters } from '../../options/orchestrator-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 (!OrchestratorOptions.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: |
|
|
||||||
if command -v aws > /dev/null 2>&1; then
|
|
||||||
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
|
|
||||||
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile default || true
|
|
||||||
fi
|
|
||||||
if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
|
|
||||||
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile default || true
|
|
||||||
fi
|
|
||||||
if [ -n "$AWS_DEFAULT_REGION" ]; then
|
|
||||||
aws configure set region "$AWS_DEFAULT_REGION" --profile default || true
|
|
||||||
fi
|
|
||||||
ENDPOINT_ARGS=""
|
|
||||||
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
|
|
||||||
aws $ENDPOINT_ARGS s3 cp /data/cache/$CACHE_KEY/build/build-${Orchestrator.buildParameters.buildGuid}.tar${
|
|
||||||
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
|
||||||
} s3://${Orchestrator.buildParameters.awsStackName}/orchestrator-cache/$CACHE_KEY/build/build-$BUILD_GUID.tar${
|
|
||||||
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
|
||||||
} || true
|
|
||||||
rm /data/cache/$CACHE_KEY/build/build-${Orchestrator.buildParameters.buildGuid}.tar${
|
|
||||||
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
|
||||||
} || true
|
|
||||||
else
|
|
||||||
echo "AWS CLI not available, skipping aws-s3-upload-build"
|
|
||||||
fi
|
|
||||||
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_ENDPOINT
|
|
||||||
value: ${OrchestratorOptions.awsS3Endpoint || process.env.AWS_S3_ENDPOINT || ``}
|
|
||||||
- name: aws-s3-pull-build
|
|
||||||
image: amazon/aws-cli
|
|
||||||
commands: |
|
|
||||||
mkdir -p /data/cache/$CACHE_KEY/build/
|
|
||||||
if command -v aws > /dev/null 2>&1; then
|
|
||||||
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
|
|
||||||
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile default || true
|
|
||||||
fi
|
|
||||||
if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
|
|
||||||
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile default || true
|
|
||||||
fi
|
|
||||||
if [ -n "$AWS_DEFAULT_REGION" ]; then
|
|
||||||
aws configure set region "$AWS_DEFAULT_REGION" --profile default || true
|
|
||||||
fi
|
|
||||||
ENDPOINT_ARGS=""
|
|
||||||
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
|
|
||||||
aws $ENDPOINT_ARGS s3 ls ${Orchestrator.buildParameters.awsStackName}/orchestrator-cache/ || true
|
|
||||||
aws $ENDPOINT_ARGS s3 ls ${Orchestrator.buildParameters.awsStackName}/orchestrator-cache/$CACHE_KEY/build || true
|
|
||||||
aws s3 cp s3://${
|
|
||||||
Orchestrator.buildParameters.awsStackName
|
|
||||||
}/orchestrator-cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
|
|
||||||
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
|
||||||
} /data/cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
|
|
||||||
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
|
||||||
} || true
|
|
||||||
else
|
|
||||||
echo "AWS CLI not available, skipping aws-s3-pull-build"
|
|
||||||
fi
|
|
||||||
secrets:
|
|
||||||
- name: AWS_ACCESS_KEY_ID
|
|
||||||
- name: AWS_SECRET_ACCESS_KEY
|
|
||||||
- name: AWS_DEFAULT_REGION
|
|
||||||
- name: BUILD_GUID_TARGET
|
|
||||||
- name: AWS_S3_ENDPOINT
|
|
||||||
- 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: |
|
|
||||||
if command -v aws > /dev/null 2>&1; then
|
|
||||||
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
|
|
||||||
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile default || true
|
|
||||||
fi
|
|
||||||
if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
|
|
||||||
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile default || true
|
|
||||||
fi
|
|
||||||
if [ -n "$AWS_DEFAULT_REGION" ]; then
|
|
||||||
aws configure set region "$AWS_DEFAULT_REGION" --profile default || true
|
|
||||||
fi
|
|
||||||
ENDPOINT_ARGS=""
|
|
||||||
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
|
|
||||||
aws $ENDPOINT_ARGS s3 cp --recursive /data/cache/$CACHE_KEY/lfs s3://${
|
|
||||||
Orchestrator.buildParameters.awsStackName
|
|
||||||
}/orchestrator-cache/$CACHE_KEY/lfs || true
|
|
||||||
rm -r /data/cache/$CACHE_KEY/lfs || true
|
|
||||||
aws $ENDPOINT_ARGS s3 cp --recursive /data/cache/$CACHE_KEY/Library s3://${
|
|
||||||
Orchestrator.buildParameters.awsStackName
|
|
||||||
}/orchestrator-cache/$CACHE_KEY/Library || true
|
|
||||||
rm -r /data/cache/$CACHE_KEY/Library || true
|
|
||||||
else
|
|
||||||
echo "AWS CLI not available, skipping aws-s3-upload-cache"
|
|
||||||
fi
|
|
||||||
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_ENDPOINT
|
|
||||||
value: ${OrchestratorOptions.awsS3Endpoint || process.env.AWS_S3_ENDPOINT || ``}
|
|
||||||
- name: aws-s3-pull-cache
|
|
||||||
image: amazon/aws-cli
|
|
||||||
hook: before
|
|
||||||
commands: |
|
|
||||||
mkdir -p /data/cache/$CACHE_KEY/Library/
|
|
||||||
mkdir -p /data/cache/$CACHE_KEY/lfs/
|
|
||||||
if command -v aws > /dev/null 2>&1; then
|
|
||||||
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
|
|
||||||
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile default || true
|
|
||||||
fi
|
|
||||||
if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
|
|
||||||
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile default || true
|
|
||||||
fi
|
|
||||||
if [ -n "$AWS_DEFAULT_REGION" ]; then
|
|
||||||
aws configure set region "$AWS_DEFAULT_REGION" --profile default || true
|
|
||||||
fi
|
|
||||||
ENDPOINT_ARGS=""
|
|
||||||
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
|
|
||||||
aws $ENDPOINT_ARGS s3 ls ${Orchestrator.buildParameters.awsStackName}/orchestrator-cache/ 2>/dev/null || true
|
|
||||||
aws $ENDPOINT_ARGS s3 ls ${
|
|
||||||
Orchestrator.buildParameters.awsStackName
|
|
||||||
}/orchestrator-cache/$CACHE_KEY/ 2>/dev/null || true
|
|
||||||
BUCKET1="${Orchestrator.buildParameters.awsStackName}/orchestrator-cache/$CACHE_KEY/Library/"
|
|
||||||
OBJECT1=""
|
|
||||||
LS_OUTPUT1="$(aws $ENDPOINT_ARGS s3 ls $BUCKET1 2>/dev/null || echo '')"
|
|
||||||
if [ -n "$LS_OUTPUT1" ] && [ "$LS_OUTPUT1" != "" ]; then
|
|
||||||
OBJECT1="$(echo "$LS_OUTPUT1" | sort | tail -n 1 | awk '{print $4}' || '')"
|
|
||||||
if [ -n "$OBJECT1" ] && [ "$OBJECT1" != "" ]; then
|
|
||||||
aws $ENDPOINT_ARGS s3 cp s3://$BUCKET1$OBJECT1 /data/cache/$CACHE_KEY/Library/ 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
BUCKET2="${Orchestrator.buildParameters.awsStackName}/orchestrator-cache/$CACHE_KEY/lfs/"
|
|
||||||
OBJECT2=""
|
|
||||||
LS_OUTPUT2="$(aws $ENDPOINT_ARGS s3 ls $BUCKET2 2>/dev/null || echo '')"
|
|
||||||
if [ -n "$LS_OUTPUT2" ] && [ "$LS_OUTPUT2" != "" ]; then
|
|
||||||
OBJECT2="$(echo "$LS_OUTPUT2" | sort | tail -n 1 | awk '{print $4}' || '')"
|
|
||||||
if [ -n "$OBJECT2" ] && [ "$OBJECT2" != "" ]; then
|
|
||||||
aws $ENDPOINT_ARGS s3 cp s3://$BUCKET2$OBJECT2 /data/cache/$CACHE_KEY/lfs/ 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "AWS CLI not available, skipping aws-s3-pull-cache"
|
|
||||||
fi
|
|
||||||
- name: rclone-upload-build
|
|
||||||
image: rclone/rclone
|
|
||||||
hook: after
|
|
||||||
commands: |
|
|
||||||
if command -v rclone > /dev/null 2>&1; then
|
|
||||||
rclone copy /data/cache/$CACHE_KEY/build/build-${Orchestrator.buildParameters.buildGuid}.tar${
|
|
||||||
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
|
||||||
} ${Orchestrator.buildParameters.rcloneRemote}/orchestrator-cache/$CACHE_KEY/build/ || true
|
|
||||||
rm /data/cache/$CACHE_KEY/build/build-${Orchestrator.buildParameters.buildGuid}.tar${
|
|
||||||
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
|
||||||
} || true
|
|
||||||
else
|
|
||||||
echo "rclone not available, skipping rclone-upload-build"
|
|
||||||
fi
|
|
||||||
secrets:
|
|
||||||
- name: RCLONE_REMOTE
|
|
||||||
value: ${Orchestrator.buildParameters.rcloneRemote || ``}
|
|
||||||
- name: rclone-pull-build
|
|
||||||
image: rclone/rclone
|
|
||||||
commands: |
|
|
||||||
mkdir -p /data/cache/$CACHE_KEY/build/
|
|
||||||
if command -v rclone > /dev/null 2>&1; then
|
|
||||||
rclone copy ${
|
|
||||||
Orchestrator.buildParameters.rcloneRemote
|
|
||||||
}/orchestrator-cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
|
|
||||||
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
|
||||||
} /data/cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
|
|
||||||
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
|
|
||||||
} || true
|
|
||||||
else
|
|
||||||
echo "rclone not available, skipping rclone-pull-build"
|
|
||||||
fi
|
|
||||||
secrets:
|
|
||||||
- name: BUILD_GUID_TARGET
|
|
||||||
- name: RCLONE_REMOTE
|
|
||||||
value: ${Orchestrator.buildParameters.rcloneRemote || ``}
|
|
||||||
- name: rclone-upload-cache
|
|
||||||
image: rclone/rclone
|
|
||||||
hook: after
|
|
||||||
commands: |
|
|
||||||
if command -v rclone > /dev/null 2>&1; then
|
|
||||||
rclone copy /data/cache/$CACHE_KEY/lfs ${
|
|
||||||
Orchestrator.buildParameters.rcloneRemote
|
|
||||||
}/orchestrator-cache/$CACHE_KEY/lfs || true
|
|
||||||
rm -r /data/cache/$CACHE_KEY/lfs || true
|
|
||||||
rclone copy /data/cache/$CACHE_KEY/Library ${
|
|
||||||
Orchestrator.buildParameters.rcloneRemote
|
|
||||||
}/orchestrator-cache/$CACHE_KEY/Library || true
|
|
||||||
rm -r /data/cache/$CACHE_KEY/Library || true
|
|
||||||
else
|
|
||||||
echo "rclone not available, skipping rclone-upload-cache"
|
|
||||||
fi
|
|
||||||
secrets:
|
|
||||||
- name: RCLONE_REMOTE
|
|
||||||
value: ${Orchestrator.buildParameters.rcloneRemote || ``}
|
|
||||||
- name: rclone-pull-cache
|
|
||||||
image: rclone/rclone
|
|
||||||
hook: before
|
|
||||||
commands: |
|
|
||||||
mkdir -p /data/cache/$CACHE_KEY/Library/
|
|
||||||
mkdir -p /data/cache/$CACHE_KEY/lfs/
|
|
||||||
if command -v rclone > /dev/null 2>&1; then
|
|
||||||
rclone copy ${
|
|
||||||
Orchestrator.buildParameters.rcloneRemote
|
|
||||||
}/orchestrator-cache/$CACHE_KEY/Library /data/cache/$CACHE_KEY/Library/ || true
|
|
||||||
rclone copy ${
|
|
||||||
Orchestrator.buildParameters.rcloneRemote
|
|
||||||
}/orchestrator-cache/$CACHE_KEY/lfs /data/cache/$CACHE_KEY/lfs/ || true
|
|
||||||
else
|
|
||||||
echo "rclone not available, skipping rclone-pull-cache"
|
|
||||||
fi
|
|
||||||
secrets:
|
|
||||||
- name: RCLONE_REMOTE
|
|
||||||
value: ${Orchestrator.buildParameters.rcloneRemote || ``}
|
|
||||||
- name: debug-cache
|
|
||||||
image: ubuntu
|
|
||||||
hook: after
|
|
||||||
commands: |
|
|
||||||
apt-get update > /dev/null || true
|
|
||||||
${OrchestratorOptions.orchestratorDebug ? `apt-get install -y tree > /dev/null || true` : `#`}
|
|
||||||
${OrchestratorOptions.orchestratorDebug ? `tree -L 3 /data/cache || true` : `#`}
|
|
||||||
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_ENDPOINT
|
|
||||||
value: ${OrchestratorOptions.awsS3Endpoint || process.env.AWS_S3_ENDPOINT || ``}`,
|
|
||||||
).filter((x) => OrchestratorOptions.containerHookFiles.includes(x.name) && x.hook === hookLifecycle);
|
|
||||||
|
|
||||||
// In local provider mode (non-container) or when AWS credentials are not present, skip AWS S3 hooks
|
|
||||||
const provider = Orchestrator.buildParameters?.providerStrategy;
|
|
||||||
const isContainerized = provider === 'aws' || provider === 'k8s' || provider === 'local-docker';
|
|
||||||
const hasAwsCreds =
|
|
||||||
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
|
|
||||||
(process.env.awsAccessKeyId && process.env.awsSecretAccessKey);
|
|
||||||
|
|
||||||
// Always include AWS hooks on the AWS provider (task role provides creds),
|
|
||||||
// otherwise require explicit creds for other containerized providers.
|
|
||||||
const shouldIncludeAwsHooks =
|
|
||||||
isContainerized && !Orchestrator.buildParameters?.skipCache && (provider === 'aws' || Boolean(hasAwsCreds));
|
|
||||||
const filteredBuiltIns = shouldIncludeAwsHooks
|
|
||||||
? builtInContainerHooks
|
|
||||||
: builtInContainerHooks.filter((x) => x.image !== 'amazon/aws-cli');
|
|
||||||
|
|
||||||
if (filteredBuiltIns.length > 0) {
|
|
||||||
results.push(...filteredBuiltIns);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (Orchestrator.buildParameters?.orchestratorDebug) {
|
|
||||||
// OrchestratorLogger.log(`Injecting custom step ${step.name} from env var ${secret.ParameterKey}`);
|
|
||||||
}
|
|
||||||
secret.ParameterValue = process.env[secret.ParameterKey] || ``;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (step.image === undefined) {
|
|
||||||
step.image = `ubuntu`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure allowFailure defaults to false if not explicitly set
|
|
||||||
if (step.allowFailure === undefined) {
|
|
||||||
step.allowFailure = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (object === undefined) {
|
|
||||||
throw new Error(`Failed to parse ${steps}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return object;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async RunPostBuildSteps(orchestratorStepState: OrchestratorStepParameters) {
|
|
||||||
let output = ``;
|
|
||||||
const steps: ContainerHook[] = [
|
|
||||||
...ContainerHookService.ParseContainerHooks(Orchestrator.buildParameters.postBuildContainerHooks),
|
|
||||||
...ContainerHookService.GetContainerHooksFromFiles(`after`),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (steps.length > 0) {
|
|
||||||
output += await CustomWorkflow.runContainerJob(
|
|
||||||
steps,
|
|
||||||
orchestratorStepState.environment,
|
|
||||||
orchestratorStepState.secrets,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
static async RunPreBuildSteps(orchestratorStepState: OrchestratorStepParameters) {
|
|
||||||
let output = ``;
|
|
||||||
const steps: ContainerHook[] = [
|
|
||||||
...ContainerHookService.ParseContainerHooks(Orchestrator.buildParameters.preBuildContainerHooks),
|
|
||||||
...ContainerHookService.GetContainerHooksFromFiles(`before`),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (steps.length > 0) {
|
|
||||||
output += await CustomWorkflow.runContainerJob(
|
|
||||||
steps,
|
|
||||||
orchestratorStepState.environment,
|
|
||||||
orchestratorStepState.secrets,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
|
||||||
|
|
||||||
export class ContainerHook {
|
|
||||||
public commands!: string;
|
|
||||||
public secrets: OrchestratorSecret[] = new Array<OrchestratorSecret>();
|
|
||||||
public name!: string;
|
|
||||||
public image: string = `ubuntu`;
|
|
||||||
public hook!: string;
|
|
||||||
public allowFailure: boolean = false; // If true, hook failures won't stop the build
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
|
||||||
import { HotRunnerRegistry } from './hot-runner-registry';
|
|
||||||
import { HotRunnerJobRequest, HotRunnerJobResult, HotRunnerStatus, HotRunnerTransport } from './hot-runner-types';
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 1000;
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
export type OutputCallback = (output: string) => void;
|
|
||||||
|
|
||||||
export class HotRunnerDispatcher {
|
|
||||||
private transports: Map<string, HotRunnerTransport>;
|
|
||||||
|
|
||||||
constructor(transports: Map<string, HotRunnerTransport>) {
|
|
||||||
this.transports = transports;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatch a job to an available hot runner matching the request's build target.
|
|
||||||
* If no runner is immediately available, waits up to the request timeout.
|
|
||||||
* Returns the job result, or throws if no runner becomes available in time.
|
|
||||||
*/
|
|
||||||
async dispatchJob(
|
|
||||||
request: HotRunnerJobRequest,
|
|
||||||
registry: HotRunnerRegistry,
|
|
||||||
unityVersion: string,
|
|
||||||
onOutput?: OutputCallback,
|
|
||||||
): Promise<HotRunnerJobResult> {
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Dispatching job ${request.jobId} (target: ${request.buildTarget})`);
|
|
||||||
|
|
||||||
// Find or wait for an available runner
|
|
||||||
let runner = registry.findAvailableRunner({
|
|
||||||
unityVersion,
|
|
||||||
platform: request.buildTarget,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!runner) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`[HotRunner] No idle runner available for ${unityVersion}/${request.buildTarget}, waiting...`,
|
|
||||||
);
|
|
||||||
runner = await this.waitForRunner({ unityVersion, platform: request.buildTarget }, request.timeout, registry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark runner as busy
|
|
||||||
registry.updateRunner(runner.id, {
|
|
||||||
state: 'busy',
|
|
||||||
currentJob: request.jobId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const transport = this.transports.get(runner.id);
|
|
||||||
if (!transport) {
|
|
||||||
registry.updateRunner(runner.id, { state: 'idle', currentJob: undefined });
|
|
||||||
throw new Error(`[HotRunner] No transport available for runner ${runner.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Sending job ${request.jobId} to runner ${runner.id}`);
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await this.executeWithTimeout(transport, request);
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`[HotRunner] Job ${request.jobId} completed on runner ${runner.id} in ${duration}ms (exit: ${result.exitCode})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onOutput && result.output) {
|
|
||||||
onOutput(result.output);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark runner as idle and increment job count
|
|
||||||
const currentStatus = registry.getRunner(runner.id);
|
|
||||||
registry.updateRunner(runner.id, {
|
|
||||||
state: 'idle',
|
|
||||||
currentJob: undefined,
|
|
||||||
lastJobCompleted: request.jobId,
|
|
||||||
jobsCompleted: (currentStatus?.jobsCompleted ?? 0) + 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] Job ${request.jobId} failed on runner ${runner.id}: ${error.message}`);
|
|
||||||
|
|
||||||
// Mark runner as idle despite failure -- the health monitor will recycle if needed
|
|
||||||
registry.updateRunner(runner.id, {
|
|
||||||
state: 'idle',
|
|
||||||
currentJob: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for an available runner matching the requirements.
|
|
||||||
* Polls the registry at a fixed interval until one becomes available or timeout expires.
|
|
||||||
*/
|
|
||||||
async waitForRunner(
|
|
||||||
requirements: { unityVersion: string; platform: string },
|
|
||||||
timeoutMs: number,
|
|
||||||
registry: HotRunnerRegistry,
|
|
||||||
): Promise<HotRunnerStatus> {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const runner = registry.findAvailableRunner(requirements);
|
|
||||||
if (runner) {
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Runner ${runner.id} became available`);
|
|
||||||
|
|
||||||
return runner;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sleep(Math.min(POLL_INTERVAL_MS, deadline - Date.now()));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`[HotRunner] Timed out waiting for available runner (${requirements.unityVersion}/${requirements.platform}) after ${timeoutMs}ms`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a job on a transport with a timeout guard.
|
|
||||||
* On timeout, disconnects the transport to release the connection
|
|
||||||
* and prevent the orphaned sendJob promise from holding resources.
|
|
||||||
*/
|
|
||||||
private async executeWithTimeout(
|
|
||||||
transport: HotRunnerTransport,
|
|
||||||
request: HotRunnerJobRequest,
|
|
||||||
): Promise<HotRunnerJobResult> {
|
|
||||||
const TIMEOUT_SENTINEL = Symbol('timeout');
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve(TIMEOUT_SENTINEL);
|
|
||||||
}, request.timeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await Promise.race([transport.sendJob(request), timeoutPromise]);
|
|
||||||
|
|
||||||
if (result === TIMEOUT_SENTINEL) {
|
|
||||||
// Disconnect the transport to clean up the orphaned sendJob call
|
|
||||||
try {
|
|
||||||
await transport.disconnect();
|
|
||||||
} catch (disconnectError: any) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`[HotRunner] Error disconnecting transport after timeout for job ${request.jobId}: ${disconnectError.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`[HotRunner] Job ${request.jobId} timed out after ${request.timeout}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
|
||||||
import { HotRunnerRegistry } from './hot-runner-registry';
|
|
||||||
import { HotRunnerTransport } from './hot-runner-types';
|
|
||||||
|
|
||||||
export class HotRunnerHealthMonitor {
|
|
||||||
private intervalHandle: ReturnType<typeof setInterval> | undefined;
|
|
||||||
private registry: HotRunnerRegistry | undefined;
|
|
||||||
private transports: Map<string, HotRunnerTransport> = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start periodic health monitoring for all registered runners.
|
|
||||||
*/
|
|
||||||
startMonitoring(registry: HotRunnerRegistry, interval: number, transports: Map<string, HotRunnerTransport>): void {
|
|
||||||
if (this.intervalHandle) {
|
|
||||||
this.stopMonitoring();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.registry = registry;
|
|
||||||
this.transports = transports;
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Starting health monitoring (interval: ${interval}s)`);
|
|
||||||
|
|
||||||
this.intervalHandle = setInterval(() => {
|
|
||||||
this.runHealthChecks().catch((error: any) => {
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] Health check cycle failed: ${error.message}`);
|
|
||||||
});
|
|
||||||
}, interval * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop periodic health monitoring.
|
|
||||||
*/
|
|
||||||
stopMonitoring(): void {
|
|
||||||
if (this.intervalHandle) {
|
|
||||||
clearInterval(this.intervalHandle);
|
|
||||||
this.intervalHandle = undefined;
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Health monitoring stopped`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check health of a specific runner by ID. Returns true if healthy.
|
|
||||||
*/
|
|
||||||
async checkHealth(runnerId: string): Promise<boolean> {
|
|
||||||
if (!this.registry) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const transport = this.transports.get(runnerId);
|
|
||||||
if (!transport) {
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] No transport for runner ${runnerId}`);
|
|
||||||
this.registry.updateRunner(runnerId, {
|
|
||||||
state: 'unhealthy',
|
|
||||||
lastHealthCheck: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const healthy = await transport.healthCheck();
|
|
||||||
if (healthy) {
|
|
||||||
const status = await transport.getStatus();
|
|
||||||
this.registry.updateRunner(runnerId, {
|
|
||||||
lastHealthCheck: new Date().toISOString(),
|
|
||||||
memoryUsageMB: status.memoryUsageMB,
|
|
||||||
uptime: status.uptime,
|
|
||||||
libraryHash: status.libraryHash,
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] Runner ${runnerId} health check returned false`);
|
|
||||||
this.registry.updateRunner(runnerId, {
|
|
||||||
state: 'unhealthy',
|
|
||||||
lastHealthCheck: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return false;
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] Runner ${runnerId} health check failed: ${error.message}`);
|
|
||||||
this.registry.updateRunner(runnerId, {
|
|
||||||
state: 'unhealthy',
|
|
||||||
lastHealthCheck: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark an unhealthy runner for cleanup and disconnect its transport.
|
|
||||||
*/
|
|
||||||
async recycleUnhealthyRunner(runnerId: string): Promise<void> {
|
|
||||||
if (!this.registry) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Recycling unhealthy runner ${runnerId}`);
|
|
||||||
this.registry.updateRunner(runnerId, { state: 'stopping' });
|
|
||||||
|
|
||||||
const transport = this.transports.get(runnerId);
|
|
||||||
if (transport) {
|
|
||||||
try {
|
|
||||||
await transport.disconnect();
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] Error disconnecting runner ${runnerId}: ${error.message}`);
|
|
||||||
}
|
|
||||||
this.transports.delete(runnerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.registry.unregisterRunner(runnerId);
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Runner ${runnerId} recycled and removed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recycle a runner that has been idle longer than the maximum idle time.
|
|
||||||
*/
|
|
||||||
async recycleIdleRunner(runnerId: string, maxIdleTime: number): Promise<void> {
|
|
||||||
if (!this.registry) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runner = this.registry.getRunner(runnerId);
|
|
||||||
if (!runner || runner.state !== 'idle') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastCheckTime = new Date(runner.lastHealthCheck).getTime();
|
|
||||||
const now = Date.now();
|
|
||||||
const idleSeconds = (now - lastCheckTime) / 1000;
|
|
||||||
|
|
||||||
if (idleSeconds >= maxIdleTime) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`[HotRunner] Runner ${runnerId} idle for ${Math.floor(idleSeconds)}s (max: ${maxIdleTime}s), recycling`,
|
|
||||||
);
|
|
||||||
await this.recycleUnhealthyRunner(runnerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run health checks and idle-recycle checks for all registered runners.
|
|
||||||
*/
|
|
||||||
private async runHealthChecks(): Promise<void> {
|
|
||||||
if (!this.registry) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runners = this.registry.listRunners();
|
|
||||||
|
|
||||||
for (const runner of runners) {
|
|
||||||
if (runner.state === 'stopping') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const healthy = await this.checkHealth(runner.id);
|
|
||||||
|
|
||||||
if (!healthy && runner.state !== 'starting') {
|
|
||||||
await this.recycleUnhealthyRunner(runner.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for idle timeout
|
|
||||||
const config = this.registry.getConfig(runner.id);
|
|
||||||
if (config && runner.state === 'idle') {
|
|
||||||
await this.recycleIdleRunner(runner.id, config.maxIdleTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for max jobs before recycle
|
|
||||||
if (config && config.maxJobsBeforeRecycle > 0 && runner.jobsCompleted >= config.maxJobsBeforeRecycle) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`[HotRunner] Runner ${runner.id} reached max jobs (${runner.jobsCompleted}/${config.maxJobsBeforeRecycle}), recycling`,
|
|
||||||
);
|
|
||||||
await this.recycleUnhealthyRunner(runner.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether health monitoring is currently active.
|
|
||||||
*/
|
|
||||||
get isMonitoring(): boolean {
|
|
||||||
return this.intervalHandle !== undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { customAlphabet } from 'nanoid';
|
|
||||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
|
||||||
import { HotRunnerConfig, HotRunnerStatus } from './hot-runner-types';
|
|
||||||
|
|
||||||
const generateId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 12);
|
|
||||||
|
|
||||||
const PERSISTENCE_FILENAME = 'hot-runners.json';
|
|
||||||
|
|
||||||
const VALID_RUNNER_STATES: ReadonlySet<string> = new Set(['idle', 'busy', 'starting', 'stopping', 'unhealthy']);
|
|
||||||
|
|
||||||
export interface HotRunnerFilter {
|
|
||||||
platform?: string;
|
|
||||||
state?: string;
|
|
||||||
unityVersion?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a restored runner entry has all required fields with correct types.
|
|
||||||
* Returns true if the entry is a valid HotRunnerStatus, false otherwise.
|
|
||||||
*/
|
|
||||||
function isValidRunnerStatus(entry: unknown): entry is HotRunnerStatus {
|
|
||||||
if (typeof entry !== 'object' || entry === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = entry as Record<string, unknown>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
typeof record.id === 'string' &&
|
|
||||||
record.id.length > 0 &&
|
|
||||||
typeof record.state === 'string' &&
|
|
||||||
VALID_RUNNER_STATES.has(record.state) &&
|
|
||||||
typeof record.unityVersion === 'string' &&
|
|
||||||
typeof record.platform === 'string' &&
|
|
||||||
typeof record.uptime === 'number' &&
|
|
||||||
typeof record.jobsCompleted === 'number' &&
|
|
||||||
typeof record.lastHealthCheck === 'string' &&
|
|
||||||
typeof record.memoryUsageMB === 'number'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a restored config entry has all required fields with correct types.
|
|
||||||
* Returns true if the entry is a valid HotRunnerConfig, false otherwise.
|
|
||||||
*/
|
|
||||||
function isValidRunnerConfig(entry: unknown): entry is HotRunnerConfig {
|
|
||||||
if (typeof entry !== 'object' || entry === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = entry as Record<string, unknown>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
typeof record.enabled === 'boolean' &&
|
|
||||||
typeof record.transport === 'string' &&
|
|
||||||
['websocket', 'grpc', 'named-pipe'].includes(record.transport) &&
|
|
||||||
typeof record.host === 'string' &&
|
|
||||||
typeof record.port === 'number' &&
|
|
||||||
typeof record.healthCheckInterval === 'number' &&
|
|
||||||
typeof record.maxIdleTime === 'number' &&
|
|
||||||
typeof record.maxJobsBeforeRecycle === 'number'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HotRunnerRegistry {
|
|
||||||
private runners: Map<string, HotRunnerStatus> = new Map();
|
|
||||||
private configs: Map<string, HotRunnerConfig> = new Map();
|
|
||||||
private persistencePath: string;
|
|
||||||
|
|
||||||
constructor(persistenceDirectory?: string) {
|
|
||||||
this.persistencePath = persistenceDirectory ? path.join(persistenceDirectory, PERSISTENCE_FILENAME) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a new hot runner. Returns the generated runner ID.
|
|
||||||
*/
|
|
||||||
registerRunner(config: HotRunnerConfig): string {
|
|
||||||
const id = `hr-${generateId()}`;
|
|
||||||
|
|
||||||
const status: HotRunnerStatus = {
|
|
||||||
id,
|
|
||||||
state: 'starting',
|
|
||||||
unityVersion: config.unityVersion ?? 'unknown',
|
|
||||||
platform: config.platform ?? 'unknown',
|
|
||||||
uptime: 0,
|
|
||||||
jobsCompleted: 0,
|
|
||||||
lastHealthCheck: new Date().toISOString(),
|
|
||||||
memoryUsageMB: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.runners.set(id, status);
|
|
||||||
this.configs.set(id, config);
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Registered runner ${id} (${status.unityVersion}/${status.platform})`);
|
|
||||||
|
|
||||||
this.persist();
|
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a runner from the registry.
|
|
||||||
*/
|
|
||||||
unregisterRunner(id: string): void {
|
|
||||||
const existed = this.runners.delete(id);
|
|
||||||
this.configs.delete(id);
|
|
||||||
|
|
||||||
if (existed) {
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Unregistered runner ${id}`);
|
|
||||||
this.persist();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a runner's current status by ID.
|
|
||||||
*/
|
|
||||||
getRunner(id: string): HotRunnerStatus | undefined {
|
|
||||||
return this.runners.get(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a runner's config by ID.
|
|
||||||
*/
|
|
||||||
getConfig(id: string): HotRunnerConfig | undefined {
|
|
||||||
return this.configs.get(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all runners, optionally filtered by platform, state, or Unity version.
|
|
||||||
*/
|
|
||||||
listRunners(filter?: HotRunnerFilter): HotRunnerStatus[] {
|
|
||||||
let results = [...this.runners.values()];
|
|
||||||
|
|
||||||
if (filter?.platform) {
|
|
||||||
results = results.filter((runner) => runner.platform === filter.platform);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter?.state) {
|
|
||||||
results = results.filter((runner) => runner.state === filter.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter?.unityVersion) {
|
|
||||||
results = results.filter((runner) => runner.unityVersion === filter.unityVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find an idle runner matching the given Unity version and platform requirements.
|
|
||||||
*/
|
|
||||||
findAvailableRunner(requirements: { unityVersion: string; platform: string }): HotRunnerStatus | undefined {
|
|
||||||
return this.listRunners({
|
|
||||||
state: 'idle',
|
|
||||||
unityVersion: requirements.unityVersion,
|
|
||||||
platform: requirements.platform,
|
|
||||||
})[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a runner's status fields. Merges partial updates into existing status.
|
|
||||||
*/
|
|
||||||
updateRunner(id: string, update: Partial<HotRunnerStatus>): void {
|
|
||||||
const existing = this.runners.get(id);
|
|
||||||
if (!existing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.runners.set(id, { ...existing, ...update, id });
|
|
||||||
this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the total number of registered runners.
|
|
||||||
*/
|
|
||||||
get size(): number {
|
|
||||||
return this.runners.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate all runners in the registry and reset invalid ones to 'unhealthy'.
|
|
||||||
* Returns the number of runners that were repaired.
|
|
||||||
*/
|
|
||||||
validateAndRepair(): number {
|
|
||||||
let repaired = 0;
|
|
||||||
|
|
||||||
for (const [id, status] of this.runners) {
|
|
||||||
// Cast to unknown to bypass the type guard narrowing to 'never',
|
|
||||||
// since the Map is typed as HotRunnerStatus but entries may have
|
|
||||||
// been corrupted via direct deserialization or unsafe casts.
|
|
||||||
const entry = status as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (!isValidRunnerStatus(entry)) {
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] Runner ${id} has invalid state, marking as unhealthy`);
|
|
||||||
this.runners.set(id, {
|
|
||||||
id,
|
|
||||||
state: 'unhealthy',
|
|
||||||
unityVersion: typeof entry.unityVersion === 'string' ? entry.unityVersion : 'unknown',
|
|
||||||
platform: typeof entry.platform === 'string' ? entry.platform : 'unknown',
|
|
||||||
uptime: typeof entry.uptime === 'number' ? entry.uptime : 0,
|
|
||||||
jobsCompleted: typeof entry.jobsCompleted === 'number' ? entry.jobsCompleted : 0,
|
|
||||||
lastHealthCheck: typeof entry.lastHealthCheck === 'string' ? entry.lastHealthCheck : new Date().toISOString(),
|
|
||||||
memoryUsageMB: typeof entry.memoryUsageMB === 'number' ? entry.memoryUsageMB : 0,
|
|
||||||
});
|
|
||||||
repaired++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repaired > 0) {
|
|
||||||
this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
return repaired;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist current registry state to disk for crash recovery.
|
|
||||||
* Validates data before writing to prevent persisting corrupt state.
|
|
||||||
*/
|
|
||||||
private persist(): void {
|
|
||||||
if (!this.persistencePath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Validate data before persisting
|
|
||||||
for (const [id, status] of this.runners) {
|
|
||||||
if (!isValidRunnerStatus(status)) {
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] Skipping persistence -- runner ${id} has invalid state`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
runners: Object.fromEntries(this.runners),
|
|
||||||
configs: Object.fromEntries(this.configs),
|
|
||||||
};
|
|
||||||
const directory = path.dirname(this.persistencePath);
|
|
||||||
if (!fs.existsSync(directory)) {
|
|
||||||
fs.mkdirSync(directory, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(this.persistencePath, JSON.stringify(data, undefined, 2));
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] Failed to persist registry: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load registry state from disk. Returns the number of runners restored.
|
|
||||||
* Validates each restored entry and discards corrupt entries with warnings.
|
|
||||||
* If the persistence file itself is corrupt (invalid JSON), starts with
|
|
||||||
* an empty registry.
|
|
||||||
*/
|
|
||||||
loadFromDisk(): number {
|
|
||||||
if (!this.persistencePath || !fs.existsSync(this.persistencePath)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: any;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(this.persistencePath, 'utf8');
|
|
||||||
data = JSON.parse(raw);
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`[HotRunner] Persistence file is corrupt, starting with empty registry: ${error.message}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data !== 'object' || data === null) {
|
|
||||||
OrchestratorLogger.logWarning('[HotRunner] Persistence file has invalid structure, starting with empty registry');
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let discarded = 0;
|
|
||||||
|
|
||||||
if (data.runners && typeof data.runners === 'object') {
|
|
||||||
for (const [id, status] of Object.entries(data.runners)) {
|
|
||||||
if (isValidRunnerStatus(status)) {
|
|
||||||
this.runners.set(id, status);
|
|
||||||
} else {
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] Discarding invalid runner entry '${id}' from persistence file`);
|
|
||||||
discarded++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.configs && typeof data.configs === 'object') {
|
|
||||||
for (const [id, config] of Object.entries(data.configs)) {
|
|
||||||
// Only restore configs for runners that were successfully restored
|
|
||||||
if (this.runners.has(id)) {
|
|
||||||
if (isValidRunnerConfig(config)) {
|
|
||||||
this.configs.set(id, config);
|
|
||||||
} else {
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] Discarding invalid config entry '${id}' from persistence file`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discarded > 0) {
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] Discarded ${discarded} invalid runner(s) from persistence file`);
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Restored ${this.runners.size} runner(s) from disk`);
|
|
||||||
|
|
||||||
return this.runners.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
|
||||||
import { HotRunnerRegistry } from './hot-runner-registry';
|
|
||||||
import { HotRunnerHealthMonitor } from './hot-runner-health-monitor';
|
|
||||||
import { HotRunnerDispatcher, OutputCallback } from './hot-runner-dispatcher';
|
|
||||||
import {
|
|
||||||
HotRunnerConfig,
|
|
||||||
HotRunnerJobRequest,
|
|
||||||
HotRunnerJobResult,
|
|
||||||
HotRunnerStatus,
|
|
||||||
HotRunnerTransport,
|
|
||||||
} from './hot-runner-types';
|
|
||||||
import BuildParameters from '../../../build-parameters';
|
|
||||||
|
|
||||||
export class HotRunnerService {
|
|
||||||
private registry: HotRunnerRegistry;
|
|
||||||
private healthMonitor: HotRunnerHealthMonitor;
|
|
||||||
private dispatcher: HotRunnerDispatcher;
|
|
||||||
private transports: Map<string, HotRunnerTransport> = new Map();
|
|
||||||
private config: HotRunnerConfig | undefined;
|
|
||||||
|
|
||||||
constructor(persistenceDirectory?: string) {
|
|
||||||
this.registry = new HotRunnerRegistry(persistenceDirectory);
|
|
||||||
this.healthMonitor = new HotRunnerHealthMonitor();
|
|
||||||
this.dispatcher = new HotRunnerDispatcher(this.transports);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the hot runner service: load persisted state, start health monitoring.
|
|
||||||
*/
|
|
||||||
async initialize(config: HotRunnerConfig): Promise<void> {
|
|
||||||
this.config = config;
|
|
||||||
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`[HotRunner] Initializing service (transport: ${config.transport}, ${config.host}:${config.port})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Attempt to restore previously registered runners from disk
|
|
||||||
const restored = this.registry.loadFromDisk();
|
|
||||||
if (restored > 0) {
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Restored ${restored} runner(s) from persistence`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start health monitoring
|
|
||||||
this.healthMonitor.startMonitoring(this.registry, config.healthCheckInterval, this.transports);
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Service initialized`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a runner with a transport implementation.
|
|
||||||
* Returns the runner ID.
|
|
||||||
*/
|
|
||||||
registerRunner(config: HotRunnerConfig, transport: HotRunnerTransport): string {
|
|
||||||
const id = this.registry.registerRunner(config);
|
|
||||||
this.transports.set(id, transport);
|
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit a build job to an available hot runner.
|
|
||||||
* Converts BuildParameters to a HotRunnerJobRequest and dispatches.
|
|
||||||
*/
|
|
||||||
async submitBuild(params: BuildParameters, onOutput?: OutputCallback): Promise<HotRunnerJobResult> {
|
|
||||||
const request: HotRunnerJobRequest = {
|
|
||||||
jobId: params.buildGuid || `build-${Date.now()}`,
|
|
||||||
buildMethod: params.buildMethod || undefined,
|
|
||||||
buildTarget: params.targetPlatform,
|
|
||||||
buildPath: params.buildPath,
|
|
||||||
customParameters: params.customParameters ? this.parseCustomParameters(params.customParameters) : undefined,
|
|
||||||
timeout: 30 * 60 * 1000, // 30 minutes default
|
|
||||||
};
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Submitting build: ${request.jobId} (target: ${request.buildTarget})`);
|
|
||||||
|
|
||||||
return this.dispatcher.dispatchJob(request, this.registry, params.editorVersion, onOutput);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit a test job to an available hot runner.
|
|
||||||
* Converts BuildParameters and optional suite config to a test-mode HotRunnerJobRequest.
|
|
||||||
*/
|
|
||||||
async submitTest(
|
|
||||||
params: BuildParameters,
|
|
||||||
suiteConfig?: { testMode?: 'editmode' | 'playmode'; testSuitePath?: string },
|
|
||||||
onOutput?: OutputCallback,
|
|
||||||
): Promise<HotRunnerJobResult> {
|
|
||||||
const request: HotRunnerJobRequest = {
|
|
||||||
jobId: params.buildGuid || `test-${Date.now()}`,
|
|
||||||
buildTarget: params.targetPlatform,
|
|
||||||
customParameters: params.customParameters ? this.parseCustomParameters(params.customParameters) : undefined,
|
|
||||||
timeout: 30 * 60 * 1000, // 30 minutes default
|
|
||||||
testMode: suiteConfig?.testMode ?? 'editmode',
|
|
||||||
testSuitePath: suiteConfig?.testSuitePath,
|
|
||||||
};
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Submitting test: ${request.jobId} (mode: ${request.testMode})`);
|
|
||||||
|
|
||||||
return this.dispatcher.dispatchJob(request, this.registry, params.editorVersion, onOutput);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shut down the service: stop health monitoring, disconnect all transports,
|
|
||||||
* and unregister all runners.
|
|
||||||
*/
|
|
||||||
async shutdown(): Promise<void> {
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Shutting down service`);
|
|
||||||
|
|
||||||
this.healthMonitor.stopMonitoring();
|
|
||||||
|
|
||||||
const disconnectPromises: Promise<void>[] = [];
|
|
||||||
for (const [id, transport] of this.transports.entries()) {
|
|
||||||
disconnectPromises.push(
|
|
||||||
transport.disconnect().catch((error: any) => {
|
|
||||||
OrchestratorLogger.logWarning(`[HotRunner] Error disconnecting runner ${id}: ${error.message}`);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await Promise.all(disconnectPromises);
|
|
||||||
|
|
||||||
this.transports.clear();
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`[HotRunner] Service shut down`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the status of all registered runners.
|
|
||||||
*/
|
|
||||||
getStatus(): HotRunnerStatus[] {
|
|
||||||
return this.registry.listRunners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the underlying registry (for testing or advanced use).
|
|
||||||
*/
|
|
||||||
getRegistry(): HotRunnerRegistry {
|
|
||||||
return this.registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a space-separated custom parameters string into a key-value map.
|
|
||||||
* Handles `-key value` and `-key=value` formats.
|
|
||||||
*/
|
|
||||||
private parseCustomParameters(raw: string): Record<string, string> {
|
|
||||||
const result: Record<string, string> = {};
|
|
||||||
const parts = raw.trim().split(/\s+/);
|
|
||||||
|
|
||||||
for (let i = 0; i < parts.length; i++) {
|
|
||||||
const part = parts[i];
|
|
||||||
if (part.startsWith('-')) {
|
|
||||||
const key = part.replace(/^-+/, '');
|
|
||||||
if (key.includes('=')) {
|
|
||||||
const [k, ...v] = key.split('=');
|
|
||||||
result[k] = v.join('=');
|
|
||||||
} else if (i + 1 < parts.length && !parts[i + 1].startsWith('-')) {
|
|
||||||
result[key] = parts[i + 1];
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
result[key] = 'true';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
export interface HotRunnerConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
transport: 'websocket' | 'grpc' | 'named-pipe';
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
healthCheckInterval: number; // seconds
|
|
||||||
maxIdleTime: number; // seconds before recycling
|
|
||||||
maxJobsBeforeRecycle: number;
|
|
||||||
unityVersion?: string;
|
|
||||||
platform?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HotRunnerStatus {
|
|
||||||
id: string;
|
|
||||||
state: 'idle' | 'busy' | 'starting' | 'stopping' | 'unhealthy';
|
|
||||||
unityVersion: string;
|
|
||||||
platform: string;
|
|
||||||
currentJob?: string;
|
|
||||||
lastJobCompleted?: string;
|
|
||||||
uptime: number;
|
|
||||||
jobsCompleted: number;
|
|
||||||
lastHealthCheck: string;
|
|
||||||
memoryUsageMB: number;
|
|
||||||
libraryHash?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HotRunnerJobRequest {
|
|
||||||
jobId: string;
|
|
||||||
buildMethod?: string;
|
|
||||||
buildTarget: string;
|
|
||||||
buildPath?: string;
|
|
||||||
customParameters?: Record<string, string>;
|
|
||||||
timeout: number;
|
|
||||||
testMode?: 'editmode' | 'playmode';
|
|
||||||
testSuitePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HotRunnerJobResult {
|
|
||||||
jobId: string;
|
|
||||||
success: boolean;
|
|
||||||
exitCode: number;
|
|
||||||
duration: number;
|
|
||||||
output: string;
|
|
||||||
artifacts?: string[];
|
|
||||||
testResults?: string; // path to test result file
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HotRunnerTransport {
|
|
||||||
connect(config: HotRunnerConfig): Promise<void>;
|
|
||||||
disconnect(): Promise<void>;
|
|
||||||
sendJob(request: HotRunnerJobRequest): Promise<HotRunnerJobResult>;
|
|
||||||
getStatus(): Promise<HotRunnerStatus>;
|
|
||||||
healthCheck(): Promise<boolean>;
|
|
||||||
}
|
|
||||||
@@ -1,740 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import { HotRunnerRegistry } from './hot-runner-registry';
|
|
||||||
import { HotRunnerHealthMonitor } from './hot-runner-health-monitor';
|
|
||||||
import { HotRunnerDispatcher } from './hot-runner-dispatcher';
|
|
||||||
import { HotRunnerService } from './hot-runner-service';
|
|
||||||
import {
|
|
||||||
HotRunnerConfig,
|
|
||||||
HotRunnerJobRequest,
|
|
||||||
HotRunnerJobResult,
|
|
||||||
HotRunnerStatus,
|
|
||||||
HotRunnerTransport,
|
|
||||||
} from './hot-runner-types';
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
jest.mock('node:fs');
|
|
||||||
jest.mock('../core/orchestrator-logger');
|
|
||||||
|
|
||||||
const mockFs = fs as jest.Mocked<typeof fs>;
|
|
||||||
|
|
||||||
function createMockConfig(overrides?: Partial<HotRunnerConfig>): HotRunnerConfig {
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
transport: 'websocket',
|
|
||||||
host: 'localhost',
|
|
||||||
port: 9090,
|
|
||||||
healthCheckInterval: 30,
|
|
||||||
maxIdleTime: 3600,
|
|
||||||
maxJobsBeforeRecycle: 100,
|
|
||||||
unityVersion: '2022.3.0f1',
|
|
||||||
platform: 'StandaloneWindows64',
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockTransport(overrides?: Partial<HotRunnerTransport>): HotRunnerTransport {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
||||||
connect: jest.fn().mockResolvedValue(undefined),
|
|
||||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
||||||
disconnect: jest.fn().mockResolvedValue(undefined),
|
|
||||||
sendJob: jest.fn().mockResolvedValue({
|
|
||||||
jobId: 'test-job',
|
|
||||||
success: true,
|
|
||||||
exitCode: 0,
|
|
||||||
duration: 5000,
|
|
||||||
output: 'Build succeeded',
|
|
||||||
artifacts: ['build/output.exe'],
|
|
||||||
} as HotRunnerJobResult),
|
|
||||||
getStatus: jest.fn().mockResolvedValue({
|
|
||||||
id: 'mock-runner',
|
|
||||||
state: 'idle',
|
|
||||||
unityVersion: '2022.3.0f1',
|
|
||||||
platform: 'StandaloneWindows64',
|
|
||||||
uptime: 3600,
|
|
||||||
jobsCompleted: 5,
|
|
||||||
lastHealthCheck: new Date().toISOString(),
|
|
||||||
memoryUsageMB: 1024,
|
|
||||||
} as HotRunnerStatus),
|
|
||||||
healthCheck: jest.fn().mockResolvedValue(true),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockJobRequest(overrides?: Partial<HotRunnerJobRequest>): HotRunnerJobRequest {
|
|
||||||
return {
|
|
||||||
jobId: 'job-001',
|
|
||||||
buildTarget: 'StandaloneWindows64',
|
|
||||||
timeout: 60000,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Registry Tests ---
|
|
||||||
|
|
||||||
describe('HotRunnerRegistry', () => {
|
|
||||||
let registry: HotRunnerRegistry;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
registry = new HotRunnerRegistry();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should register a runner and return an ID', () => {
|
|
||||||
const config = createMockConfig();
|
|
||||||
const id = registry.registerRunner(config);
|
|
||||||
|
|
||||||
expect(id).toMatch(/^hr-/);
|
|
||||||
expect(registry.size).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should retrieve a registered runner by ID', () => {
|
|
||||||
const config = createMockConfig();
|
|
||||||
const id = registry.registerRunner(config);
|
|
||||||
const runner = registry.getRunner(id);
|
|
||||||
|
|
||||||
expect(runner).toBeDefined();
|
|
||||||
expect(runner!.id).toBe(id);
|
|
||||||
expect(runner!.state).toBe('starting');
|
|
||||||
expect(runner!.unityVersion).toBe('2022.3.0f1');
|
|
||||||
expect(runner!.platform).toBe('StandaloneWindows64');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return undefined for unknown runner ID', () => {
|
|
||||||
const runner = registry.getRunner('nonexistent');
|
|
||||||
expect(runner).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should unregister a runner', () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
expect(registry.size).toBe(1);
|
|
||||||
|
|
||||||
registry.unregisterRunner(id);
|
|
||||||
expect(registry.size).toBe(0);
|
|
||||||
expect(registry.getRunner(id)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle unregistering a nonexistent runner gracefully', () => {
|
|
||||||
registry.unregisterRunner('nonexistent');
|
|
||||||
expect(registry.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list all runners without filter', () => {
|
|
||||||
registry.registerRunner(createMockConfig({ platform: 'StandaloneWindows64' }));
|
|
||||||
registry.registerRunner(createMockConfig({ platform: 'StandaloneLinux64' }));
|
|
||||||
registry.registerRunner(createMockConfig({ platform: 'StandaloneOSX' }));
|
|
||||||
|
|
||||||
const all = registry.listRunners();
|
|
||||||
expect(all).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter runners by platform', () => {
|
|
||||||
registry.registerRunner(createMockConfig({ platform: 'StandaloneWindows64' }));
|
|
||||||
registry.registerRunner(createMockConfig({ platform: 'StandaloneLinux64' }));
|
|
||||||
registry.registerRunner(createMockConfig({ platform: 'StandaloneWindows64' }));
|
|
||||||
|
|
||||||
const windows = registry.listRunners({ platform: 'StandaloneWindows64' });
|
|
||||||
expect(windows).toHaveLength(2);
|
|
||||||
|
|
||||||
const linux = registry.listRunners({ platform: 'StandaloneLinux64' });
|
|
||||||
expect(linux).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter runners by state', () => {
|
|
||||||
const id1 = registry.registerRunner(createMockConfig());
|
|
||||||
registry.registerRunner(createMockConfig());
|
|
||||||
|
|
||||||
registry.updateRunner(id1, { state: 'idle' });
|
|
||||||
|
|
||||||
// second runner remains in 'starting' state
|
|
||||||
|
|
||||||
const idle = registry.listRunners({ state: 'idle' });
|
|
||||||
expect(idle).toHaveLength(1);
|
|
||||||
expect(idle[0].id).toBe(id1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter runners by Unity version', () => {
|
|
||||||
registry.registerRunner(createMockConfig({ unityVersion: '2022.3.0f1' }));
|
|
||||||
registry.registerRunner(createMockConfig({ unityVersion: '2023.1.0f1' }));
|
|
||||||
registry.registerRunner(createMockConfig({ unityVersion: '2022.3.0f1' }));
|
|
||||||
|
|
||||||
const v2022 = registry.listRunners({ unityVersion: '2022.3.0f1' });
|
|
||||||
expect(v2022).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find an available idle runner matching requirements', () => {
|
|
||||||
const id1 = registry.registerRunner(
|
|
||||||
createMockConfig({ unityVersion: '2022.3.0f1', platform: 'StandaloneWindows64' }),
|
|
||||||
);
|
|
||||||
registry.updateRunner(id1, { state: 'idle' });
|
|
||||||
|
|
||||||
const id2 = registry.registerRunner(
|
|
||||||
createMockConfig({ unityVersion: '2023.1.0f1', platform: 'StandaloneLinux64' }),
|
|
||||||
);
|
|
||||||
registry.updateRunner(id2, { state: 'idle' });
|
|
||||||
|
|
||||||
const found = registry.findAvailableRunner({
|
|
||||||
unityVersion: '2022.3.0f1',
|
|
||||||
platform: 'StandaloneWindows64',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(found).toBeDefined();
|
|
||||||
expect(found!.id).toBe(id1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return undefined when no runner matches requirements', () => {
|
|
||||||
const id = registry.registerRunner(
|
|
||||||
createMockConfig({ unityVersion: '2022.3.0f1', platform: 'StandaloneWindows64' }),
|
|
||||||
);
|
|
||||||
registry.updateRunner(id, { state: 'idle' });
|
|
||||||
|
|
||||||
const found = registry.findAvailableRunner({
|
|
||||||
unityVersion: '2023.1.0f1',
|
|
||||||
platform: 'StandaloneLinux64',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(found).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update runner status fields', () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
registry.updateRunner(id, { state: 'idle', memoryUsageMB: 2048 });
|
|
||||||
|
|
||||||
const runner = registry.getRunner(id);
|
|
||||||
expect(runner!.state).toBe('idle');
|
|
||||||
expect(runner!.memoryUsageMB).toBe(2048);
|
|
||||||
|
|
||||||
// ID should not be overridden by the update
|
|
||||||
expect(runner!.id).toBe(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should persist and load registry from disk', () => {
|
|
||||||
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.writeFileSync.mockImplementation(() => {});
|
|
||||||
mockFs.mkdirSync.mockImplementation(() => '' as any);
|
|
||||||
|
|
||||||
const id = persistenceRegistry.registerRunner(createMockConfig());
|
|
||||||
|
|
||||||
// Verify writeFileSync was called for persistence
|
|
||||||
expect(mockFs.writeFileSync).toHaveBeenCalled();
|
|
||||||
const writtenData = JSON.parse((mockFs.writeFileSync as jest.Mock).mock.calls[0][1] as string);
|
|
||||||
expect(writtenData.runners).toBeDefined();
|
|
||||||
expect(writtenData.runners[id]).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should load runners from disk on loadFromDisk', () => {
|
|
||||||
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
|
|
||||||
const storedData = {
|
|
||||||
runners: {
|
|
||||||
'hr-restored': {
|
|
||||||
id: 'hr-restored',
|
|
||||||
state: 'idle',
|
|
||||||
unityVersion: '2022.3.0f1',
|
|
||||||
platform: 'StandaloneWindows64',
|
|
||||||
uptime: 100,
|
|
||||||
jobsCompleted: 3,
|
|
||||||
lastHealthCheck: new Date().toISOString(),
|
|
||||||
memoryUsageMB: 512,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
configs: {
|
|
||||||
'hr-restored': createMockConfig(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(storedData));
|
|
||||||
|
|
||||||
const count = persistenceRegistry.loadFromDisk();
|
|
||||||
expect(count).toBe(1);
|
|
||||||
expect(persistenceRegistry.getRunner('hr-restored')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should discard invalid runner entries when loading from disk', () => {
|
|
||||||
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
|
|
||||||
const storedData = {
|
|
||||||
runners: {
|
|
||||||
'hr-valid': {
|
|
||||||
id: 'hr-valid',
|
|
||||||
state: 'idle',
|
|
||||||
unityVersion: '2022.3.0f1',
|
|
||||||
platform: 'StandaloneWindows64',
|
|
||||||
uptime: 100,
|
|
||||||
jobsCompleted: 3,
|
|
||||||
lastHealthCheck: new Date().toISOString(),
|
|
||||||
memoryUsageMB: 512,
|
|
||||||
},
|
|
||||||
'hr-invalid': {
|
|
||||||
// Missing required fields like state, unityVersion
|
|
||||||
id: 'hr-invalid',
|
|
||||||
},
|
|
||||||
'hr-bad-state': {
|
|
||||||
id: 'hr-bad-state',
|
|
||||||
state: 'nonexistent-state',
|
|
||||||
unityVersion: '2022.3.0f1',
|
|
||||||
platform: 'StandaloneWindows64',
|
|
||||||
uptime: 0,
|
|
||||||
jobsCompleted: 0,
|
|
||||||
lastHealthCheck: new Date().toISOString(),
|
|
||||||
memoryUsageMB: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
configs: {
|
|
||||||
'hr-valid': createMockConfig(),
|
|
||||||
'hr-invalid': createMockConfig(),
|
|
||||||
'hr-bad-state': createMockConfig(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(storedData));
|
|
||||||
|
|
||||||
const count = persistenceRegistry.loadFromDisk();
|
|
||||||
expect(count).toBe(1);
|
|
||||||
expect(persistenceRegistry.getRunner('hr-valid')).toBeDefined();
|
|
||||||
expect(persistenceRegistry.getRunner('hr-invalid')).toBeUndefined();
|
|
||||||
expect(persistenceRegistry.getRunner('hr-bad-state')).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle corrupt JSON persistence file gracefully', () => {
|
|
||||||
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue('{ invalid json !!!');
|
|
||||||
|
|
||||||
const count = persistenceRegistry.loadFromDisk();
|
|
||||||
expect(count).toBe(0);
|
|
||||||
expect(persistenceRegistry.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle persistence file with invalid top-level structure', () => {
|
|
||||||
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue('"just a string"');
|
|
||||||
|
|
||||||
const count = persistenceRegistry.loadFromDisk();
|
|
||||||
expect(count).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle persistence file with null runners', () => {
|
|
||||||
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue('{"runners": null, "configs": null}');
|
|
||||||
|
|
||||||
const count = persistenceRegistry.loadFromDisk();
|
|
||||||
expect(count).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate and repair invalid runners', () => {
|
|
||||||
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.writeFileSync.mockImplementation(() => {});
|
|
||||||
mockFs.mkdirSync.mockImplementation(() => '' as any);
|
|
||||||
|
|
||||||
// Register a valid runner first
|
|
||||||
const id = persistenceRegistry.registerRunner(createMockConfig());
|
|
||||||
persistenceRegistry.updateRunner(id, { state: 'idle' });
|
|
||||||
|
|
||||||
// Manually corrupt the runner's state by setting an invalid state
|
|
||||||
// (we access via the public API -- updateRunner with a cast)
|
|
||||||
persistenceRegistry.updateRunner(id, { state: 'invalid-state' as any });
|
|
||||||
|
|
||||||
const repaired = persistenceRegistry.validateAndRepair();
|
|
||||||
expect(repaired).toBe(1);
|
|
||||||
|
|
||||||
const runner = persistenceRegistry.getRunner(id);
|
|
||||||
expect(runner!.state).toBe('unhealthy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not discard configs for valid runners when loading from disk', () => {
|
|
||||||
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
|
|
||||||
const storedData = {
|
|
||||||
runners: {
|
|
||||||
'hr-valid': {
|
|
||||||
id: 'hr-valid',
|
|
||||||
state: 'idle',
|
|
||||||
unityVersion: '2022.3.0f1',
|
|
||||||
platform: 'StandaloneWindows64',
|
|
||||||
uptime: 100,
|
|
||||||
jobsCompleted: 3,
|
|
||||||
lastHealthCheck: new Date().toISOString(),
|
|
||||||
memoryUsageMB: 512,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
configs: {
|
|
||||||
'hr-valid': createMockConfig(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
|
||||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(storedData));
|
|
||||||
|
|
||||||
persistenceRegistry.loadFromDisk();
|
|
||||||
expect(persistenceRegistry.getConfig('hr-valid')).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Health Monitor Tests ---
|
|
||||||
|
|
||||||
describe('HotRunnerHealthMonitor', () => {
|
|
||||||
let monitor: HotRunnerHealthMonitor;
|
|
||||||
let registry: HotRunnerRegistry;
|
|
||||||
let transports: Map<string, HotRunnerTransport>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.useFakeTimers();
|
|
||||||
monitor = new HotRunnerHealthMonitor();
|
|
||||||
registry = new HotRunnerRegistry();
|
|
||||||
transports = new Map();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
monitor.stopMonitoring();
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should start and stop monitoring', () => {
|
|
||||||
monitor.startMonitoring(registry, 30, transports);
|
|
||||||
expect(monitor.isMonitoring).toBe(true);
|
|
||||||
|
|
||||||
monitor.stopMonitoring();
|
|
||||||
expect(monitor.isMonitoring).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should report healthy when transport health check passes', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
registry.updateRunner(id, { state: 'idle' });
|
|
||||||
|
|
||||||
const transport = createMockTransport();
|
|
||||||
transports.set(id, transport);
|
|
||||||
monitor.startMonitoring(registry, 30, transports);
|
|
||||||
|
|
||||||
const healthy = await monitor.checkHealth(id);
|
|
||||||
expect(healthy).toBe(true);
|
|
||||||
expect(transport.healthCheck).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should mark runner as unhealthy when health check fails', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
registry.updateRunner(id, { state: 'idle' });
|
|
||||||
|
|
||||||
const transport = createMockTransport({
|
|
||||||
healthCheck: jest.fn().mockResolvedValue(false),
|
|
||||||
});
|
|
||||||
transports.set(id, transport);
|
|
||||||
monitor.startMonitoring(registry, 30, transports);
|
|
||||||
|
|
||||||
const healthy = await monitor.checkHealth(id);
|
|
||||||
expect(healthy).toBe(false);
|
|
||||||
|
|
||||||
const runner = registry.getRunner(id);
|
|
||||||
expect(runner!.state).toBe('unhealthy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should mark runner as unhealthy when health check throws', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
registry.updateRunner(id, { state: 'idle' });
|
|
||||||
|
|
||||||
const transport = createMockTransport({
|
|
||||||
healthCheck: jest.fn().mockRejectedValue(new Error('Connection refused')),
|
|
||||||
});
|
|
||||||
transports.set(id, transport);
|
|
||||||
monitor.startMonitoring(registry, 30, transports);
|
|
||||||
|
|
||||||
const healthy = await monitor.checkHealth(id);
|
|
||||||
expect(healthy).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should recycle unhealthy runner and remove from registry', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
const transport = createMockTransport();
|
|
||||||
transports.set(id, transport);
|
|
||||||
monitor.startMonitoring(registry, 30, transports);
|
|
||||||
|
|
||||||
await monitor.recycleUnhealthyRunner(id);
|
|
||||||
|
|
||||||
expect(registry.getRunner(id)).toBeUndefined();
|
|
||||||
expect(transport.disconnect).toHaveBeenCalled();
|
|
||||||
expect(transports.has(id)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should recycle idle runner when max idle time exceeded', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig({ maxIdleTime: 60 }));
|
|
||||||
|
|
||||||
// Set lastHealthCheck to 120 seconds ago
|
|
||||||
const oldDate = new Date(Date.now() - 120 * 1000).toISOString();
|
|
||||||
registry.updateRunner(id, { state: 'idle', lastHealthCheck: oldDate });
|
|
||||||
|
|
||||||
const transport = createMockTransport();
|
|
||||||
transports.set(id, transport);
|
|
||||||
monitor.startMonitoring(registry, 30, transports);
|
|
||||||
|
|
||||||
await monitor.recycleIdleRunner(id, 60);
|
|
||||||
|
|
||||||
expect(registry.getRunner(id)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not recycle idle runner when within max idle time', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig({ maxIdleTime: 3600 }));
|
|
||||||
registry.updateRunner(id, {
|
|
||||||
state: 'idle',
|
|
||||||
lastHealthCheck: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const transport = createMockTransport();
|
|
||||||
transports.set(id, transport);
|
|
||||||
monitor.startMonitoring(registry, 30, transports);
|
|
||||||
|
|
||||||
await monitor.recycleIdleRunner(id, 3600);
|
|
||||||
|
|
||||||
// Runner should still exist
|
|
||||||
expect(registry.getRunner(id)).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when no transport exists for runner', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
|
|
||||||
// Do not set any transport for this runner
|
|
||||||
monitor.startMonitoring(registry, 30, transports);
|
|
||||||
|
|
||||||
const healthy = await monitor.checkHealth(id);
|
|
||||||
expect(healthy).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Dispatcher Tests ---
|
|
||||||
|
|
||||||
describe('HotRunnerDispatcher', () => {
|
|
||||||
let registry: HotRunnerRegistry;
|
|
||||||
let transports: Map<string, HotRunnerTransport>;
|
|
||||||
let dispatcher: HotRunnerDispatcher;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
registry = new HotRunnerRegistry();
|
|
||||||
transports = new Map();
|
|
||||||
dispatcher = new HotRunnerDispatcher(transports);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should dispatch a job to an available runner', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
registry.updateRunner(id, { state: 'idle' });
|
|
||||||
|
|
||||||
const transport = createMockTransport();
|
|
||||||
transports.set(id, transport);
|
|
||||||
|
|
||||||
const request = createMockJobRequest();
|
|
||||||
const result = await dispatcher.dispatchJob(request, registry, '2022.3.0f1');
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.exitCode).toBe(0);
|
|
||||||
expect(transport.sendJob).toHaveBeenCalledWith(request);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should mark runner as busy during job execution', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
registry.updateRunner(id, { state: 'idle' });
|
|
||||||
|
|
||||||
const statesDuringJob: string[] = [];
|
|
||||||
const transport = createMockTransport({
|
|
||||||
sendJob: jest.fn().mockImplementation(async () => {
|
|
||||||
const runner = registry.getRunner(id);
|
|
||||||
if (runner) statesDuringJob.push(runner.state);
|
|
||||||
|
|
||||||
return {
|
|
||||||
jobId: 'job-001',
|
|
||||||
success: true,
|
|
||||||
exitCode: 0,
|
|
||||||
duration: 1000,
|
|
||||||
output: 'ok',
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
transports.set(id, transport);
|
|
||||||
|
|
||||||
await dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1');
|
|
||||||
|
|
||||||
expect(statesDuringJob).toContain('busy');
|
|
||||||
|
|
||||||
// After completion, should be idle again
|
|
||||||
const runner = registry.getRunner(id);
|
|
||||||
expect(runner!.state).toBe('idle');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should increment jobsCompleted after successful dispatch', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
registry.updateRunner(id, { state: 'idle', jobsCompleted: 5 });
|
|
||||||
|
|
||||||
const transport = createMockTransport();
|
|
||||||
transports.set(id, transport);
|
|
||||||
|
|
||||||
await dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1');
|
|
||||||
|
|
||||||
const runner = registry.getRunner(id);
|
|
||||||
expect(runner!.jobsCompleted).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw when no runner is available and wait times out', async () => {
|
|
||||||
// No runners registered at all
|
|
||||||
const request = createMockJobRequest({ timeout: 100 });
|
|
||||||
|
|
||||||
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/Timed out waiting/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw when runner has no transport', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
registry.updateRunner(id, { state: 'idle' });
|
|
||||||
|
|
||||||
// No transport set for this runner
|
|
||||||
|
|
||||||
const request = createMockJobRequest();
|
|
||||||
|
|
||||||
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/No transport available/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle job failure and return runner to idle', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
registry.updateRunner(id, { state: 'idle' });
|
|
||||||
|
|
||||||
const transport = createMockTransport({
|
|
||||||
sendJob: jest.fn().mockRejectedValue(new Error('Unity crashed')),
|
|
||||||
});
|
|
||||||
transports.set(id, transport);
|
|
||||||
|
|
||||||
await expect(dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1')).rejects.toThrow(
|
|
||||||
'Unity crashed',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Runner should be back to idle despite failure
|
|
||||||
const runner = registry.getRunner(id);
|
|
||||||
expect(runner!.state).toBe('idle');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle job timeout', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
registry.updateRunner(id, { state: 'idle' });
|
|
||||||
|
|
||||||
const transport = createMockTransport({
|
|
||||||
sendJob: jest.fn().mockImplementation(
|
|
||||||
() => new Promise((resolve) => setTimeout(resolve, 60000)), // never resolves within timeout
|
|
||||||
),
|
|
||||||
});
|
|
||||||
transports.set(id, transport);
|
|
||||||
|
|
||||||
const request = createMockJobRequest({ timeout: 50 });
|
|
||||||
|
|
||||||
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/timed out/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should disconnect transport on job timeout', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
registry.updateRunner(id, { state: 'idle' });
|
|
||||||
|
|
||||||
const transport = createMockTransport({
|
|
||||||
sendJob: jest.fn().mockImplementation(
|
|
||||||
() => new Promise((resolve) => setTimeout(resolve, 60000)), // never resolves within timeout
|
|
||||||
),
|
|
||||||
});
|
|
||||||
transports.set(id, transport);
|
|
||||||
|
|
||||||
const request = createMockJobRequest({ timeout: 50 });
|
|
||||||
|
|
||||||
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/timed out/);
|
|
||||||
|
|
||||||
// Transport should have been disconnected to clean up orphaned connection
|
|
||||||
expect(transport.disconnect).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call output callback with job output', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
registry.updateRunner(id, { state: 'idle' });
|
|
||||||
|
|
||||||
const transport = createMockTransport();
|
|
||||||
transports.set(id, transport);
|
|
||||||
|
|
||||||
const outputCallback = jest.fn();
|
|
||||||
await dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1', outputCallback);
|
|
||||||
|
|
||||||
expect(outputCallback).toHaveBeenCalledWith('Build succeeded');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should wait for runner to become available', async () => {
|
|
||||||
const id = registry.registerRunner(createMockConfig());
|
|
||||||
|
|
||||||
// Runner starts in 'starting' state, not idle
|
|
||||||
|
|
||||||
const transport = createMockTransport();
|
|
||||||
transports.set(id, transport);
|
|
||||||
|
|
||||||
// Simulate runner becoming idle after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
registry.updateRunner(id, { state: 'idle' });
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
const request = createMockJobRequest({ timeout: 5000 });
|
|
||||||
const result = await dispatcher.dispatchJob(request, registry, '2022.3.0f1');
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Service Integration Tests ---
|
|
||||||
|
|
||||||
describe('HotRunnerService', () => {
|
|
||||||
let service: HotRunnerService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
mockFs.existsSync.mockReturnValue(false);
|
|
||||||
service = new HotRunnerService();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await service.shutdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize and shut down cleanly', async () => {
|
|
||||||
const config = createMockConfig();
|
|
||||||
await service.initialize(config);
|
|
||||||
|
|
||||||
const status = service.getStatus();
|
|
||||||
expect(status).toEqual([]);
|
|
||||||
|
|
||||||
await service.shutdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should register a runner with transport', async () => {
|
|
||||||
await service.initialize(createMockConfig());
|
|
||||||
|
|
||||||
const transport = createMockTransport();
|
|
||||||
const id = service.registerRunner(createMockConfig(), transport);
|
|
||||||
|
|
||||||
expect(id).toMatch(/^hr-/);
|
|
||||||
expect(service.getStatus()).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should disconnect all transports on shutdown', async () => {
|
|
||||||
await service.initialize(createMockConfig());
|
|
||||||
|
|
||||||
const transport1 = createMockTransport();
|
|
||||||
const transport2 = createMockTransport();
|
|
||||||
service.registerRunner(createMockConfig(), transport1);
|
|
||||||
service.registerRunner(createMockConfig(), transport2);
|
|
||||||
|
|
||||||
await service.shutdown();
|
|
||||||
|
|
||||||
expect(transport1.disconnect).toHaveBeenCalled();
|
|
||||||
expect(transport2.disconnect).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should expose the underlying registry', async () => {
|
|
||||||
await service.initialize(createMockConfig());
|
|
||||||
const registry = service.getRegistry();
|
|
||||||
|
|
||||||
expect(registry).toBeInstanceOf(HotRunnerRegistry);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user