mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-01 06:16:14 -07:00
Compare commits
53 Commits
feature/us
...
release/lt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
936ca76c4e | ||
|
|
5f63107fa7 | ||
|
|
cdb17b2a9d | ||
|
|
6f751bf476 | ||
|
|
49c3bcf0a5 | ||
|
|
54a6c80784 | ||
|
|
c08d13e3a5 | ||
|
|
f05cfe7036 | ||
|
|
18e20aaff1 | ||
|
|
52a5bc4a6d | ||
|
|
67fd293725 | ||
|
|
f77a1350e6 | ||
|
|
7307bea200 | ||
|
|
3e1547170b | ||
|
|
2ef2275ae3 | ||
|
|
6c548cd3f7 | ||
|
|
02d4ec0dd2 | ||
|
|
b4ffa3e070 | ||
|
|
81ba9c38af | ||
|
|
79ae55802d | ||
|
|
e9c247f04f | ||
|
|
3976b7cedd | ||
|
|
9789eb5c3b | ||
|
|
b3bd405399 | ||
|
|
4d7e8717e9 | ||
|
|
120c3c5b24 | ||
|
|
40dd436000 | ||
|
|
cff759721a | ||
|
|
f06f99b3e5 | ||
|
|
1f3affe097 | ||
|
|
fe63d7b32d | ||
|
|
007852a800 | ||
|
|
ff56194b30 | ||
|
|
47670cf3ce | ||
|
|
4f07508484 | ||
|
|
7db70a712f | ||
|
|
12f287168d | ||
|
|
26903e96dd | ||
|
|
cf3478c8ec | ||
|
|
7f895304f4 | ||
|
|
e4c156e7b0 | ||
|
|
8a41533779 | ||
|
|
a0c79bd657 | ||
|
|
f4451060a7 | ||
|
|
17a0ea3776 | ||
|
|
7e9d0bf53e | ||
|
|
cfac5f138d | ||
|
|
d17b099593 | ||
|
|
8194790728 | ||
|
|
786ee3799c | ||
|
|
f4bc5d20c4 | ||
|
|
d8563369e1 | ||
|
|
5268630ef0 |
@@ -2,4 +2,3 @@ dist/
|
|||||||
lib/
|
lib/
|
||||||
node_modules/
|
node_modules/
|
||||||
jest.config.js
|
jest.config.js
|
||||||
src/types/
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
|
|||||||
6
.github/workflows/build-tests-mac.yml
vendored
6
.github/workflows/build-tests-mac.yml
vendored
@@ -18,9 +18,9 @@ jobs:
|
|||||||
projectPath:
|
projectPath:
|
||||||
- test-project
|
- test-project
|
||||||
unityVersion:
|
unityVersion:
|
||||||
- 2021.3.45f2
|
- 2021.3.45f1
|
||||||
- 2022.3.62f3
|
- 2022.3.13f1
|
||||||
- 2023.2.22f1
|
- 2023.2.2f1
|
||||||
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.45f2
|
- 2021.3.32f1
|
||||||
- 2022.3.62f3
|
- 2022.3.13f1
|
||||||
- 2023.2.22f1
|
- 2023.2.2f1
|
||||||
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.45f2
|
- 2021.3.32f1
|
||||||
- 2022.3.62f3
|
- 2022.3.13f1
|
||||||
- 2023.2.22f1
|
- 2023.2.2f1
|
||||||
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,34 +66,6 @@ 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 #
|
||||||
###########################
|
###########################
|
||||||
@@ -174,8 +146,6 @@ jobs:
|
|||||||
###########################
|
###########################
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name:
|
name: Build ${{ matrix.targetPlatform }} on Windows (${{ matrix.unityVersion }})${{ matrix.enableGpu && ' With GPU' || '' }}${{ matrix.buildProfile && ' With Build Profile' || '' }}
|
||||||
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,8 +2,7 @@ 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
|
||||||
@@ -33,9 +32,7 @@ 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-integration:
|
orchestrator:
|
||||||
name: Orchestrator Integration
|
name: Orchestrator Integrity
|
||||||
if: >-
|
uses: ./.github/workflows/orchestrator-integrity.yml
|
||||||
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
Normal file
61
.github/workflows/orchestrator-async-checks.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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
|
||||||
File diff suppressed because it is too large
Load Diff
91
.github/workflows/sync-secrets.yml
vendored
91
.github/workflows/sync-secrets.yml
vendored
@@ -1,91 +0,0 @@
|
|||||||
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
203
.github/workflows/validate-community-plugins.yml
vendored
@@ -1,203 +0,0 @@
|
|||||||
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']
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
237
.github/workflows/validate-orchestrator.yml
vendored
237
.github/workflows/validate-orchestrator.yml
vendored
@@ -1,237 +0,0 @@
|
|||||||
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');
|
|
||||||
})();
|
|
||||||
"
|
|
||||||
476
action.yml
476
action.yml
@@ -47,10 +47,6 @@ 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'
|
||||||
@@ -108,13 +104,17 @@ inputs:
|
|||||||
gitPrivateToken:
|
gitPrivateToken:
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
description: 'Github private token to pull from github'
|
description: '[Orchestrator] Github private token to pull from github'
|
||||||
providerStrategy:
|
gitAuthMode:
|
||||||
default: 'local'
|
|
||||||
required: false
|
required: false
|
||||||
|
default: 'header'
|
||||||
description:
|
description:
|
||||||
'Build execution strategy. Use "local" for local Docker/Mac builds. For remote builds (aws, k8s, etc.), install
|
'[Orchestrator] How git authentication is configured. "header" (default) uses http.extraHeader so the token
|
||||||
@game-ci/orchestrator and use the game-ci/orchestrator action which declares its own inputs.'
|
never appears in clone URLs or git config. "url" embeds the token in clone URLs (legacy behavior).'
|
||||||
|
githubOwner:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description: '[Orchestrator] GitHub owner name or organization/team name'
|
||||||
runAsHostUser:
|
runAsHostUser:
|
||||||
required: false
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
@@ -155,7 +155,147 @@ inputs:
|
|||||||
allowDirtyBuild:
|
allowDirtyBuild:
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
description: 'Allows the branch of the build to be dirty, and still generate the build.'
|
description: '[Orchestrator] 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.'
|
||||||
|
fallbackProviderStrategy:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description:
|
||||||
|
'[Orchestrator] Fallback provider when the primary is unavailable. Used with runnerCheckEnabled for automatic
|
||||||
|
failover, or as a catch-all if the primary provider fails to initialize.'
|
||||||
|
runnerCheckEnabled:
|
||||||
|
default: 'false'
|
||||||
|
required: false
|
||||||
|
description:
|
||||||
|
'[Orchestrator] Check GitHub Actions runner availability before starting a build. When no suitable runners are
|
||||||
|
available and fallbackProviderStrategy is set, automatically routes to the fallback provider.'
|
||||||
|
runnerCheckLabels:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description:
|
||||||
|
'[Orchestrator] Comma-separated runner labels to filter when checking availability (e.g. self-hosted,linux).
|
||||||
|
When empty, checks all runners in the repository.'
|
||||||
|
runnerCheckMinAvailable:
|
||||||
|
default: '1'
|
||||||
|
required: false
|
||||||
|
description:
|
||||||
|
'[Orchestrator] Minimum number of idle runners required for the primary provider. If fewer are available,
|
||||||
|
routes to fallbackProviderStrategy.'
|
||||||
|
retryOnFallback:
|
||||||
|
default: 'false'
|
||||||
|
required: false
|
||||||
|
description:
|
||||||
|
'[Orchestrator] When true and fallbackProviderStrategy is set, automatically retry the build on the fallback
|
||||||
|
provider if the primary provider fails. Useful for long builds where transient cloud failures are common.'
|
||||||
|
providerInitTimeout:
|
||||||
|
default: '0'
|
||||||
|
required: false
|
||||||
|
description:
|
||||||
|
'[Orchestrator] Maximum seconds to wait for the primary provider to initialize (setupWorkflow). If exceeded
|
||||||
|
and fallbackProviderStrategy is set, switches to the fallback. Set to 0 to disable (default).'
|
||||||
|
secretSource:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description:
|
||||||
|
'[Orchestrator] Premade secret source for pulling build secrets. Supported values: aws-secrets-manager,
|
||||||
|
aws-parameter-store, gcp-secret-manager, azure-key-vault, hashicorp-vault, hashicorp-vault-kv1,
|
||||||
|
vault (alias for hashicorp-vault), env. Can also be a custom shell command with {0} placeholder
|
||||||
|
for the key, or a path to a YAML file defining custom sources. Takes precedence over
|
||||||
|
inputPullCommand when set.'
|
||||||
|
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
|
||||||
@@ -180,6 +320,322 @@ 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.'
|
||||||
|
submoduleProfilePath:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'Path to a YAML submodule profile file (relative to repo root). Defines which submodules to initialize (branch:
|
||||||
|
main) or skip (branch: empty). See docs for format.'
|
||||||
|
submoduleVariantPath:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'Path to a YAML variant overlay file that modifies the base submodule profile. Used for server or debug build
|
||||||
|
variants.'
|
||||||
|
submoduleToken:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'Git token for authenticating submodule clones. Falls back to gitPrivateToken or GITHUB_TOKEN if empty.'
|
||||||
|
localCacheEnabled:
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
description:
|
||||||
|
'Enable filesystem-based caching for local builds. Caches the Unity Library folder and optionally LFS objects
|
||||||
|
between builds without requiring actions/cache.'
|
||||||
|
localCacheRoot:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'Root directory for local build cache. Defaults to $RUNNER_TEMP/game-ci-cache or .game-ci/cache if RUNNER_TEMP is
|
||||||
|
not set.'
|
||||||
|
localCacheLibrary:
|
||||||
|
required: false
|
||||||
|
default: 'true'
|
||||||
|
description: 'Cache the Unity Library folder for local builds. Only effective when localCacheEnabled is true.'
|
||||||
|
localCacheLfs:
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
description: 'Cache Git LFS objects for local builds. Only effective when localCacheEnabled is true.'
|
||||||
|
childWorkspacesEnabled:
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
description:
|
||||||
|
'Enable child workspace isolation for multi-product builds. Uses atomic filesystem moves for O(1) workspace
|
||||||
|
restore instead of tar/download/extract. Ideal for 50GB+ workspaces on self-hosted runners.'
|
||||||
|
childWorkspaceName:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'Name for this child workspace (e.g., product name like "TurnOfWar"). Used as the cache key for workspace
|
||||||
|
isolation. Required when childWorkspacesEnabled is true.'
|
||||||
|
childWorkspaceCacheRoot:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'Parent directory for cached child workspaces. Should be on the same NTFS volume as the build directory for O(1)
|
||||||
|
atomic restore via filesystem rename. Defaults to $RUNNER_TEMP/game-ci-workspaces.'
|
||||||
|
childWorkspacePreserveGit:
|
||||||
|
required: false
|
||||||
|
default: 'true'
|
||||||
|
description:
|
||||||
|
'Preserve .git directory in cached child workspace. Enables delta operations on restore but increases cache size.
|
||||||
|
Set to false to save disk space at the cost of full re-clone on restore.'
|
||||||
|
childWorkspaceSeparateLibrary:
|
||||||
|
required: false
|
||||||
|
default: 'true'
|
||||||
|
description:
|
||||||
|
'Cache Unity Library folder separately from the child workspace. Allows independent Library restore even when
|
||||||
|
workspace cache is invalidated. Recommended for large projects.'
|
||||||
|
lfsTransferAgent:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'Custom Git LFS transfer agent. Set to "elastic-git-storage" for built-in support (auto-installs from GitHub
|
||||||
|
releases). Append @version for a specific release (e.g. "elastic-git-storage@v1.0.0"). Or provide a path to any
|
||||||
|
custom transfer agent executable. When set, the agent is registered via git config before LFS operations.'
|
||||||
|
lfsTransferAgentArgs:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description: 'Additional arguments to pass to the custom LFS transfer agent.'
|
||||||
|
lfsStoragePaths:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'Semicolon-separated list of storage paths for the custom LFS transfer agent. Interpretation depends on the agent
|
||||||
|
(e.g. local paths, WebDAV URLs, rclone remotes).'
|
||||||
|
gitHooksEnabled:
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
description:
|
||||||
|
'Install and run git hooks (lefthook, husky, or native) during builds. When false (default), hooks are disabled
|
||||||
|
for build performance.'
|
||||||
|
gitHooksSkipList:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'Comma-separated list of hook names to skip even when gitHooksEnabled is true. Example: pre-push,post-merge'
|
||||||
|
gitHooksRunBeforeBuild:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'Comma-separated list of lefthook hook groups to run before the Unity build. Allows CI to trigger checks that
|
||||||
|
normally only run on git events. Example: pre-commit,pre-push. Requires lefthook. Works with Unity Git Hooks
|
||||||
|
(com.frostebite.unitygithooks) when installed as a UPM package — the init script runs automatically.'
|
||||||
|
providerExecutable:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'Path to an external CLI executable that implements the provider protocol. Enables providers written in any
|
||||||
|
language (Go, Python, Rust, shell). Uses JSON-over-stdin/stdout communication.'
|
||||||
|
gitIntegrityCheck:
|
||||||
|
description: 'Run git integrity checks before build (fsck, lock cleanup, submodule validation)'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
gitAutoRecover:
|
||||||
|
description: 'Attempt automatic recovery if git corruption is detected'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
cleanReservedFilenames:
|
||||||
|
description: 'Remove Windows reserved filenames that cause Unity import loops'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
buildArchiveEnabled:
|
||||||
|
description: 'Archive build output after successful build'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
buildArchivePath:
|
||||||
|
description: 'Path to store build archives'
|
||||||
|
required: false
|
||||||
|
default: './build-archives'
|
||||||
|
buildArchiveRetention:
|
||||||
|
description: 'Days to retain build archives before cleanup'
|
||||||
|
required: false
|
||||||
|
default: '30'
|
||||||
|
gcpProject:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] Google Cloud project ID for Cloud Run Jobs provider. Falls back to
|
||||||
|
GOOGLE_CLOUD_PROJECT env var.'
|
||||||
|
gcpRegion:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] Google Cloud region for Cloud Run Jobs (e.g. us-central1). Defaults to the region
|
||||||
|
input if empty.'
|
||||||
|
gcpStorageType:
|
||||||
|
required: false
|
||||||
|
default: 'gcs-fuse'
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] Storage type for Cloud Run Jobs. Options: gcs-fuse (mount GCS bucket as filesystem,
|
||||||
|
unlimited size, best for large sequential I/O), gcs-copy (copy artifacts in/out via gsutil, simpler, no FUSE
|
||||||
|
overhead), nfs (Filestore NFS mount, true POSIX, good random I/O, up to 100 TiB), in-memory (tmpfs, fastest but
|
||||||
|
volatile, up to 32 GiB).'
|
||||||
|
gcpBucket:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] GCS bucket name for build artifact storage. Used by gcs-fuse and gcs-copy storage
|
||||||
|
types.'
|
||||||
|
gcpFilestoreIp:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] Filestore instance IP address for NFS storage type. Required when gcpStorageType is
|
||||||
|
nfs.'
|
||||||
|
gcpFilestoreShare:
|
||||||
|
required: false
|
||||||
|
default: '/share1'
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] Filestore share name for NFS storage type. Defaults to /share1 (the Filestore
|
||||||
|
default).'
|
||||||
|
gcpMachineType:
|
||||||
|
required: false
|
||||||
|
default: 'e2-standard-4'
|
||||||
|
description: '[Orchestrator] [Experimental] Machine type for Cloud Run Jobs (e.g. e2-standard-4, e2-highmem-8).'
|
||||||
|
gcpDiskSizeGb:
|
||||||
|
required: false
|
||||||
|
default: '100'
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] Disk size in GB for Cloud Run Jobs in-memory volumes. Only applies to in-memory
|
||||||
|
storage type (max 32).'
|
||||||
|
gcpServiceAccount:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description: '[Orchestrator] [Experimental] Google Cloud service account email for Cloud Run Jobs execution.'
|
||||||
|
gcpVpcConnector:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description: '[Orchestrator] [Experimental] VPC connector name for Cloud Run Jobs private networking.'
|
||||||
|
azureResourceGroup:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] Azure resource group for Container Instances provider. Falls back to
|
||||||
|
AZURE_RESOURCE_GROUP env var.'
|
||||||
|
azureLocation:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] Azure region for Container Instances (e.g. eastus, westeurope). Defaults to the
|
||||||
|
region input if empty.'
|
||||||
|
azureStorageType:
|
||||||
|
required: false
|
||||||
|
default: 'azure-files'
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] Storage type for Azure Container Instances. Options: azure-files (SMB file share
|
||||||
|
mount, up to 100 TiB, premium throughput), blob-copy (copy artifacts in/out via az storage blob, no mount
|
||||||
|
overhead), azure-files-nfs (NFS 4.1 file share mount, true POSIX, no SMB lock overhead), in-memory (emptyDir
|
||||||
|
tmpfs, fastest but volatile, size limited by container memory).'
|
||||||
|
azureStorageAccount:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] Azure Storage Account name. Used by azure-files, azure-files-nfs, and blob-copy
|
||||||
|
storage types.'
|
||||||
|
azureFileShareName:
|
||||||
|
required: false
|
||||||
|
default: 'unity-builds'
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] Azure File Share name within the storage account. Used by azure-files and
|
||||||
|
azure-files-nfs storage types. Supports up to 100 TiB per share.'
|
||||||
|
azureBlobContainer:
|
||||||
|
required: false
|
||||||
|
default: 'unity-builds'
|
||||||
|
description: '[Orchestrator] [Experimental] Azure Blob container name for blob-copy storage type.'
|
||||||
|
azureSubscriptionId:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description: '[Orchestrator] [Experimental] Azure subscription ID. Falls back to AZURE_SUBSCRIPTION_ID env var.'
|
||||||
|
azureCpu:
|
||||||
|
required: false
|
||||||
|
default: '4'
|
||||||
|
description: '[Orchestrator] [Experimental] CPU cores for Azure Container Instances (1-16).'
|
||||||
|
azureMemoryGb:
|
||||||
|
required: false
|
||||||
|
default: '16'
|
||||||
|
description: '[Orchestrator] [Experimental] Memory in GB for Azure Container Instances (1-16).'
|
||||||
|
azureDiskSizeGb:
|
||||||
|
required: false
|
||||||
|
default: '100'
|
||||||
|
description:
|
||||||
|
'[Orchestrator] [Experimental] File share quota in GB for Azure Container Instances. Premium shares support up to
|
||||||
|
102400 GB (100 TiB).'
|
||||||
|
azureSubnetId:
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
description: '[Orchestrator] [Experimental] Azure subnet resource ID for VNet-integrated Container Instances.'
|
||||||
|
remotePowershellHost:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] Remote PowerShell host (hostname or IP) for the remote-powershell provider'
|
||||||
|
remotePowershellCredential:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] Remote PowerShell credential (username:password or certificate path)'
|
||||||
|
remotePowershellTransport:
|
||||||
|
default: 'wsman'
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] Remote PowerShell transport protocol (wsman or ssh)'
|
||||||
|
githubActionsRepo:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] Target repository (owner/repo) for the github-actions provider'
|
||||||
|
githubActionsWorkflow:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] Workflow filename or ID to dispatch for the github-actions provider'
|
||||||
|
githubActionsToken:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] PAT with actions:write scope for the github-actions provider'
|
||||||
|
githubActionsRef:
|
||||||
|
default: 'main'
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] Branch/ref to run the workflow on for the github-actions provider'
|
||||||
|
gitlabProjectId:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] GitLab project ID or URL-encoded path for the gitlab-ci provider'
|
||||||
|
gitlabTriggerToken:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] Pipeline trigger token for the gitlab-ci provider'
|
||||||
|
gitlabApiUrl:
|
||||||
|
default: 'https://gitlab.com'
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] GitLab API URL (for self-hosted instances) for the gitlab-ci provider'
|
||||||
|
gitlabRef:
|
||||||
|
default: 'main'
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] Branch/ref to trigger the pipeline on for the gitlab-ci provider'
|
||||||
|
ansibleInventory:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] Path to Ansible inventory file or dynamic inventory script'
|
||||||
|
ansiblePlaybook:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] Path to Ansible playbook for Unity builds'
|
||||||
|
ansibleExtraVars:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] Additional Ansible variables as JSON'
|
||||||
|
ansibleVaultPassword:
|
||||||
|
default: ''
|
||||||
|
required: false
|
||||||
|
description: '[Orchestrator] Path to Ansible vault password file'
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
volume:
|
volume:
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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]
|
|
||||||
138
delete-me-update-all-integration-branches.ps1
Normal file
138
delete-me-update-all-integration-branches.ps1
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# delete-me-update-all-integration-branches.ps1
|
||||||
|
# Updates ALL integration branches from their component branches.
|
||||||
|
# Run from any branch -- it will stash changes, update each integration branch, then return.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$originalBranch = git rev-parse --abbrev-ref HEAD
|
||||||
|
$stashed = $false
|
||||||
|
|
||||||
|
# Stash any uncommitted changes
|
||||||
|
$status = git status --porcelain
|
||||||
|
if ($status) {
|
||||||
|
Write-Host "Stashing uncommitted changes..." -ForegroundColor Cyan
|
||||||
|
git stash push -m "auto-stash before integration branch update"
|
||||||
|
$stashed = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Fetching all branches from origin..." -ForegroundColor Cyan
|
||||||
|
git fetch origin
|
||||||
|
|
||||||
|
$integrationBranches = @(
|
||||||
|
@{
|
||||||
|
Name = 'release/next-gen'
|
||||||
|
Branches = @(
|
||||||
|
'feature/test-workflow-engine'
|
||||||
|
'feature/hot-runner-protocol'
|
||||||
|
'feature/generic-artifact-system'
|
||||||
|
'feature/incremental-sync-protocol'
|
||||||
|
'feature/community-plugin-validation'
|
||||||
|
'feature/cli-support'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@{
|
||||||
|
Name = 'release/lts-infrastructure'
|
||||||
|
Branches = @(
|
||||||
|
'feature/orchestrator-enterprise-support'
|
||||||
|
'feature/cloud-run-azure-providers'
|
||||||
|
'feature/provider-load-balancing'
|
||||||
|
'feature/orchestrator-unit-tests'
|
||||||
|
'fix/secure-git-token-usage'
|
||||||
|
'feature/premade-secret-sources'
|
||||||
|
'feature/ci-platform-providers'
|
||||||
|
'feature/build-reliability'
|
||||||
|
'ci/orchestrator-integrity-speedup'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@{
|
||||||
|
Name = 'release/lts-2.0.0'
|
||||||
|
Branches = @(
|
||||||
|
# Infrastructure
|
||||||
|
'feature/orchestrator-enterprise-support'
|
||||||
|
'feature/cloud-run-azure-providers'
|
||||||
|
'feature/provider-load-balancing'
|
||||||
|
'feature/orchestrator-unit-tests'
|
||||||
|
'fix/secure-git-token-usage'
|
||||||
|
'feature/premade-secret-sources'
|
||||||
|
'feature/ci-platform-providers'
|
||||||
|
'feature/build-reliability'
|
||||||
|
'ci/orchestrator-integrity-speedup'
|
||||||
|
# Next-gen
|
||||||
|
'feature/test-workflow-engine'
|
||||||
|
'feature/hot-runner-protocol'
|
||||||
|
'feature/generic-artifact-system'
|
||||||
|
'feature/incremental-sync-protocol'
|
||||||
|
'feature/community-plugin-validation'
|
||||||
|
'feature/cli-support'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($integration in $integrationBranches) {
|
||||||
|
$name = $integration.Name
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Updating $name" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Check if branch exists locally
|
||||||
|
$exists = git branch --list $name
|
||||||
|
if (-not $exists) {
|
||||||
|
Write-Host "Creating local branch from origin/$name..." -ForegroundColor Yellow
|
||||||
|
git checkout -b $name "origin/$name"
|
||||||
|
} else {
|
||||||
|
git checkout $name
|
||||||
|
git pull origin $name --ff-only 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
git pull origin $name --no-edit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed = @()
|
||||||
|
foreach ($branch in $integration.Branches) {
|
||||||
|
$remoteBranch = "origin/$branch"
|
||||||
|
# Check if remote branch exists
|
||||||
|
$refExists = git rev-parse --verify $remoteBranch 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " Skipping $branch (not found on remote)" -ForegroundColor DarkGray
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if already merged
|
||||||
|
$mergeBase = git merge-base HEAD $remoteBranch 2>$null
|
||||||
|
$remoteHead = git rev-parse $remoteBranch 2>$null
|
||||||
|
if ($mergeBase -eq $remoteHead) {
|
||||||
|
Write-Host " $branch - already up to date" -ForegroundColor DarkGray
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " Merging $branch..." -ForegroundColor Yellow
|
||||||
|
$result = git merge $remoteBranch --no-edit 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " CONFLICT - skipped (resolve manually)" -ForegroundColor Red
|
||||||
|
$failed += $branch
|
||||||
|
git merge --abort
|
||||||
|
} else {
|
||||||
|
Write-Host " OK" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failed.Count -gt 0) {
|
||||||
|
Write-Host "`n Conflicts in:" -ForegroundColor Red
|
||||||
|
$failed | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Push
|
||||||
|
Write-Host " Pushing $name to origin..." -ForegroundColor Cyan
|
||||||
|
git push origin $name
|
||||||
|
}
|
||||||
|
|
||||||
|
# Return to original branch
|
||||||
|
Write-Host "`nReturning to $originalBranch..." -ForegroundColor Cyan
|
||||||
|
git checkout $originalBranch
|
||||||
|
|
||||||
|
if ($stashed) {
|
||||||
|
Write-Host "Restoring stashed changes..." -ForegroundColor Cyan
|
||||||
|
git stash pop
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`nDone!" -ForegroundColor Green
|
||||||
52
delete-me-update-this-integration-branch.ps1
Normal file
52
delete-me-update-this-integration-branch.ps1
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# delete-me-update-this-integration-branch.ps1
|
||||||
|
# Run this script from the repo root while on the release/lts-infrastructure branch.
|
||||||
|
# It merges the latest from each component branch to keep this integration branch current.
|
||||||
|
# After running, review any conflicts, then commit and push.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$branchName = git rev-parse --abbrev-ref HEAD
|
||||||
|
if ($branchName -ne 'release/lts-infrastructure') {
|
||||||
|
Write-Error "Must be on release/lts-infrastructure branch. Currently on: $branchName"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Component branches for this integration branch (infrastructure only, no next-gen)
|
||||||
|
$branches = @(
|
||||||
|
'feature/orchestrator-enterprise-support'
|
||||||
|
'feature/cloud-run-azure-providers'
|
||||||
|
'feature/provider-load-balancing'
|
||||||
|
'feature/orchestrator-unit-tests'
|
||||||
|
'fix/secure-git-token-usage'
|
||||||
|
'feature/premade-secret-sources'
|
||||||
|
'feature/ci-platform-providers'
|
||||||
|
'feature/build-reliability'
|
||||||
|
'ci/orchestrator-integrity-speedup'
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "Fetching latest from origin..." -ForegroundColor Cyan
|
||||||
|
git fetch origin
|
||||||
|
|
||||||
|
$failed = @()
|
||||||
|
foreach ($branch in $branches) {
|
||||||
|
Write-Host "`nMerging origin/$branch..." -ForegroundColor Yellow
|
||||||
|
$result = git merge "origin/$branch" --no-edit 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " CONFLICT merging $branch - resolve manually" -ForegroundColor Red
|
||||||
|
$failed += $branch
|
||||||
|
# Abort this merge so we can continue with others
|
||||||
|
git merge --abort
|
||||||
|
} else {
|
||||||
|
Write-Host " Merged successfully" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failed.Count -gt 0) {
|
||||||
|
Write-Host "`nThe following branches had conflicts and were skipped:" -ForegroundColor Red
|
||||||
|
$failed | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
|
||||||
|
Write-Host "`nRe-run after resolving, or merge them manually:" -ForegroundColor Yellow
|
||||||
|
$failed | ForEach-Object { Write-Host " git merge origin/$_" -ForegroundColor Yellow }
|
||||||
|
} else {
|
||||||
|
Write-Host "`nAll branches merged successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "Run 'git push origin release/lts-infrastructure' to update the remote." -ForegroundColor Cyan
|
||||||
|
}
|
||||||
268909
dist/index.js
generated
vendored
268909
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,8 +11,19 @@
|
|||||||
"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"
|
||||||
@@ -22,20 +33,35 @@
|
|||||||
"@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",
|
||||||
"reflect-metadata": "^0.1.13",
|
|
||||||
"nanoid": "^3.3.1",
|
"nanoid": "^3.3.1",
|
||||||
|
"reflect-metadata": "^0.1.13",
|
||||||
"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",
|
||||||
|
|||||||
606
src/index-enterprise-features.test.ts
Normal file
606
src/index-enterprise-features.test.ts
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
/**
|
||||||
|
* Integration wiring tests for enterprise features in index.ts
|
||||||
|
*
|
||||||
|
* These tests verify the conditional gating logic in runMain():
|
||||||
|
* - Each enterprise feature is only invoked when its gate condition is met
|
||||||
|
* - Services are NOT called when their feature is disabled (the default)
|
||||||
|
* - The order of operations is correct (restore before build, save after build)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { BuildParameters } from './model';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service mocks — must be declared before importing index.ts (jest hoists them)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockChildWorkspaceService = {
|
||||||
|
buildConfig: jest.fn().mockReturnValue({ enabled: true, workspaceName: 'Test' }),
|
||||||
|
initializeWorkspace: jest.fn().mockReturnValue(false),
|
||||||
|
getWorkspaceSize: jest.fn().mockReturnValue('0 B'),
|
||||||
|
saveWorkspace: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSubmoduleProfileService = {
|
||||||
|
createInitPlan: jest.fn().mockResolvedValue([]),
|
||||||
|
execute: jest.fn().mockResolvedValue(''),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLfsAgentService = {
|
||||||
|
configure: jest.fn().mockResolvedValue(''),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLocalCacheService = {
|
||||||
|
resolveCacheRoot: jest.fn().mockReturnValue('/cache'),
|
||||||
|
generateCacheKey: jest.fn().mockReturnValue('key-1'),
|
||||||
|
restoreLfsCache: jest.fn().mockResolvedValue(true),
|
||||||
|
restoreLibraryCache: jest.fn().mockResolvedValue(true),
|
||||||
|
saveLibraryCache: jest.fn().mockResolvedValue(''),
|
||||||
|
saveLfsCache: jest.fn().mockResolvedValue(''),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockGitHooksService = {
|
||||||
|
installHooks: jest.fn().mockResolvedValue(''),
|
||||||
|
configureSkipList: jest.fn().mockReturnValue({ LEFTHOOK_EXCLUDE: 'pre-commit' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the dynamic import() targets — jest.mock with factory functions.
|
||||||
|
// The services are imported dynamically via `await import(...)` in index.ts,
|
||||||
|
// so we mock the module path and return the mock objects as named exports.
|
||||||
|
jest.mock('./model/orchestrator/services/cache/child-workspace-service', () => ({
|
||||||
|
ChildWorkspaceService: mockChildWorkspaceService,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./model/orchestrator/services/submodule/submodule-profile-service', () => ({
|
||||||
|
SubmoduleProfileService: mockSubmoduleProfileService,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./model/orchestrator/services/lfs/lfs-agent-service', () => ({
|
||||||
|
LfsAgentService: mockLfsAgentService,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./model/orchestrator/services/cache/local-cache-service', () => ({
|
||||||
|
LocalCacheService: mockLocalCacheService,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./model/orchestrator/services/hooks/git-hooks-service', () => ({
|
||||||
|
GitHooksService: mockGitHooksService,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock all non-enterprise dependencies to isolate the wiring logic
|
||||||
|
jest.mock('@actions/core');
|
||||||
|
jest.mock('./model', () => ({
|
||||||
|
Action: {
|
||||||
|
checkCompatibility: jest.fn(),
|
||||||
|
workspace: '/workspace',
|
||||||
|
actionFolder: '/action',
|
||||||
|
},
|
||||||
|
BuildParameters: {
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
Cache: {
|
||||||
|
verify: jest.fn(),
|
||||||
|
},
|
||||||
|
Orchestrator: {
|
||||||
|
run: jest.fn().mockResolvedValue(''),
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
|
||||||
|
interface EnterpriseBuildParametersOverrides {
|
||||||
|
providerStrategy?: string;
|
||||||
|
childWorkspacesEnabled?: boolean;
|
||||||
|
childWorkspaceName?: string;
|
||||||
|
childWorkspaceCacheRoot?: string;
|
||||||
|
childWorkspacePreserveGit?: boolean;
|
||||||
|
childWorkspaceSeparateLibrary?: boolean;
|
||||||
|
submoduleProfilePath?: string;
|
||||||
|
submoduleVariantPath?: string;
|
||||||
|
submoduleToken?: string;
|
||||||
|
gitPrivateToken?: string;
|
||||||
|
lfsTransferAgent?: string;
|
||||||
|
lfsTransferAgentArgs?: string;
|
||||||
|
lfsStoragePaths?: string;
|
||||||
|
localCacheEnabled?: boolean;
|
||||||
|
localCacheRoot?: string;
|
||||||
|
localCacheLibrary?: boolean;
|
||||||
|
localCacheLfs?: boolean;
|
||||||
|
gitHooksEnabled?: boolean;
|
||||||
|
gitHooksSkipList?: string;
|
||||||
|
gitHooksRunBeforeBuild?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockBuildParameters(overrides: EnterpriseBuildParametersOverrides = {}) {
|
||||||
|
return {
|
||||||
|
// Required base properties
|
||||||
|
providerStrategy: 'local',
|
||||||
|
targetPlatform: 'StandaloneLinux64',
|
||||||
|
editorVersion: '2021.3.1f1',
|
||||||
|
buildVersion: '1.0.0',
|
||||||
|
androidVersionCode: '1',
|
||||||
|
projectPath: '.',
|
||||||
|
branch: 'main',
|
||||||
|
runnerTempPath: '/tmp',
|
||||||
|
|
||||||
|
// Enterprise features - all disabled by default
|
||||||
|
childWorkspacesEnabled: false,
|
||||||
|
childWorkspaceName: '',
|
||||||
|
childWorkspaceCacheRoot: '',
|
||||||
|
childWorkspacePreserveGit: true,
|
||||||
|
childWorkspaceSeparateLibrary: true,
|
||||||
|
submoduleProfilePath: '',
|
||||||
|
submoduleVariantPath: '',
|
||||||
|
submoduleToken: '',
|
||||||
|
gitPrivateToken: '',
|
||||||
|
lfsTransferAgent: '',
|
||||||
|
lfsTransferAgentArgs: '',
|
||||||
|
lfsStoragePaths: '',
|
||||||
|
localCacheEnabled: false,
|
||||||
|
localCacheRoot: '',
|
||||||
|
localCacheLibrary: true,
|
||||||
|
localCacheLfs: false,
|
||||||
|
gitHooksEnabled: false,
|
||||||
|
gitHooksSkipList: '',
|
||||||
|
gitHooksRunBeforeBuild: '',
|
||||||
|
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry point (runMain) is invoked by importing index.ts.
|
||||||
|
* Since it calls `runMain()` at module scope, we need to re-import it
|
||||||
|
* for each test. jest.isolateModules() handles this.
|
||||||
|
*/
|
||||||
|
async function runIndex(overrides: EnterpriseBuildParametersOverrides = {}): Promise<void> {
|
||||||
|
mockedBuildParametersCreate.mockResolvedValue(createMockBuildParameters(overrides));
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
jest.isolateModules(() => {
|
||||||
|
require('./index');
|
||||||
|
|
||||||
|
// runMain() is async; give it a tick to complete
|
||||||
|
// We use setImmediate to ensure all microtasks from the dynamic imports resolve
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow all promises and microtasks to settle
|
||||||
|
setTimeout(resolve, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('index.ts enterprise feature wiring', () => {
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
const originalEnvironment = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
process.env.GITHUB_WORKSPACE = '/workspace';
|
||||||
|
|
||||||
|
// Force linux platform so Docker.run is used (not MacBuilder)
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||||
|
process.env = { ...originalEnvironment };
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GitHooksService gating
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GitHooksService gating', () => {
|
||||||
|
it('should NOT call GitHooksService when gitHooksEnabled is false (default)', async () => {
|
||||||
|
await runIndex({ gitHooksEnabled: false });
|
||||||
|
|
||||||
|
expect(mockGitHooksService.installHooks).not.toHaveBeenCalled();
|
||||||
|
expect(mockGitHooksService.configureSkipList).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call installHooks when gitHooksEnabled is true', async () => {
|
||||||
|
await runIndex({ gitHooksEnabled: true });
|
||||||
|
|
||||||
|
expect(mockGitHooksService.installHooks).toHaveBeenCalledWith('/workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call configureSkipList when gitHooksEnabled and gitHooksSkipList is set', async () => {
|
||||||
|
await runIndex({
|
||||||
|
gitHooksEnabled: true,
|
||||||
|
gitHooksSkipList: 'pre-commit,pre-push',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGitHooksService.configureSkipList).toHaveBeenCalledWith(['pre-commit', 'pre-push']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT call configureSkipList when gitHooksSkipList is empty', async () => {
|
||||||
|
await runIndex({
|
||||||
|
gitHooksEnabled: true,
|
||||||
|
gitHooksSkipList: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGitHooksService.installHooks).toHaveBeenCalled();
|
||||||
|
expect(mockGitHooksService.configureSkipList).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// LocalCacheService gating
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('LocalCacheService gating', () => {
|
||||||
|
it('should NOT call LocalCacheService when localCacheEnabled is false (default)', async () => {
|
||||||
|
await runIndex({ localCacheEnabled: false });
|
||||||
|
|
||||||
|
expect(mockLocalCacheService.resolveCacheRoot).not.toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.generateCacheKey).not.toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.restoreLibraryCache).not.toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.restoreLfsCache).not.toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.saveLibraryCache).not.toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.saveLfsCache).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call restore and save operations when localCacheEnabled is true', async () => {
|
||||||
|
await runIndex({
|
||||||
|
localCacheEnabled: true,
|
||||||
|
localCacheLibrary: true,
|
||||||
|
localCacheLfs: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLocalCacheService.resolveCacheRoot).toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.generateCacheKey).toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.restoreLibraryCache).toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.restoreLfsCache).toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.saveLibraryCache).toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.saveLfsCache).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only cache Library when localCacheLibrary is true and localCacheLfs is false', async () => {
|
||||||
|
await runIndex({
|
||||||
|
localCacheEnabled: true,
|
||||||
|
localCacheLibrary: true,
|
||||||
|
localCacheLfs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLocalCacheService.restoreLibraryCache).toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.restoreLfsCache).not.toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.saveLibraryCache).toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.saveLfsCache).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only cache LFS when localCacheLfs is true and localCacheLibrary is false', async () => {
|
||||||
|
await runIndex({
|
||||||
|
localCacheEnabled: true,
|
||||||
|
localCacheLibrary: false,
|
||||||
|
localCacheLfs: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLocalCacheService.restoreLibraryCache).not.toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.restoreLfsCache).toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.saveLibraryCache).not.toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.saveLfsCache).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// ChildWorkspaceService gating
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ChildWorkspaceService gating', () => {
|
||||||
|
it('should NOT call ChildWorkspaceService when childWorkspacesEnabled is false (default)', async () => {
|
||||||
|
await runIndex({ childWorkspacesEnabled: false });
|
||||||
|
|
||||||
|
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
|
||||||
|
expect(mockChildWorkspaceService.initializeWorkspace).not.toHaveBeenCalled();
|
||||||
|
expect(mockChildWorkspaceService.saveWorkspace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT call ChildWorkspaceService when childWorkspacesEnabled is true but childWorkspaceName is empty', async () => {
|
||||||
|
await runIndex({
|
||||||
|
childWorkspacesEnabled: true,
|
||||||
|
childWorkspaceName: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call buildConfig, initializeWorkspace, and saveWorkspace when enabled with a name', async () => {
|
||||||
|
mockChildWorkspaceService.buildConfig.mockReturnValue({ enabled: true, workspaceName: 'TurnOfWar' });
|
||||||
|
|
||||||
|
await runIndex({
|
||||||
|
childWorkspacesEnabled: true,
|
||||||
|
childWorkspaceName: 'TurnOfWar',
|
||||||
|
childWorkspaceCacheRoot: '/cache/workspaces',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockChildWorkspaceService.buildConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
childWorkspacesEnabled: true,
|
||||||
|
childWorkspaceName: 'TurnOfWar',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockChildWorkspaceService.initializeWorkspace).toHaveBeenCalled();
|
||||||
|
expect(mockChildWorkspaceService.getWorkspaceSize).toHaveBeenCalled();
|
||||||
|
expect(mockChildWorkspaceService.saveWorkspace).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// SubmoduleProfileService gating
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('SubmoduleProfileService gating', () => {
|
||||||
|
it('should NOT call SubmoduleProfileService when submoduleProfilePath is empty (default)', async () => {
|
||||||
|
await runIndex({ submoduleProfilePath: '' });
|
||||||
|
|
||||||
|
expect(mockSubmoduleProfileService.createInitPlan).not.toHaveBeenCalled();
|
||||||
|
expect(mockSubmoduleProfileService.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call createInitPlan and execute when submoduleProfilePath is set', async () => {
|
||||||
|
await runIndex({
|
||||||
|
submoduleProfilePath: '/path/to/profile.yml',
|
||||||
|
submoduleVariantPath: '',
|
||||||
|
submoduleToken: 'my-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSubmoduleProfileService.createInitPlan).toHaveBeenCalledWith('/path/to/profile.yml', '', '/workspace');
|
||||||
|
expect(mockSubmoduleProfileService.execute).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass variant path when provided', async () => {
|
||||||
|
await runIndex({
|
||||||
|
submoduleProfilePath: '/path/to/profile.yml',
|
||||||
|
submoduleVariantPath: '/path/to/variant.yml',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSubmoduleProfileService.createInitPlan).toHaveBeenCalledWith(
|
||||||
|
'/path/to/profile.yml',
|
||||||
|
'/path/to/variant.yml',
|
||||||
|
'/workspace',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use submoduleToken for auth, falling back to gitPrivateToken', async () => {
|
||||||
|
await runIndex({
|
||||||
|
submoduleProfilePath: '/path/to/profile.yml',
|
||||||
|
submoduleToken: '',
|
||||||
|
gitPrivateToken: 'fallback-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSubmoduleProfileService.execute).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'/workspace',
|
||||||
|
'fallback-token',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer submoduleToken over gitPrivateToken', async () => {
|
||||||
|
await runIndex({
|
||||||
|
submoduleProfilePath: '/path/to/profile.yml',
|
||||||
|
submoduleToken: 'specific-token',
|
||||||
|
gitPrivateToken: 'fallback-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSubmoduleProfileService.execute).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'/workspace',
|
||||||
|
'specific-token',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// LfsAgentService gating
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('LfsAgentService gating', () => {
|
||||||
|
it('should NOT call LfsAgentService when lfsTransferAgent is empty (default)', async () => {
|
||||||
|
await runIndex({ lfsTransferAgent: '' });
|
||||||
|
|
||||||
|
expect(mockLfsAgentService.configure).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call configure when lfsTransferAgent is set', async () => {
|
||||||
|
await runIndex({
|
||||||
|
lfsTransferAgent: '/tools/elastic-git-storage',
|
||||||
|
lfsTransferAgentArgs: '--verbose',
|
||||||
|
lfsStoragePaths: '/path/a;/path/b',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLfsAgentService.configure).toHaveBeenCalledWith(
|
||||||
|
'/tools/elastic-git-storage',
|
||||||
|
'--verbose',
|
||||||
|
['/path/a', '/path/b'],
|
||||||
|
'/workspace',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass empty array when lfsStoragePaths is empty', async () => {
|
||||||
|
await runIndex({
|
||||||
|
lfsTransferAgent: '/tools/agent',
|
||||||
|
lfsStoragePaths: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLfsAgentService.configure).toHaveBeenCalledWith('/tools/agent', '', [], '/workspace');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Order of operations (restore before build, save after build)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('order of operations', () => {
|
||||||
|
it('should execute restore operations before build and save operations after build', async () => {
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
|
||||||
|
// Track call order for each relevant operation
|
||||||
|
mockChildWorkspaceService.buildConfig.mockReturnValue({ enabled: true, workspaceName: 'Test' });
|
||||||
|
mockChildWorkspaceService.initializeWorkspace.mockImplementation(() => {
|
||||||
|
callOrder.push('child-workspace-restore');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
mockChildWorkspaceService.getWorkspaceSize.mockImplementation(() => {
|
||||||
|
callOrder.push('child-workspace-size');
|
||||||
|
|
||||||
|
return '0 B';
|
||||||
|
});
|
||||||
|
mockSubmoduleProfileService.createInitPlan.mockImplementation(async () => {
|
||||||
|
callOrder.push('submodule-profile-plan');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
mockSubmoduleProfileService.execute.mockImplementation(async () => {
|
||||||
|
callOrder.push('submodule-profile-execute');
|
||||||
|
});
|
||||||
|
mockLfsAgentService.configure.mockImplementation(async () => {
|
||||||
|
callOrder.push('lfs-agent-configure');
|
||||||
|
});
|
||||||
|
mockLocalCacheService.resolveCacheRoot.mockImplementation(() => {
|
||||||
|
callOrder.push('local-cache-resolve');
|
||||||
|
|
||||||
|
return '/cache';
|
||||||
|
});
|
||||||
|
mockLocalCacheService.generateCacheKey.mockImplementation(() => {
|
||||||
|
callOrder.push('local-cache-keygen');
|
||||||
|
|
||||||
|
return 'key-1';
|
||||||
|
});
|
||||||
|
mockLocalCacheService.restoreLfsCache.mockImplementation(async () => {
|
||||||
|
callOrder.push('local-cache-restore-lfs');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
mockLocalCacheService.restoreLibraryCache.mockImplementation(async () => {
|
||||||
|
callOrder.push('local-cache-restore-library');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
mockGitHooksService.installHooks.mockImplementation(async () => {
|
||||||
|
callOrder.push('git-hooks-install');
|
||||||
|
});
|
||||||
|
mockLocalCacheService.saveLibraryCache.mockImplementation(async () => {
|
||||||
|
callOrder.push('local-cache-save-library');
|
||||||
|
});
|
||||||
|
mockLocalCacheService.saveLfsCache.mockImplementation(async () => {
|
||||||
|
callOrder.push('local-cache-save-lfs');
|
||||||
|
});
|
||||||
|
mockChildWorkspaceService.saveWorkspace.mockImplementation(() => {
|
||||||
|
callOrder.push('child-workspace-save');
|
||||||
|
});
|
||||||
|
|
||||||
|
await runIndex({
|
||||||
|
childWorkspacesEnabled: true,
|
||||||
|
childWorkspaceName: 'TurnOfWar',
|
||||||
|
submoduleProfilePath: '/profile.yml',
|
||||||
|
lfsTransferAgent: '/tools/agent',
|
||||||
|
localCacheEnabled: true,
|
||||||
|
localCacheLfs: true,
|
||||||
|
localCacheLibrary: true,
|
||||||
|
gitHooksEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify restore operations happen before save operations.
|
||||||
|
// The expected order from index.ts is:
|
||||||
|
// 1. Child workspace restore
|
||||||
|
// 2. Submodule profile init
|
||||||
|
// 3. LFS agent configure
|
||||||
|
// 4. Local cache restore (LFS then Library)
|
||||||
|
// 5. Git hooks install
|
||||||
|
// 6. [BUILD happens here - Docker.run or MacBuilder.run]
|
||||||
|
// 7. Local cache save (Library then LFS)
|
||||||
|
// 8. Child workspace save
|
||||||
|
|
||||||
|
const restoreOps = [
|
||||||
|
'child-workspace-restore',
|
||||||
|
'submodule-profile-plan',
|
||||||
|
'submodule-profile-execute',
|
||||||
|
'lfs-agent-configure',
|
||||||
|
'local-cache-restore-lfs',
|
||||||
|
'local-cache-restore-library',
|
||||||
|
'git-hooks-install',
|
||||||
|
];
|
||||||
|
|
||||||
|
const saveOps = ['local-cache-save-library', 'local-cache-save-lfs', 'child-workspace-save'];
|
||||||
|
|
||||||
|
// All restore ops should appear before all save ops
|
||||||
|
for (const restoreOp of restoreOps) {
|
||||||
|
if (!callOrder.includes(restoreOp)) continue; // Skip if the operation wasn't called
|
||||||
|
for (const saveOp of saveOps) {
|
||||||
|
if (!callOrder.includes(saveOp)) continue;
|
||||||
|
expect(callOrder.indexOf(restoreOp)).toBeLessThan(callOrder.indexOf(saveOp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child workspace save should be last
|
||||||
|
if (callOrder.includes('child-workspace-save') && callOrder.includes('local-cache-save-lfs')) {
|
||||||
|
expect(callOrder.indexOf('local-cache-save-lfs')).toBeLessThan(callOrder.indexOf('child-workspace-save'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Non-local provider strategy
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('non-local provider strategy', () => {
|
||||||
|
it('should skip all enterprise features when providerStrategy is not local', async () => {
|
||||||
|
await runIndex({
|
||||||
|
providerStrategy: 'aws',
|
||||||
|
childWorkspacesEnabled: true,
|
||||||
|
childWorkspaceName: 'Test',
|
||||||
|
submoduleProfilePath: '/profile.yml',
|
||||||
|
lfsTransferAgent: '/tools/agent',
|
||||||
|
localCacheEnabled: true,
|
||||||
|
gitHooksEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// None of the enterprise services should be called because
|
||||||
|
// they are inside the `if (providerStrategy === 'local')` block
|
||||||
|
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
|
||||||
|
expect(mockSubmoduleProfileService.createInitPlan).not.toHaveBeenCalled();
|
||||||
|
expect(mockLfsAgentService.configure).not.toHaveBeenCalled();
|
||||||
|
expect(mockLocalCacheService.resolveCacheRoot).not.toHaveBeenCalled();
|
||||||
|
expect(mockGitHooksService.installHooks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
206
src/index.ts
206
src/index.ts
@@ -1,9 +1,10 @@
|
|||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
import { Action, BuildParameters, Cache, Docker, ImageTag, Output } from './model';
|
import path from 'node:path';
|
||||||
|
import { Action, BuildParameters, Cache, Orchestrator, 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 { loadOrchestratorPlugin, OrchestratorPlugin } from './model/orchestrator-plugin';
|
import { BuildReliabilityService } from './model/orchestrator/services/reliability';
|
||||||
|
|
||||||
async function runMain() {
|
async function runMain() {
|
||||||
try {
|
try {
|
||||||
@@ -15,40 +16,179 @@ async function runMain() {
|
|||||||
Action.checkCompatibility();
|
Action.checkCompatibility();
|
||||||
Cache.verify();
|
Cache.verify();
|
||||||
|
|
||||||
|
// Always configure git environment for CI reliability
|
||||||
|
BuildReliabilityService.configureGitEnvironment();
|
||||||
|
|
||||||
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)
|
// Pre-build reliability checks
|
||||||
const plugin = await loadOrchestratorPlugin();
|
if (buildParameters.gitIntegrityCheck) {
|
||||||
await plugin?.initialize(buildParameters, workspace);
|
core.info('Running git integrity checks...');
|
||||||
|
|
||||||
|
const isHealthy = BuildReliabilityService.checkGitIntegrity(workspace);
|
||||||
|
BuildReliabilityService.cleanStaleLockFiles(workspace);
|
||||||
|
BuildReliabilityService.validateSubmoduleBackingStores(workspace);
|
||||||
|
|
||||||
|
if (buildParameters.cleanReservedFilenames) {
|
||||||
|
BuildReliabilityService.cleanReservedFilenames(buildParameters.projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isHealthy && buildParameters.gitAutoRecover) {
|
||||||
|
core.info('Git corruption detected, attempting automatic recovery...');
|
||||||
|
const recovered = BuildReliabilityService.recoverCorruptedRepo(workspace);
|
||||||
|
if (!recovered) {
|
||||||
|
core.warning('Automatic recovery failed. Build may encounter issues.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (buildParameters.cleanReservedFilenames) {
|
||||||
|
// cleanReservedFilenames can run independently of gitIntegrityCheck
|
||||||
|
BuildReliabilityService.cleanReservedFilenames(buildParameters.projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
let exitCode = -1;
|
let exitCode = -1;
|
||||||
|
|
||||||
if (plugin?.canHandleBuild()) {
|
if (buildParameters.providerStrategy === 'local') {
|
||||||
// Plugin handles the build entirely (remote providers, hot runner, test workflows)
|
core.info('Building locally');
|
||||||
const result = await plugin.handleBuild(baseImage.toString());
|
|
||||||
|
|
||||||
exitCode = result.fallbackToLocal
|
// Child workspace isolation - restore cached workspace before any other setup
|
||||||
? await runLocalBuild(buildParameters, baseImage, workspace, actionFolder, plugin)
|
let childWorkspaceConfig: any;
|
||||||
: result.exitCode;
|
if (buildParameters.childWorkspacesEnabled && buildParameters.childWorkspaceName) {
|
||||||
} else if (buildParameters.providerStrategy === 'local') {
|
const { ChildWorkspaceService } = await import('./model/orchestrator/services/cache/child-workspace-service');
|
||||||
exitCode = await runLocalBuild(buildParameters, baseImage, workspace, actionFolder, plugin);
|
const cacheRoot =
|
||||||
|
buildParameters.childWorkspaceCacheRoot ||
|
||||||
|
path.join(buildParameters.runnerTempPath || process.env.RUNNER_TEMP || '', 'game-ci-workspaces');
|
||||||
|
childWorkspaceConfig = ChildWorkspaceService.buildConfig({
|
||||||
|
childWorkspacesEnabled: buildParameters.childWorkspacesEnabled,
|
||||||
|
childWorkspaceName: buildParameters.childWorkspaceName,
|
||||||
|
childWorkspaceCacheRoot: cacheRoot,
|
||||||
|
childWorkspacePreserveGit: buildParameters.childWorkspacePreserveGit,
|
||||||
|
childWorkspaceSeparateLibrary: buildParameters.childWorkspaceSeparateLibrary,
|
||||||
|
});
|
||||||
|
const projectFullPath = path.join(workspace, buildParameters.projectPath);
|
||||||
|
const restored = ChildWorkspaceService.initializeWorkspace(projectFullPath, childWorkspaceConfig);
|
||||||
|
core.info(
|
||||||
|
`Child workspace "${buildParameters.childWorkspaceName}": ${
|
||||||
|
restored ? 'restored from cache' : 'starting fresh'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log workspace size for resource tracking
|
||||||
|
const size = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
|
||||||
|
core.info(`Child workspace size after restore: ${size}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submodule profile initialization
|
||||||
|
if (buildParameters.submoduleProfilePath) {
|
||||||
|
const { SubmoduleProfileService } = await import(
|
||||||
|
'./model/orchestrator/services/submodule/submodule-profile-service'
|
||||||
|
);
|
||||||
|
core.info('Initializing submodules from profile...');
|
||||||
|
const plan = await SubmoduleProfileService.createInitPlan(
|
||||||
|
buildParameters.submoduleProfilePath,
|
||||||
|
buildParameters.submoduleVariantPath,
|
||||||
|
workspace,
|
||||||
|
);
|
||||||
|
await SubmoduleProfileService.execute(
|
||||||
|
plan,
|
||||||
|
workspace,
|
||||||
|
buildParameters.submoduleToken || buildParameters.gitPrivateToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure custom LFS transfer agent
|
||||||
|
if (buildParameters.lfsTransferAgent) {
|
||||||
|
const { LfsAgentService } = await import('./model/orchestrator/services/lfs/lfs-agent-service');
|
||||||
|
core.info('Configuring custom LFS transfer agent...');
|
||||||
|
await LfsAgentService.configure(
|
||||||
|
buildParameters.lfsTransferAgent,
|
||||||
|
buildParameters.lfsTransferAgentArgs,
|
||||||
|
buildParameters.lfsStoragePaths ? buildParameters.lfsStoragePaths.split(';') : [],
|
||||||
|
workspace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local build caching - restore
|
||||||
|
let cacheRoot = '';
|
||||||
|
let cacheKey = '';
|
||||||
|
if (buildParameters.localCacheEnabled) {
|
||||||
|
const { LocalCacheService } = await import('./model/orchestrator/services/cache/local-cache-service');
|
||||||
|
cacheRoot = LocalCacheService.resolveCacheRoot(buildParameters);
|
||||||
|
cacheKey = LocalCacheService.generateCacheKey(
|
||||||
|
buildParameters.targetPlatform,
|
||||||
|
buildParameters.editorVersion,
|
||||||
|
buildParameters.branch || '',
|
||||||
|
);
|
||||||
|
if (buildParameters.localCacheLfs) {
|
||||||
|
await LocalCacheService.restoreLfsCache(workspace, cacheRoot, cacheKey);
|
||||||
|
}
|
||||||
|
if (buildParameters.localCacheLibrary) {
|
||||||
|
const projectFullPath = path.join(workspace, buildParameters.projectPath);
|
||||||
|
await LocalCacheService.restoreLibraryCache(projectFullPath, cacheRoot, cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git hooks — opt-in only. When disabled (default), do not touch hooks at all.
|
||||||
|
if (buildParameters.gitHooksEnabled) {
|
||||||
|
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
|
||||||
|
await GitHooksService.installHooks(workspace);
|
||||||
|
if (buildParameters.gitHooksSkipList) {
|
||||||
|
const environment = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
|
||||||
|
Object.assign(process.env, environment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await PlatformSetup.setup(buildParameters, actionFolder);
|
||||||
|
exitCode =
|
||||||
|
process.platform === 'darwin'
|
||||||
|
? await MacBuilder.run(actionFolder)
|
||||||
|
: await Docker.run(baseImage.toString(), {
|
||||||
|
workspace,
|
||||||
|
actionFolder,
|
||||||
|
...buildParameters,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Local build caching - save
|
||||||
|
if (buildParameters.localCacheEnabled) {
|
||||||
|
const { LocalCacheService } = await import('./model/orchestrator/services/cache/local-cache-service');
|
||||||
|
if (buildParameters.localCacheLibrary) {
|
||||||
|
const projectFullPath = path.join(workspace, buildParameters.projectPath);
|
||||||
|
await LocalCacheService.saveLibraryCache(projectFullPath, cacheRoot, cacheKey);
|
||||||
|
}
|
||||||
|
if (buildParameters.localCacheLfs) {
|
||||||
|
await LocalCacheService.saveLfsCache(workspace, cacheRoot, cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child workspace isolation - save workspace for next run
|
||||||
|
if (childWorkspaceConfig && childWorkspaceConfig.enabled) {
|
||||||
|
const { ChildWorkspaceService } = await import('./model/orchestrator/services/cache/child-workspace-service');
|
||||||
|
const projectFullPath = path.join(workspace, buildParameters.projectPath);
|
||||||
|
const preSaveSize = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
|
||||||
|
core.info(`Child workspace size before save: ${preSaveSize}`);
|
||||||
|
|
||||||
|
ChildWorkspaceService.saveWorkspace(projectFullPath, childWorkspaceConfig);
|
||||||
|
core.info(`Child workspace "${buildParameters.childWorkspaceName}" saved to cache`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
await Orchestrator.run(buildParameters, baseImage.toString());
|
||||||
`Provider strategy "${buildParameters.providerStrategy}" requires @game-ci/orchestrator. ` +
|
exitCode = 0;
|
||||||
'Install it via the game-ci/orchestrator action, or use providerStrategy=local.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set core outputs
|
// Post-build: archive and enforce retention
|
||||||
|
if (buildParameters.buildArchiveEnabled && exitCode === 0) {
|
||||||
|
core.info('Archiving build output...');
|
||||||
|
BuildReliabilityService.archiveBuildOutput(buildParameters.buildPath, buildParameters.buildArchivePath);
|
||||||
|
BuildReliabilityService.enforceRetention(buildParameters.buildArchivePath, buildParameters.buildArchiveRetention);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set output
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
@@ -57,28 +197,4 @@ async function runMain() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLocalBuild(
|
|
||||||
buildParameters: BuildParameters,
|
|
||||||
baseImage: ImageTag,
|
|
||||||
workspace: string,
|
|
||||||
actionFolder: string,
|
|
||||||
plugin?: OrchestratorPlugin,
|
|
||||||
): Promise<number> {
|
|
||||||
await plugin?.beforeLocalBuild(workspace);
|
|
||||||
|
|
||||||
await PlatformSetup.setup(buildParameters, actionFolder);
|
|
||||||
const exitCode =
|
|
||||||
process.platform === 'darwin'
|
|
||||||
? await MacBuilder.run(actionFolder)
|
|
||||||
: await Docker.run(baseImage.toString(), {
|
|
||||||
workspace,
|
|
||||||
actionFolder,
|
|
||||||
...buildParameters,
|
|
||||||
});
|
|
||||||
|
|
||||||
await plugin?.afterLocalBuild(workspace, exitCode);
|
|
||||||
|
|
||||||
return exitCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
runMain();
|
runMain();
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// 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,10 +219,5 @@ 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,5 +1,7 @@
|
|||||||
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';
|
||||||
@@ -8,6 +10,8 @@ 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 {
|
||||||
@@ -47,26 +51,145 @@ 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 gitAuthMode!: string;
|
||||||
|
public fallbackProviderStrategy!: string;
|
||||||
|
public runnerCheckEnabled!: boolean;
|
||||||
|
public runnerCheckLabels!: string[];
|
||||||
|
public runnerCheckMinAvailable!: number;
|
||||||
|
public retryOnFallback!: boolean;
|
||||||
|
public providerInitTimeout!: number;
|
||||||
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 submoduleProfilePath!: string;
|
||||||
|
public submoduleVariantPath!: string;
|
||||||
|
public submoduleToken!: string;
|
||||||
|
public localCacheEnabled!: boolean;
|
||||||
|
public localCacheRoot!: string;
|
||||||
|
public localCacheLibrary!: boolean;
|
||||||
|
public localCacheLfs!: boolean;
|
||||||
|
public childWorkspacesEnabled!: boolean;
|
||||||
|
public childWorkspaceName!: string;
|
||||||
|
public childWorkspaceCacheRoot!: string;
|
||||||
|
public childWorkspacePreserveGit!: boolean;
|
||||||
|
public childWorkspaceSeparateLibrary!: boolean;
|
||||||
|
public lfsTransferAgent!: string;
|
||||||
|
public lfsTransferAgentArgs!: string;
|
||||||
|
public lfsStoragePaths!: string;
|
||||||
|
public gitHooksEnabled!: boolean;
|
||||||
|
public gitHooksSkipList!: string;
|
||||||
|
public gitHooksRunBeforeBuild!: string;
|
||||||
|
public providerExecutable!: string;
|
||||||
|
public gitIntegrityCheck!: boolean;
|
||||||
|
public gitAutoRecover!: boolean;
|
||||||
|
public cleanReservedFilenames!: boolean;
|
||||||
|
public buildArchiveEnabled!: boolean;
|
||||||
|
public buildArchivePath!: string;
|
||||||
|
public buildArchiveRetention!: number;
|
||||||
|
|
||||||
|
// GCP Cloud Run (Experimental)
|
||||||
|
public gcpProject!: string;
|
||||||
|
public gcpRegion!: string;
|
||||||
|
public gcpStorageType!: string;
|
||||||
|
public gcpBucket!: string;
|
||||||
|
public gcpFilestoreIp!: string;
|
||||||
|
public gcpFilestoreShare!: string;
|
||||||
|
public gcpMachineType!: string;
|
||||||
|
public gcpDiskSizeGb!: string;
|
||||||
|
public gcpServiceAccount!: string;
|
||||||
|
public gcpVpcConnector!: string;
|
||||||
|
|
||||||
|
// Azure Container Instances (Experimental)
|
||||||
|
public azureResourceGroup!: string;
|
||||||
|
public azureLocation!: string;
|
||||||
|
public azureStorageType!: string;
|
||||||
|
public azureStorageAccount!: string;
|
||||||
|
public azureBlobContainer!: string;
|
||||||
|
public azureFileShareName!: string;
|
||||||
|
public azureSubscriptionId!: string;
|
||||||
|
public azureCpu!: string;
|
||||||
|
public azureMemoryGb!: string;
|
||||||
|
public azureDiskSizeGb!: string;
|
||||||
|
public azureSubnetId!: string;
|
||||||
|
|
||||||
|
// Remote PowerShell provider
|
||||||
|
public remotePowershellHost!: string;
|
||||||
|
public remotePowershellCredential!: string;
|
||||||
|
public remotePowershellTransport!: string;
|
||||||
|
|
||||||
|
// GitHub Actions provider
|
||||||
|
public githubActionsRepo!: string;
|
||||||
|
public githubActionsWorkflow!: string;
|
||||||
|
public githubActionsToken!: string;
|
||||||
|
public githubActionsRef!: string;
|
||||||
|
|
||||||
|
// GitLab CI provider
|
||||||
|
public gitlabProjectId!: string;
|
||||||
|
public gitlabTriggerToken!: string;
|
||||||
|
public gitlabApiUrl!: string;
|
||||||
|
public gitlabRef!: string;
|
||||||
|
|
||||||
|
// Ansible provider
|
||||||
|
public ansibleInventory!: string;
|
||||||
|
public ansiblePlaybook!: string;
|
||||||
|
public ansibleExtraVars!: string;
|
||||||
|
public ansibleVaultPassword!: string;
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -112,8 +235,6 @@ 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,
|
||||||
@@ -142,7 +263,6 @@ 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()),
|
||||||
@@ -153,21 +273,131 @@ class BuildParameters {
|
|||||||
dockerIsolationMode: Input.dockerIsolationMode,
|
dockerIsolationMode: Input.dockerIsolationMode,
|
||||||
containerRegistryRepository: Input.containerRegistryRepository,
|
containerRegistryRepository: Input.containerRegistryRepository,
|
||||||
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
||||||
providerStrategy,
|
providerStrategy: OrchestratorOptions.providerStrategy,
|
||||||
buildPlatform: providerStrategy !== 'local' ? 'linux' : process.platform,
|
gitAuthMode: OrchestratorOptions.gitAuthMode,
|
||||||
|
fallbackProviderStrategy: OrchestratorOptions.fallbackProviderStrategy,
|
||||||
|
runnerCheckEnabled: OrchestratorOptions.runnerCheckEnabled,
|
||||||
|
runnerCheckLabels: OrchestratorOptions.runnerCheckLabels,
|
||||||
|
runnerCheckMinAvailable: OrchestratorOptions.runnerCheckMinAvailable,
|
||||||
|
retryOnFallback: OrchestratorOptions.retryOnFallback,
|
||||||
|
providerInitTimeout: OrchestratorOptions.providerInitTimeout,
|
||||||
|
buildPlatform: OrchestratorOptions.buildPlatform,
|
||||||
|
kubeConfig: OrchestratorOptions.kubeConfig,
|
||||||
|
containerMemory: OrchestratorOptions.containerMemory,
|
||||||
|
containerCpu: OrchestratorOptions.containerCpu,
|
||||||
|
containerNamespace: OrchestratorOptions.containerNamespace,
|
||||||
|
kubeVolumeSize: OrchestratorOptions.kubeVolumeSize,
|
||||||
|
kubeVolume: OrchestratorOptions.kubeVolume,
|
||||||
|
postBuildContainerHooks: OrchestratorOptions.postBuildContainerHooks,
|
||||||
|
preBuildContainerHooks: OrchestratorOptions.preBuildContainerHooks,
|
||||||
|
customJob: OrchestratorOptions.customJob,
|
||||||
runNumber: Input.runNumber,
|
runNumber: Input.runNumber,
|
||||||
branch: Input.branch.replace('/head', '') || (await GitRepoReader.GetBranch()),
|
branch: Input.branch.replace('/head', '') || (await GitRepoReader.GetBranch()),
|
||||||
githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || 'game-ci/unity-builder',
|
orchestratorBranch: OrchestratorOptions.orchestratorBranch.split('/').reverse()[0],
|
||||||
gitSha: Input.gitSha,
|
orchestratorDebug: OrchestratorOptions.orchestratorDebug,
|
||||||
logId: customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 9)(),
|
githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || OrchestratorOptions.orchestratorRepoName,
|
||||||
buildGuid: `${Input.runNumber}-${Input.targetPlatform.toLowerCase().replace('standalone', '')}-${customAlphabet(
|
orchestratorRepoName: OrchestratorOptions.orchestratorRepoName,
|
||||||
'0123456789abcdefghijklmnopqrstuvwxyz',
|
cloneDepth: Number.parseInt(OrchestratorOptions.cloneDepth),
|
||||||
4,
|
|
||||||
)()}`,
|
|
||||||
isCliMode: Cli.isCliMode,
|
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,
|
||||||
|
logId: customAlphabet(OrchestratorConstants.alphabet, 9)(),
|
||||||
|
buildGuid: OrchestratorBuildGuid.generateGuid(Input.runNumber, Input.targetPlatform),
|
||||||
|
commandHooks: OrchestratorOptions.commandHooks,
|
||||||
|
inputPullCommand: OrchestratorOptions.inputPullCommand,
|
||||||
|
pullInputList: OrchestratorOptions.pullInputList,
|
||||||
|
kubeStorageClass: OrchestratorOptions.kubeStorageClass,
|
||||||
|
gcpProject: Input.gcpProject,
|
||||||
|
gcpRegion: Input.gcpRegion,
|
||||||
|
gcpStorageType: Input.gcpStorageType,
|
||||||
|
gcpBucket: Input.gcpBucket,
|
||||||
|
gcpFilestoreIp: Input.gcpFilestoreIp,
|
||||||
|
gcpFilestoreShare: Input.gcpFilestoreShare,
|
||||||
|
gcpMachineType: Input.gcpMachineType,
|
||||||
|
gcpDiskSizeGb: Input.gcpDiskSizeGb,
|
||||||
|
gcpServiceAccount: Input.gcpServiceAccount,
|
||||||
|
gcpVpcConnector: Input.gcpVpcConnector,
|
||||||
|
azureResourceGroup: Input.azureResourceGroup,
|
||||||
|
azureLocation: Input.azureLocation,
|
||||||
|
azureStorageType: Input.azureStorageType,
|
||||||
|
azureStorageAccount: Input.azureStorageAccount,
|
||||||
|
azureBlobContainer: Input.azureBlobContainer,
|
||||||
|
azureFileShareName: Input.azureFileShareName,
|
||||||
|
azureSubscriptionId: Input.azureSubscriptionId,
|
||||||
|
azureCpu: Input.azureCpu,
|
||||||
|
azureMemoryGb: Input.azureMemoryGb,
|
||||||
|
azureDiskSizeGb: Input.azureDiskSizeGb,
|
||||||
|
azureSubnetId: Input.azureSubnetId,
|
||||||
|
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,
|
||||||
|
submoduleProfilePath: Input.submoduleProfilePath,
|
||||||
|
submoduleVariantPath: Input.submoduleVariantPath,
|
||||||
|
submoduleToken: Input.submoduleToken,
|
||||||
|
localCacheEnabled: Input.localCacheEnabled,
|
||||||
|
localCacheRoot: Input.localCacheRoot,
|
||||||
|
localCacheLibrary: Input.localCacheLibrary,
|
||||||
|
localCacheLfs: Input.localCacheLfs,
|
||||||
|
childWorkspacesEnabled: Input.childWorkspacesEnabled,
|
||||||
|
childWorkspaceName: Input.childWorkspaceName,
|
||||||
|
childWorkspaceCacheRoot: Input.childWorkspaceCacheRoot,
|
||||||
|
childWorkspacePreserveGit: Input.childWorkspacePreserveGit,
|
||||||
|
childWorkspaceSeparateLibrary: Input.childWorkspaceSeparateLibrary,
|
||||||
|
lfsTransferAgent: Input.lfsTransferAgent,
|
||||||
|
lfsTransferAgentArgs: Input.lfsTransferAgentArgs,
|
||||||
|
lfsStoragePaths: Input.lfsStoragePaths,
|
||||||
|
gitHooksEnabled: Input.gitHooksEnabled,
|
||||||
|
gitHooksSkipList: Input.gitHooksSkipList,
|
||||||
|
gitHooksRunBeforeBuild: Input.gitHooksRunBeforeBuild,
|
||||||
|
providerExecutable: Input.providerExecutable,
|
||||||
|
gitIntegrityCheck: Input.gitIntegrityCheck,
|
||||||
|
gitAutoRecover: Input.gitAutoRecover,
|
||||||
|
cleanReservedFilenames: Input.cleanReservedFilenames,
|
||||||
|
buildArchiveEnabled: Input.buildArchiveEnabled,
|
||||||
|
buildArchivePath: Input.buildArchivePath,
|
||||||
|
buildArchiveRetention: Input.buildArchiveRetention,
|
||||||
|
|
||||||
|
// Remote PowerShell provider
|
||||||
|
remotePowershellHost: Input.remotePowershellHost,
|
||||||
|
remotePowershellCredential: Input.remotePowershellCredential,
|
||||||
|
remotePowershellTransport: Input.remotePowershellTransport,
|
||||||
|
|
||||||
|
// GitHub Actions provider
|
||||||
|
githubActionsRepo: Input.githubActionsRepo,
|
||||||
|
githubActionsWorkflow: Input.githubActionsWorkflow,
|
||||||
|
githubActionsToken: Input.githubActionsToken,
|
||||||
|
githubActionsRef: Input.githubActionsRef,
|
||||||
|
|
||||||
|
// GitLab CI provider
|
||||||
|
gitlabProjectId: Input.gitlabProjectId,
|
||||||
|
gitlabTriggerToken: Input.gitlabTriggerToken,
|
||||||
|
gitlabApiUrl: Input.gitlabApiUrl,
|
||||||
|
gitlabRef: Input.gitlabRef,
|
||||||
|
|
||||||
|
// Ansible provider
|
||||||
|
ansibleInventory: Input.ansibleInventory,
|
||||||
|
ansiblePlaybook: Input.ansiblePlaybook,
|
||||||
|
ansibleExtraVars: Input.ansibleExtraVars,
|
||||||
|
ansibleVaultPassword: Input.ansibleVaultPassword,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { Command } from 'commander-ts';
|
import { Command } from 'commander-ts';
|
||||||
import { Input } from '..';
|
import { BuildParameters, Orchestrator, ImageTag, 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';
|
||||||
|
import { SubmoduleProfileService } from '../orchestrator/services/submodule/submodule-profile-service';
|
||||||
|
import { LfsAgentService } from '../orchestrator/services/lfs/lfs-agent-service';
|
||||||
|
|
||||||
export class Cli {
|
export class Cli {
|
||||||
public static options: OptionValues | undefined;
|
public static options: OptionValues | undefined;
|
||||||
@@ -23,13 +32,14 @@ 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));
|
||||||
}
|
}
|
||||||
@@ -57,15 +67,26 @@ export class Cli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async RunCli(): Promise<void> {
|
static async RunCli(): Promise<void> {
|
||||||
const results = CliFunctionsRepository.GetCliFunctions(Cli.options?.mode);
|
GitHub.githubInputEnabled = false;
|
||||||
if (!results) {
|
if (Cli.options!['populateOverride'] === `true`) {
|
||||||
throw new Error(
|
await OrchestratorQueryOverride.PopulateQueryOverrideInput();
|
||||||
`Unknown CLI mode: ${Cli.options?.mode}. Orchestrator CLI features require @game-ci/orchestrator.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
core.info(`Entrypoint: ${results.key}`);
|
if (Cli.options!['logInput']) {
|
||||||
|
Cli.logInput();
|
||||||
|
}
|
||||||
|
const results = CliFunctionsRepository.GetCliFunctions(Cli.options?.mode);
|
||||||
|
OrchestratorLogger.log(`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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +94,7 @@ export class Cli {
|
|||||||
private static logInput() {
|
private static logInput() {
|
||||||
core.info(`\n`);
|
core.info(`\n`);
|
||||||
core.info(`INPUT:`);
|
core.info(`INPUT:`);
|
||||||
const properties = Object.getOwnPropertyNames(Input).filter(
|
const properties = OrchestratorOptionsReader.GetProperties();
|
||||||
(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 &&
|
||||||
@@ -91,4 +110,95 @@ 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
@CliFunction(`submodule-init`, `initializes submodules from a YAML profile`)
|
||||||
|
public static async SubmoduleInit(): Promise<void> {
|
||||||
|
const profilePath = Cli.options!['profilePath'];
|
||||||
|
const variantPath = Cli.options!['variantPath'] || '';
|
||||||
|
if (!profilePath) {
|
||||||
|
throw new Error('--profilePath is required for submodule-init');
|
||||||
|
}
|
||||||
|
const plan = await SubmoduleProfileService.createInitPlan(profilePath, variantPath, process.cwd());
|
||||||
|
await SubmoduleProfileService.execute(plan, process.cwd());
|
||||||
|
}
|
||||||
|
|
||||||
|
@CliFunction(`lfs-agent-configure`, `configures a custom LFS transfer agent`)
|
||||||
|
public static async LfsAgentConfigure(): Promise<void> {
|
||||||
|
const agentPath = Cli.options!['agentPath'];
|
||||||
|
if (!agentPath) {
|
||||||
|
throw new Error('--agentPath is required for lfs-agent-configure');
|
||||||
|
}
|
||||||
|
const agentArgs = Cli.options!['agentArgs'] || '';
|
||||||
|
const storagePaths = (Cli.options!['storagePaths'] || '').split(';').filter(Boolean);
|
||||||
|
await LfsAgentService.configure(agentPath, agentArgs, storagePaths, process.cwd());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class Docker {
|
|||||||
const {
|
const {
|
||||||
workspace,
|
workspace,
|
||||||
actionFolder,
|
actionFolder,
|
||||||
useHostNetwork,
|
|
||||||
runnerTempPath,
|
runnerTempPath,
|
||||||
sshAgent,
|
sshAgent,
|
||||||
sshPublicKeysDirectoryPath,
|
sshPublicKeysDirectoryPath,
|
||||||
@@ -86,7 +85,6 @@ 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`} \
|
||||||
|
|||||||
509
src/model/enterprise-inputs.test.ts
Normal file
509
src/model/enterprise-inputs.test.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
/**
|
||||||
|
* Tests for enterprise input properties and their wiring into BuildParameters.
|
||||||
|
*
|
||||||
|
* Covers all 20 new input properties added for enterprise features:
|
||||||
|
* - Boolean inputs: localCacheEnabled, childWorkspacesEnabled, gitHooksEnabled,
|
||||||
|
* localCacheLibrary, localCacheLfs, childWorkspacePreserveGit, childWorkspaceSeparateLibrary
|
||||||
|
* - String inputs: submoduleProfilePath, submoduleVariantPath, submoduleToken,
|
||||||
|
* localCacheRoot, childWorkspaceName, childWorkspaceCacheRoot, lfsTransferAgent,
|
||||||
|
* lfsTransferAgentArgs, lfsStoragePaths, providerExecutable, gitHooksSkipList,
|
||||||
|
* gitHooksRunBeforeBuild
|
||||||
|
*
|
||||||
|
* Special attention to boolean inputs: GitHub Actions always passes inputs as strings,
|
||||||
|
* so 'false' must NOT evaluate as truthy (the #1 source of bugs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as core from '@actions/core';
|
||||||
|
import Input from './input';
|
||||||
|
import Versioning from './versioning';
|
||||||
|
import BuildParameters from './build-parameters';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Part 1: Input getters — defaults and explicit values
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Enterprise Input properties', () => {
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Boolean inputs — default and string parsing
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('localCacheEnabled', () => {
|
||||||
|
it('returns false by default', () => {
|
||||||
|
expect(Input.localCacheEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when string "true" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('true');
|
||||||
|
expect(Input.localCacheEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when string "false" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('false');
|
||||||
|
expect(Input.localCacheEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when empty string is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('');
|
||||||
|
expect(Input.localCacheEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('localCacheLibrary', () => {
|
||||||
|
it('returns true by default (library caching on by default when cache enabled)', () => {
|
||||||
|
expect(Input.localCacheLibrary).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when string "true" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('true');
|
||||||
|
expect(Input.localCacheLibrary).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when string "false" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('false');
|
||||||
|
expect(Input.localCacheLibrary).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('localCacheLfs', () => {
|
||||||
|
it('returns false by default', () => {
|
||||||
|
expect(Input.localCacheLfs).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when string "true" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('true');
|
||||||
|
expect(Input.localCacheLfs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when string "false" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('false');
|
||||||
|
expect(Input.localCacheLfs).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('childWorkspacesEnabled', () => {
|
||||||
|
it('returns false by default', () => {
|
||||||
|
expect(Input.childWorkspacesEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when string "true" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('true');
|
||||||
|
expect(Input.childWorkspacesEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when string "false" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('false');
|
||||||
|
expect(Input.childWorkspacesEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when empty string is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('');
|
||||||
|
expect(Input.childWorkspacesEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('childWorkspacePreserveGit', () => {
|
||||||
|
it('returns true by default', () => {
|
||||||
|
expect(Input.childWorkspacePreserveGit).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when string "false" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('false');
|
||||||
|
expect(Input.childWorkspacePreserveGit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when string "true" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('true');
|
||||||
|
expect(Input.childWorkspacePreserveGit).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('childWorkspaceSeparateLibrary', () => {
|
||||||
|
it('returns true by default', () => {
|
||||||
|
expect(Input.childWorkspaceSeparateLibrary).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when string "false" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('false');
|
||||||
|
expect(Input.childWorkspaceSeparateLibrary).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when string "true" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('true');
|
||||||
|
expect(Input.childWorkspaceSeparateLibrary).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gitHooksEnabled', () => {
|
||||||
|
it('returns false by default', () => {
|
||||||
|
expect(Input.gitHooksEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when string "true" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('true');
|
||||||
|
expect(Input.gitHooksEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when string "false" is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('false');
|
||||||
|
expect(Input.gitHooksEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when empty string is passed', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('');
|
||||||
|
expect(Input.gitHooksEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Boolean truthiness edge cases — the #1 source of bugs
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('boolean input string handling (edge cases)', () => {
|
||||||
|
// These tests verify that the === 'true' comparison is correct.
|
||||||
|
// In JavaScript, 'false' is truthy when used in a boolean context,
|
||||||
|
// but the Input class correctly uses === 'true' comparison.
|
||||||
|
|
||||||
|
const booleanInputs: Array<{
|
||||||
|
name: string;
|
||||||
|
getter: () => boolean;
|
||||||
|
defaultValue: boolean;
|
||||||
|
}> = [
|
||||||
|
{ name: 'localCacheEnabled', getter: () => Input.localCacheEnabled, defaultValue: false },
|
||||||
|
{ name: 'localCacheLfs', getter: () => Input.localCacheLfs, defaultValue: false },
|
||||||
|
{ name: 'childWorkspacesEnabled', getter: () => Input.childWorkspacesEnabled, defaultValue: false },
|
||||||
|
{ name: 'gitHooksEnabled', getter: () => Input.gitHooksEnabled, defaultValue: false },
|
||||||
|
|
||||||
|
// These default to true:
|
||||||
|
{ name: 'localCacheLibrary', getter: () => Input.localCacheLibrary, defaultValue: true },
|
||||||
|
{ name: 'childWorkspacePreserveGit', getter: () => Input.childWorkspacePreserveGit, defaultValue: true },
|
||||||
|
{ name: 'childWorkspaceSeparateLibrary', getter: () => Input.childWorkspaceSeparateLibrary, defaultValue: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(booleanInputs)('$name: "false" string does NOT evaluate as truthy', ({ getter }) => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('false');
|
||||||
|
expect(getter()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(booleanInputs)('$name: "true" string evaluates as truthy', ({ getter }) => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('true');
|
||||||
|
expect(getter()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(booleanInputs)('$name: "TRUE" (uppercase) does NOT evaluate as true (case sensitive)', ({ getter }) => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('TRUE');
|
||||||
|
expect(getter()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(booleanInputs)('$name: "1" does NOT evaluate as true', ({ getter }) => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('1');
|
||||||
|
expect(getter()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(booleanInputs)('$name: "yes" does NOT evaluate as true', ({ getter }) => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('yes');
|
||||||
|
expect(getter()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// String inputs — defaults and explicit values
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submoduleProfilePath', () => {
|
||||||
|
it('returns empty string by default', () => {
|
||||||
|
expect(Input.submoduleProfilePath).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes input from workflow', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('config/submodule-profiles/tow/ec/profile.yml');
|
||||||
|
expect(Input.submoduleProfilePath).toBe('config/submodule-profiles/tow/ec/profile.yml');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('submoduleVariantPath', () => {
|
||||||
|
it('returns empty string by default', () => {
|
||||||
|
expect(Input.submoduleVariantPath).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes input from workflow', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('config/submodule-profiles/tow/ec/server.yml');
|
||||||
|
expect(Input.submoduleVariantPath).toBe('config/submodule-profiles/tow/ec/server.yml');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('submoduleToken', () => {
|
||||||
|
it('returns empty string by default', () => {
|
||||||
|
expect(Input.submoduleToken).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes input from workflow', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('ghp_abc123');
|
||||||
|
expect(Input.submoduleToken).toBe('ghp_abc123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('localCacheRoot', () => {
|
||||||
|
it('returns empty string by default', () => {
|
||||||
|
expect(Input.localCacheRoot).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes input from workflow', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('/d/cache/game-ci');
|
||||||
|
expect(Input.localCacheRoot).toBe('/d/cache/game-ci');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('childWorkspaceName', () => {
|
||||||
|
it('returns empty string by default', () => {
|
||||||
|
expect(Input.childWorkspaceName).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes input from workflow', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('TurnOfWarEndlessCrusade');
|
||||||
|
expect(Input.childWorkspaceName).toBe('TurnOfWarEndlessCrusade');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('childWorkspaceCacheRoot', () => {
|
||||||
|
it('returns empty string by default', () => {
|
||||||
|
expect(Input.childWorkspaceCacheRoot).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes input from workflow', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('/d/workspaces');
|
||||||
|
expect(Input.childWorkspaceCacheRoot).toBe('/d/workspaces');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lfsTransferAgent', () => {
|
||||||
|
it('returns empty string by default', () => {
|
||||||
|
expect(Input.lfsTransferAgent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes input from workflow', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('/tools/elastic-git-storage');
|
||||||
|
expect(Input.lfsTransferAgent).toBe('/tools/elastic-git-storage');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lfsTransferAgentArgs', () => {
|
||||||
|
it('returns empty string by default', () => {
|
||||||
|
expect(Input.lfsTransferAgentArgs).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes input from workflow', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('--verbose --timeout=60');
|
||||||
|
expect(Input.lfsTransferAgentArgs).toBe('--verbose --timeout=60');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lfsStoragePaths', () => {
|
||||||
|
it('returns empty string by default', () => {
|
||||||
|
expect(Input.lfsStoragePaths).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes input from workflow', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('/storage/primary;/storage/secondary');
|
||||||
|
expect(Input.lfsStoragePaths).toBe('/storage/primary;/storage/secondary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('providerExecutable', () => {
|
||||||
|
it('returns empty string by default', () => {
|
||||||
|
expect(Input.providerExecutable).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes input from workflow', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('/usr/local/bin/custom-provider');
|
||||||
|
expect(Input.providerExecutable).toBe('/usr/local/bin/custom-provider');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gitHooksSkipList', () => {
|
||||||
|
it('returns empty string by default', () => {
|
||||||
|
expect(Input.gitHooksSkipList).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes input from workflow', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('pre-commit,pre-push');
|
||||||
|
expect(Input.gitHooksSkipList).toBe('pre-commit,pre-push');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gitHooksRunBeforeBuild', () => {
|
||||||
|
it('returns empty string by default', () => {
|
||||||
|
expect(Input.gitHooksRunBeforeBuild).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes input from workflow', () => {
|
||||||
|
jest.spyOn(core, 'getInput').mockReturnValue('pre-commit');
|
||||||
|
expect(Input.gitHooksRunBeforeBuild).toBe('pre-commit');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Part 2: BuildParameters.create() maps new inputs to properties
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const testLicense =
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?><root>\n <License id="Terms">\n <MachineBindings>\n <Binding Key="1" Value="576562626572264761624c65526f7578"/>\n <Binding Key="2" Value="576562626572264761624c65526f7578"/>\n </MachineBindings>\n <MachineID Value="D7nTUnjNAmtsUMcnoyrqkgIbYdM="/>\n <SerialHash Value="2033b8ac3e6faa3742ca9f0bfae44d18f2a96b80"/>\n <Features>\n <Feature Value="33"/>\n <Feature Value="1"/>\n <Feature Value="12"/>\n <Feature Value="2"/>\n <Feature Value="24"/>\n <Feature Value="3"/>\n <Feature Value="36"/>\n <Feature Value="17"/>\n <Feature Value="19"/>\n <Feature Value="62"/>\n </Features>\n <DeveloperData Value="AQAAAEY0LUJHUlgtWEQ0RS1aQ1dWLUM1SlctR0RIQg=="/>\n <SerialMasked Value="F4-BGRX-XD4E-ZCWV-C5JW-XXXX"/>\n <StartDate Value="2021-02-08T00:00:00"/>\n <UpdateDate Value="2021-02-09T00:34:57"/>\n <InitialActivationDate Value="2021-02-08T00:34:56"/>\n <LicenseVersion Value="6.x"/>\n <ClientProvidedVersion Value="2018.4.30f1"/>\n <AlwaysOnline Value="false"/>\n <Entitlements>\n <Entitlement Ns="unity_editor" Tag="UnityPersonal" Type="EDITOR" ValidTo="9999-12-31T00:00:00"/>\n <Entitlement Ns="unity_editor" Tag="DarkSkin" Type="EDITOR_FEATURE" ValidTo="9999-12-31T00:00:00"/>\n </Entitlements>\n </License>\n<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#Terms"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>m0Db8UK+ktnOLJBtHybkfetpcKo=</DigestValue></Reference></SignedInfo><SignatureValue>o/pUbSQAukz7+ZYAWhnA0AJbIlyyCPL7bKVEM2lVqbrXt7cyey+umkCXamuOgsWPVUKBMkXtMH8L\n5etLmD0getWIhTGhzOnDCk+gtIPfL4jMo9tkEuOCROQAXCci23VFscKcrkB+3X6h4wEOtA2APhOY\nB+wvC794o8/82ffjP79aVAi57rp3Wmzx+9pe9yMwoJuljAy2sc2tIMgdQGWVmOGBpQm3JqsidyzI\nJWG2kjnc7pDXK9pwYzXoKiqUqqrut90d+kQqRyv7MSZXR50HFqD/LI69h68b7P8Bjo3bPXOhNXGR\n9YCoemH6EkfCJxp2gIjzjWW+l2Hj2EsFQi8YXw==</SignatureValue></Signature></root>';
|
||||||
|
|
||||||
|
describe('BuildParameters.create() enterprise property mapping', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
|
||||||
|
process.env.UNITY_LICENSE = testLicense;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps submoduleProfilePath from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'submoduleProfilePath', 'get').mockReturnValue('/path/to/profile.yml');
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.submoduleProfilePath).toBe('/path/to/profile.yml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps submoduleVariantPath from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'submoduleVariantPath', 'get').mockReturnValue('/path/to/variant.yml');
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.submoduleVariantPath).toBe('/path/to/variant.yml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps submoduleToken from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'submoduleToken', 'get').mockReturnValue('ghp_token123');
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.submoduleToken).toBe('ghp_token123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps localCacheEnabled from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'localCacheEnabled', 'get').mockReturnValue(true);
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.localCacheEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps localCacheRoot from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'localCacheRoot', 'get').mockReturnValue('/d/cache');
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.localCacheRoot).toBe('/d/cache');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps localCacheLibrary from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'localCacheLibrary', 'get').mockReturnValue(false);
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.localCacheLibrary).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps localCacheLfs from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'localCacheLfs', 'get').mockReturnValue(true);
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.localCacheLfs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps childWorkspacesEnabled from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'childWorkspacesEnabled', 'get').mockReturnValue(true);
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.childWorkspacesEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps childWorkspaceName from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'childWorkspaceName', 'get').mockReturnValue('TurnOfWar');
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.childWorkspaceName).toBe('TurnOfWar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps childWorkspaceCacheRoot from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'childWorkspaceCacheRoot', 'get').mockReturnValue('/cache/workspaces');
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.childWorkspaceCacheRoot).toBe('/cache/workspaces');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps childWorkspacePreserveGit from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'childWorkspacePreserveGit', 'get').mockReturnValue(false);
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.childWorkspacePreserveGit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps childWorkspaceSeparateLibrary from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'childWorkspaceSeparateLibrary', 'get').mockReturnValue(false);
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.childWorkspaceSeparateLibrary).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps lfsTransferAgent from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'lfsTransferAgent', 'get').mockReturnValue('/tools/elastic-git-storage');
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.lfsTransferAgent).toBe('/tools/elastic-git-storage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps lfsTransferAgentArgs from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'lfsTransferAgentArgs', 'get').mockReturnValue('--verbose');
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.lfsTransferAgentArgs).toBe('--verbose');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps lfsStoragePaths from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'lfsStoragePaths', 'get').mockReturnValue('/path/a;/path/b');
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.lfsStoragePaths).toBe('/path/a;/path/b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps gitHooksEnabled from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'gitHooksEnabled', 'get').mockReturnValue(true);
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.gitHooksEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps gitHooksSkipList from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'gitHooksSkipList', 'get').mockReturnValue('pre-commit,pre-push');
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.gitHooksSkipList).toBe('pre-commit,pre-push');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps gitHooksRunBeforeBuild from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'gitHooksRunBeforeBuild', 'get').mockReturnValue('pre-commit');
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.gitHooksRunBeforeBuild).toBe('pre-commit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps providerExecutable from Input', async () => {
|
||||||
|
jest.spyOn(Input, 'providerExecutable', 'get').mockReturnValue('/usr/local/bin/provider');
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
expect(parameters.providerExecutable).toBe('/usr/local/bin/provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test that all enterprise properties have correct defaults when not explicitly set
|
||||||
|
it('has correct defaults for all enterprise properties', async () => {
|
||||||
|
const parameters = await BuildParameters.create();
|
||||||
|
|
||||||
|
expect(parameters.submoduleProfilePath).toBe('');
|
||||||
|
expect(parameters.submoduleVariantPath).toBe('');
|
||||||
|
expect(parameters.submoduleToken).toBe('');
|
||||||
|
expect(parameters.localCacheEnabled).toBe(false);
|
||||||
|
expect(parameters.localCacheRoot).toBe('');
|
||||||
|
expect(parameters.localCacheLibrary).toBe(true);
|
||||||
|
expect(parameters.localCacheLfs).toBe(false);
|
||||||
|
expect(parameters.childWorkspacesEnabled).toBe(false);
|
||||||
|
expect(parameters.childWorkspaceName).toBe('');
|
||||||
|
expect(parameters.childWorkspaceCacheRoot).toBe('');
|
||||||
|
expect(parameters.childWorkspacePreserveGit).toBe(true);
|
||||||
|
expect(parameters.childWorkspaceSeparateLibrary).toBe(true);
|
||||||
|
expect(parameters.lfsTransferAgent).toBe('');
|
||||||
|
expect(parameters.lfsTransferAgentArgs).toBe('');
|
||||||
|
expect(parameters.lfsStoragePaths).toBe('');
|
||||||
|
expect(parameters.gitHooksEnabled).toBe(false);
|
||||||
|
expect(parameters.gitHooksSkipList).toBe('');
|
||||||
|
expect(parameters.gitHooksRunBeforeBuild).toBe('');
|
||||||
|
expect(parameters.providerExecutable).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,222 @@
|
|||||||
|
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,5 +9,22 @@ 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 { Action, BuildParameters, Cache, Docker, Input, ImageTag, Output, Platform, Project, Unity, Versioning };
|
export {
|
||||||
|
Action,
|
||||||
|
BuildParameters,
|
||||||
|
Cache,
|
||||||
|
Docker,
|
||||||
|
Input,
|
||||||
|
ImageTag,
|
||||||
|
Output,
|
||||||
|
Platform,
|
||||||
|
Project,
|
||||||
|
Unity,
|
||||||
|
Versioning,
|
||||||
|
Orchestrator as Orchestrator,
|
||||||
|
loadProvider,
|
||||||
|
ProviderLoader,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import { exec } from 'node:child_process';
|
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
|
||||||
import Input from '../input';
|
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
|
||||||
|
|
||||||
export class GenericInputReader {
|
export class GenericInputReader {
|
||||||
public static async Run(command: string) {
|
public static async Run(command: string) {
|
||||||
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
if (OrchestratorOptions.providerStrategy === 'local') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
return await OrchestratorSystem.Run(command, false, true);
|
||||||
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout) => {
|
|
||||||
if (error) {
|
|
||||||
reject(error);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(stdout.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { GitRepoReader } from './git-repo';
|
import { GitRepoReader } from './git-repo';
|
||||||
import Input from '../input';
|
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
|
||||||
|
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 () => {
|
||||||
@@ -9,15 +10,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';
|
||||||
jest.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
|
await jest.spyOn(OrchestratorSystem, 'Run').mockReturnValue(Promise.resolve(mockValue));
|
||||||
jest.spyOn(Input, 'getInput').mockReturnValue('not-local');
|
await jest.spyOn(OrchestratorOptions, 'providerStrategy', 'get').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';
|
||||||
jest.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
|
await jest.spyOn(OrchestratorSystem, 'Run').mockReturnValue(Promise.resolve(mockValue));
|
||||||
jest.spyOn(Input, 'getInput').mockReturnValue('not-local');
|
await jest.spyOn(OrchestratorOptions, 'providerStrategy', 'get').mockReturnValue('not-local');
|
||||||
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
|
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,42 +1,33 @@
|
|||||||
import { assert } from 'node:console';
|
import { assert } from 'node:console';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { exec } from 'node:child_process';
|
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
|
||||||
import * as core from '@actions/core';
|
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';
|
||||||
|
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 ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
if (OrchestratorOptions.providerStrategy === 'local') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
assert(fs.existsSync(`.git`));
|
assert(fs.existsSync(`.git`));
|
||||||
const value = (await GitRepoReader.runCommand(`cd ${Input.projectPath} && git remote -v`)).replace(/ /g, ``);
|
const value = (await OrchestratorSystem.Run(`cd ${Input.projectPath} && git remote -v`, false, true)).replace(
|
||||||
core.info(`value ${value}`);
|
/ /g,
|
||||||
|
``,
|
||||||
|
);
|
||||||
|
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 ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
if (OrchestratorOptions.providerStrategy === 'local') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
assert(fs.existsSync(`.git`));
|
assert(fs.existsSync(`.git`));
|
||||||
|
|
||||||
return (await GitRepoReader.runCommand(`cd ${Input.projectPath} && git branch --show-current`))
|
return (await OrchestratorSystem.Run(`cd ${Input.projectPath} && git branch --show-current`, false, true))
|
||||||
.split('\n')[0]
|
.split('\n')[0]
|
||||||
.replace(/ /g, ``)
|
.replace(/ /g, ``)
|
||||||
.replace('/head', '');
|
.replace('/head', '');
|
||||||
|
|||||||
@@ -1,32 +1,19 @@
|
|||||||
import { exec } from 'node:child_process';
|
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
|
||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
import Input from '../input';
|
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
|
||||||
|
|
||||||
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 ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
if (OrchestratorOptions.providerStrategy === 'local') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const authStatus = await GithubCliReader.runCommand(`gh auth status`, true);
|
const authStatus = await OrchestratorSystem.Run(`gh auth status`, true, true);
|
||||||
if (authStatus.includes('You are not logged') || authStatus === '') {
|
if (authStatus.includes('You are not logged') || authStatus === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await GithubCliReader.runCommand(`gh auth status -t`))
|
return (await OrchestratorSystem.Run(`gh auth status -t`, false, true))
|
||||||
.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 Input from '../input';
|
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
|
||||||
|
|
||||||
export function ReadLicense(): string {
|
export function ReadLicense(): string {
|
||||||
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
if (OrchestratorOptions.providerStrategy === '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,22 +334,4 @@ 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,6 +1,7 @@
|
|||||||
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';
|
||||||
@@ -14,8 +15,7 @@ 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.
|
||||||
*
|
*
|
||||||
* Only core build inputs belong here. Orchestrator/plugin inputs are read
|
* Todo: rename to UserInput and remove anything that is not direct input from the user / ci workflow
|
||||||
* 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,6 +32,10 @@ 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]!;
|
||||||
}
|
}
|
||||||
@@ -41,6 +45,10 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -139,12 +147,6 @@ 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';
|
||||||
}
|
}
|
||||||
@@ -280,6 +282,276 @@ class Input {
|
|||||||
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
|
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get submoduleProfilePath(): string {
|
||||||
|
return Input.getInput('submoduleProfilePath') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get submoduleVariantPath(): string {
|
||||||
|
return Input.getInput('submoduleVariantPath') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get submoduleToken(): string {
|
||||||
|
return Input.getInput('submoduleToken') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get localCacheEnabled(): boolean {
|
||||||
|
return (Input.getInput('localCacheEnabled') ?? 'false') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get localCacheRoot(): string {
|
||||||
|
return Input.getInput('localCacheRoot') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get localCacheLibrary(): boolean {
|
||||||
|
return (Input.getInput('localCacheLibrary') ?? 'true') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get localCacheLfs(): boolean {
|
||||||
|
return (Input.getInput('localCacheLfs') ?? 'false') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get childWorkspacesEnabled(): boolean {
|
||||||
|
return (Input.getInput('childWorkspacesEnabled') ?? 'false') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get childWorkspaceName(): string {
|
||||||
|
return Input.getInput('childWorkspaceName') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get childWorkspaceCacheRoot(): string {
|
||||||
|
return Input.getInput('childWorkspaceCacheRoot') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get childWorkspacePreserveGit(): boolean {
|
||||||
|
return (Input.getInput('childWorkspacePreserveGit') ?? 'true') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get childWorkspaceSeparateLibrary(): boolean {
|
||||||
|
return (Input.getInput('childWorkspaceSeparateLibrary') ?? 'true') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get lfsTransferAgent(): string {
|
||||||
|
return Input.getInput('lfsTransferAgent') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get lfsTransferAgentArgs(): string {
|
||||||
|
return Input.getInput('lfsTransferAgentArgs') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get lfsStoragePaths(): string {
|
||||||
|
return Input.getInput('lfsStoragePaths') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gitHooksEnabled(): boolean {
|
||||||
|
return (Input.getInput('gitHooksEnabled') ?? 'false') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gitHooksSkipList(): string {
|
||||||
|
return Input.getInput('gitHooksSkipList') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gitHooksRunBeforeBuild(): string {
|
||||||
|
return Input.getInput('gitHooksRunBeforeBuild') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get providerExecutable(): string {
|
||||||
|
return Input.getInput('providerExecutable') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gitIntegrityCheck(): boolean {
|
||||||
|
const input = Input.getInput('gitIntegrityCheck') ?? 'false';
|
||||||
|
|
||||||
|
return input === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gitAutoRecover(): boolean {
|
||||||
|
const input = Input.getInput('gitAutoRecover') ?? 'false';
|
||||||
|
|
||||||
|
return input === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get cleanReservedFilenames(): boolean {
|
||||||
|
const input = Input.getInput('cleanReservedFilenames') ?? 'false';
|
||||||
|
|
||||||
|
return input === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get buildArchiveEnabled(): boolean {
|
||||||
|
const input = Input.getInput('buildArchiveEnabled') ?? 'false';
|
||||||
|
|
||||||
|
return input === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get buildArchivePath(): string {
|
||||||
|
return Input.getInput('buildArchivePath') ?? './build-archives';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get buildArchiveRetention(): number {
|
||||||
|
return Number.parseInt(Input.getInput('buildArchiveRetention') ?? '30', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GCP Cloud Run (Experimental)
|
||||||
|
static get gcpProject(): string {
|
||||||
|
return Input.getInput('gcpProject') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gcpRegion(): string {
|
||||||
|
return Input.getInput('gcpRegion') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gcpStorageType(): string {
|
||||||
|
return Input.getInput('gcpStorageType') ?? 'gcs-fuse';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gcpBucket(): string {
|
||||||
|
return Input.getInput('gcpBucket') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gcpFilestoreIp(): string {
|
||||||
|
return Input.getInput('gcpFilestoreIp') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gcpFilestoreShare(): string {
|
||||||
|
return Input.getInput('gcpFilestoreShare') ?? '/share1';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gcpMachineType(): string {
|
||||||
|
return Input.getInput('gcpMachineType') ?? 'e2-standard-4';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gcpDiskSizeGb(): string {
|
||||||
|
return Input.getInput('gcpDiskSizeGb') ?? '100';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gcpServiceAccount(): string {
|
||||||
|
return Input.getInput('gcpServiceAccount') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gcpVpcConnector(): string {
|
||||||
|
return Input.getInput('gcpVpcConnector') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Azure Container Instances (Experimental)
|
||||||
|
static get azureResourceGroup(): string {
|
||||||
|
return Input.getInput('azureResourceGroup') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get azureLocation(): string {
|
||||||
|
return Input.getInput('azureLocation') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get azureStorageType(): string {
|
||||||
|
return Input.getInput('azureStorageType') ?? 'azure-files';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get azureStorageAccount(): string {
|
||||||
|
return Input.getInput('azureStorageAccount') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get azureBlobContainer(): string {
|
||||||
|
return Input.getInput('azureBlobContainer') ?? 'unity-builds';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get azureFileShareName(): string {
|
||||||
|
return Input.getInput('azureFileShareName') ?? 'unity-builds';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get azureSubscriptionId(): string {
|
||||||
|
return Input.getInput('azureSubscriptionId') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get azureCpu(): string {
|
||||||
|
return Input.getInput('azureCpu') ?? '4';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get azureMemoryGb(): string {
|
||||||
|
return Input.getInput('azureMemoryGb') ?? '16';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get azureDiskSizeGb(): string {
|
||||||
|
return Input.getInput('azureDiskSizeGb') ?? '100';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get azureSubnetId(): string {
|
||||||
|
return Input.getInput('azureSubnetId') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ### ### ###
|
||||||
|
// Remote PowerShell provider
|
||||||
|
// ### ### ###
|
||||||
|
|
||||||
|
static get remotePowershellHost(): string {
|
||||||
|
return Input.getInput('remotePowershellHost') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get remotePowershellCredential(): string {
|
||||||
|
return Input.getInput('remotePowershellCredential') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get remotePowershellTransport(): string {
|
||||||
|
return Input.getInput('remotePowershellTransport') ?? 'wsman';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ### ### ###
|
||||||
|
// GitHub Actions provider
|
||||||
|
// ### ### ###
|
||||||
|
|
||||||
|
static get githubActionsRepo(): string {
|
||||||
|
return Input.getInput('githubActionsRepo') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get githubActionsWorkflow(): string {
|
||||||
|
return Input.getInput('githubActionsWorkflow') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get githubActionsToken(): string {
|
||||||
|
return Input.getInput('githubActionsToken') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get githubActionsRef(): string {
|
||||||
|
return Input.getInput('githubActionsRef') ?? 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ### ### ###
|
||||||
|
// GitLab CI provider
|
||||||
|
// ### ### ###
|
||||||
|
|
||||||
|
static get gitlabProjectId(): string {
|
||||||
|
return Input.getInput('gitlabProjectId') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gitlabTriggerToken(): string {
|
||||||
|
return Input.getInput('gitlabTriggerToken') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gitlabApiUrl(): string {
|
||||||
|
return Input.getInput('gitlabApiUrl') ?? 'https://gitlab.com';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get gitlabRef(): string {
|
||||||
|
return Input.getInput('gitlabRef') ?? 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ### ### ###
|
||||||
|
// Ansible provider
|
||||||
|
// ### ### ###
|
||||||
|
|
||||||
|
static get ansibleInventory(): string {
|
||||||
|
return Input.getInput('ansibleInventory') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get ansiblePlaybook(): string {
|
||||||
|
return Input.getInput('ansiblePlaybook') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get ansibleExtraVars(): string {
|
||||||
|
return Input.getInput('ansibleExtraVars') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get ansibleVaultPassword(): string {
|
||||||
|
return Input.getInput('ansibleVaultPassword') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
public static ToEnvVarFormat(input: string) {
|
public static ToEnvVarFormat(input: string) {
|
||||||
if (input.toUpperCase() === input) {
|
if (input.toUpperCase() === input) {
|
||||||
return input;
|
return input;
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
15
src/model/orchestrator/error/orchestrator-error.ts
Normal file
15
src/model/orchestrator/error/orchestrator-error.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/model/orchestrator/options/orchestrator-constants.ts
Normal file
4
src/model/orchestrator/options/orchestrator-constants.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
class OrchestratorConstants {
|
||||||
|
static alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||||
|
}
|
||||||
|
export default OrchestratorConstants;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class OrchestratorEnvironmentVariable {
|
||||||
|
public name!: string;
|
||||||
|
public value!: string;
|
||||||
|
}
|
||||||
|
export default OrchestratorEnvironmentVariable;
|
||||||
140
src/model/orchestrator/options/orchestrator-folders-auth.test.ts
Normal file
140
src/model/orchestrator/options/orchestrator-folders-auth.test.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { OrchestratorFolders } from './orchestrator-folders';
|
||||||
|
|
||||||
|
jest.mock('../orchestrator', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
buildParameters: {
|
||||||
|
orchestratorRepoName: 'game-ci/unity-builder',
|
||||||
|
githubRepo: 'myorg/myrepo',
|
||||||
|
gitPrivateToken: 'ghp_test123',
|
||||||
|
gitAuthMode: 'header',
|
||||||
|
buildGuid: 'test-guid',
|
||||||
|
projectPath: '',
|
||||||
|
buildPath: 'Builds',
|
||||||
|
cacheKey: 'test-cache',
|
||||||
|
},
|
||||||
|
lockedWorkspace: '',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./orchestrator-options', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
useSharedBuilder: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../services/core/orchestrator-system', () => ({
|
||||||
|
OrchestratorSystem: {
|
||||||
|
Run: jest.fn().mockResolvedValue(''),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockOrchestrator = require('../orchestrator').default;
|
||||||
|
|
||||||
|
describe('OrchestratorFolders git auth', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useHeaderAuth', () => {
|
||||||
|
it('should return true when gitAuthMode is header', () => {
|
||||||
|
mockOrchestrator.buildParameters.gitAuthMode = 'header';
|
||||||
|
expect(OrchestratorFolders.useHeaderAuth).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when gitAuthMode is undefined (default)', () => {
|
||||||
|
mockOrchestrator.buildParameters.gitAuthMode = undefined;
|
||||||
|
expect(OrchestratorFolders.useHeaderAuth).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when gitAuthMode is url', () => {
|
||||||
|
mockOrchestrator.buildParameters.gitAuthMode = 'url';
|
||||||
|
expect(OrchestratorFolders.useHeaderAuth).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unityBuilderRepoUrl', () => {
|
||||||
|
it('should not include token in URL when using header auth', () => {
|
||||||
|
mockOrchestrator.buildParameters.gitAuthMode = 'header';
|
||||||
|
const url = OrchestratorFolders.unityBuilderRepoUrl;
|
||||||
|
expect(url).toBe('https://github.com/game-ci/unity-builder.git');
|
||||||
|
expect(url).not.toContain('ghp_test123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include token in URL when using url auth (legacy)', () => {
|
||||||
|
mockOrchestrator.buildParameters.gitAuthMode = 'url';
|
||||||
|
const url = OrchestratorFolders.unityBuilderRepoUrl;
|
||||||
|
expect(url).toBe('https://ghp_test123@github.com/game-ci/unity-builder.git');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('targetBuildRepoUrl', () => {
|
||||||
|
it('should not include token in URL when using header auth', () => {
|
||||||
|
mockOrchestrator.buildParameters.gitAuthMode = 'header';
|
||||||
|
const url = OrchestratorFolders.targetBuildRepoUrl;
|
||||||
|
expect(url).toBe('https://github.com/myorg/myrepo.git');
|
||||||
|
expect(url).not.toContain('ghp_test123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include token in URL when using url auth (legacy)', () => {
|
||||||
|
mockOrchestrator.buildParameters.gitAuthMode = 'url';
|
||||||
|
const url = OrchestratorFolders.targetBuildRepoUrl;
|
||||||
|
expect(url).toBe('https://ghp_test123@github.com/myorg/myrepo.git');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gitAuthConfigScript', () => {
|
||||||
|
it('should emit http.extraHeader commands in header mode', () => {
|
||||||
|
mockOrchestrator.buildParameters.gitAuthMode = 'header';
|
||||||
|
const script = OrchestratorFolders.gitAuthConfigScript;
|
||||||
|
expect(script).toContain('http.extraHeader');
|
||||||
|
expect(script).toContain('GIT_PRIVATE_TOKEN');
|
||||||
|
expect(script).toContain('Authorization: Basic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit no-op comment in url mode', () => {
|
||||||
|
mockOrchestrator.buildParameters.gitAuthMode = 'url';
|
||||||
|
const script = OrchestratorFolders.gitAuthConfigScript;
|
||||||
|
expect(script).toContain('legacy');
|
||||||
|
expect(script).not.toContain('http.extraHeader');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configureGitAuth', () => {
|
||||||
|
it('should run git config with http.extraHeader in header mode', async () => {
|
||||||
|
mockOrchestrator.buildParameters.gitAuthMode = 'header';
|
||||||
|
mockOrchestrator.buildParameters.gitPrivateToken = 'ghp_test123';
|
||||||
|
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
|
||||||
|
|
||||||
|
await OrchestratorFolders.configureGitAuth();
|
||||||
|
|
||||||
|
// Verify the base64 encoding and extraHeader config are correct
|
||||||
|
const expectedEncoded = Buffer.from('x-access-token:ghp_test123').toString('base64');
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining(expectedEncoded));
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('.extraHeader'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not run git config in url mode', async () => {
|
||||||
|
mockOrchestrator.buildParameters.gitAuthMode = 'url';
|
||||||
|
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
|
||||||
|
|
||||||
|
await OrchestratorFolders.configureGitAuth();
|
||||||
|
|
||||||
|
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not run git config when no token is available', async () => {
|
||||||
|
mockOrchestrator.buildParameters.gitAuthMode = 'header';
|
||||||
|
mockOrchestrator.buildParameters.gitPrivateToken = '';
|
||||||
|
const originalEnv = process.env.GIT_PRIVATE_TOKEN;
|
||||||
|
delete process.env.GIT_PRIVATE_TOKEN;
|
||||||
|
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
|
||||||
|
|
||||||
|
await OrchestratorFolders.configureGitAuth();
|
||||||
|
|
||||||
|
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
|
||||||
|
if (originalEnv !== undefined) process.env.GIT_PRIVATE_TOKEN = originalEnv;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
162
src/model/orchestrator/options/orchestrator-folders.test.ts
Normal file
162
src/model/orchestrator/options/orchestrator-folders.test.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { OrchestratorFolders } from './orchestrator-folders';
|
||||||
|
|
||||||
|
// Mock Orchestrator
|
||||||
|
jest.mock('../orchestrator', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
buildParameters: {
|
||||||
|
buildGuid: 'test-guid-abc',
|
||||||
|
cacheKey: 'my-cache-key',
|
||||||
|
projectPath: 'test-project',
|
||||||
|
buildPath: 'Builds',
|
||||||
|
maxRetainedWorkspaces: 0,
|
||||||
|
gitPrivateToken: 'ghp_test123',
|
||||||
|
gitAuthMode: 'url',
|
||||||
|
orchestratorRepoName: 'game-ci/unity-builder',
|
||||||
|
githubRepo: 'user/my-game',
|
||||||
|
},
|
||||||
|
lockedWorkspace: '',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../build-parameters', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
shouldUseRetainedWorkspaceMode: jest.fn().mockReturnValue(false),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./orchestrator-options', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
useSharedBuilder: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Normalize paths for cross-platform test compatibility
|
||||||
|
const normalize = (p: string) => p.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
describe('OrchestratorFolders', () => {
|
||||||
|
describe('static constants', () => {
|
||||||
|
it('repositoryFolder is "repo"', () => {
|
||||||
|
expect(OrchestratorFolders.repositoryFolder).toBe('repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildVolumeFolder is "data"', () => {
|
||||||
|
expect(OrchestratorFolders.buildVolumeFolder).toBe('data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cacheFolder is "cache"', () => {
|
||||||
|
expect(OrchestratorFolders.cacheFolder).toBe('cache');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ToLinuxFolder', () => {
|
||||||
|
it('converts backslashes to forward slashes', () => {
|
||||||
|
expect(OrchestratorFolders.ToLinuxFolder('C:\\Users\\test\\project')).toBe('C:/Users/test/project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves forward slashes', () => {
|
||||||
|
expect(OrchestratorFolders.ToLinuxFolder('/home/user/project')).toBe('/home/user/project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles mixed slashes', () => {
|
||||||
|
expect(OrchestratorFolders.ToLinuxFolder('some/path\\mixed/slashes\\here')).toBe('some/path/mixed/slashes/here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string', () => {
|
||||||
|
expect(OrchestratorFolders.ToLinuxFolder('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('path computations (non-retained workspace mode)', () => {
|
||||||
|
it('uniqueOrchestratorJobFolderAbsolute uses buildGuid', () => {
|
||||||
|
const result = normalize(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute);
|
||||||
|
expect(result).toBe('/data/test-guid-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cacheFolderForAllFull returns /data/cache', () => {
|
||||||
|
const result = normalize(OrchestratorFolders.cacheFolderForAllFull);
|
||||||
|
expect(result).toBe('/data/cache');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cacheFolderForCacheKeyFull includes cache key', () => {
|
||||||
|
const result = normalize(OrchestratorFolders.cacheFolderForCacheKeyFull);
|
||||||
|
expect(result).toBe('/data/cache/my-cache-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repoPathAbsolute is under job folder', () => {
|
||||||
|
const result = normalize(OrchestratorFolders.repoPathAbsolute);
|
||||||
|
expect(result).toBe('/data/test-guid-abc/repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projectPathAbsolute includes project path', () => {
|
||||||
|
const result = normalize(OrchestratorFolders.projectPathAbsolute);
|
||||||
|
expect(result).toBe('/data/test-guid-abc/repo/test-project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('libraryFolderAbsolute is under project path', () => {
|
||||||
|
const result = normalize(OrchestratorFolders.libraryFolderAbsolute);
|
||||||
|
expect(result).toBe('/data/test-guid-abc/repo/test-project/Library');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projectBuildFolderAbsolute uses buildPath', () => {
|
||||||
|
const result = normalize(OrchestratorFolders.projectBuildFolderAbsolute);
|
||||||
|
expect(result).toBe('/data/test-guid-abc/repo/Builds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lfsFolderAbsolute is under .git/lfs', () => {
|
||||||
|
const result = normalize(OrchestratorFolders.lfsFolderAbsolute);
|
||||||
|
expect(result).toBe('/data/test-guid-abc/repo/.git/lfs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lfsCacheFolderFull is under cache key', () => {
|
||||||
|
const result = normalize(OrchestratorFolders.lfsCacheFolderFull);
|
||||||
|
expect(result).toBe('/data/cache/my-cache-key/lfs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('libraryCacheFolderFull is under cache key', () => {
|
||||||
|
const result = normalize(OrchestratorFolders.libraryCacheFolderFull);
|
||||||
|
expect(result).toBe('/data/cache/my-cache-key/Library');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('builderPathAbsolute', () => {
|
||||||
|
it('uses job folder when shared builder is disabled', () => {
|
||||||
|
const result = normalize(OrchestratorFolders.builderPathAbsolute);
|
||||||
|
expect(result).toBe('/data/test-guid-abc/builder');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repo URLs', () => {
|
||||||
|
it('unityBuilderRepoUrl includes token and repo name', () => {
|
||||||
|
const url = OrchestratorFolders.unityBuilderRepoUrl;
|
||||||
|
expect(url).toBe('https://ghp_test123@github.com/game-ci/unity-builder.git');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('targetBuildRepoUrl includes token and github repo', () => {
|
||||||
|
const url = OrchestratorFolders.targetBuildRepoUrl;
|
||||||
|
expect(url).toBe('https://ghp_test123@github.com/user/my-game.git');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purgeRemoteCaching', () => {
|
||||||
|
it('returns false when env var is not set', () => {
|
||||||
|
const original = process.env.PURGE_REMOTE_BUILDER_CACHE;
|
||||||
|
delete process.env.PURGE_REMOTE_BUILDER_CACHE;
|
||||||
|
expect(OrchestratorFolders.purgeRemoteCaching).toBe(false);
|
||||||
|
if (original !== undefined) process.env.PURGE_REMOTE_BUILDER_CACHE = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when env var is set', () => {
|
||||||
|
const original = process.env.PURGE_REMOTE_BUILDER_CACHE;
|
||||||
|
process.env.PURGE_REMOTE_BUILDER_CACHE = 'true';
|
||||||
|
expect(OrchestratorFolders.purgeRemoteCaching).toBe(true);
|
||||||
|
if (original !== undefined) {
|
||||||
|
process.env.PURGE_REMOTE_BUILDER_CACHE = original;
|
||||||
|
} else {
|
||||||
|
delete process.env.PURGE_REMOTE_BUILDER_CACHE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
143
src/model/orchestrator/options/orchestrator-folders.ts
Normal file
143
src/model/orchestrator/options/orchestrator-folders.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to use http.extraHeader for git authentication (secure, default)
|
||||||
|
* instead of embedding the token in clone URLs (legacy).
|
||||||
|
*/
|
||||||
|
public static get useHeaderAuth(): boolean {
|
||||||
|
return Orchestrator.buildParameters.gitAuthMode !== 'url';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get unityBuilderRepoUrl(): string {
|
||||||
|
if (OrchestratorFolders.useHeaderAuth) {
|
||||||
|
return `https://github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get targetBuildRepoUrl(): string {
|
||||||
|
if (OrchestratorFolders.useHeaderAuth) {
|
||||||
|
return `https://github.com/${Orchestrator.buildParameters.githubRepo}.git`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.githubRepo}.git`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell commands to configure git authentication via http.extraHeader.
|
||||||
|
* Uses GIT_PRIVATE_TOKEN env var so the token never appears in clone URLs or git config output.
|
||||||
|
* This is the same mechanism used by actions/checkout.
|
||||||
|
*
|
||||||
|
* Only emits commands when gitAuthMode is 'header' (default). In 'url' mode,
|
||||||
|
* returns a no-op comment since the token is already in the URL.
|
||||||
|
*/
|
||||||
|
public static get gitAuthConfigScript(): string {
|
||||||
|
if (!OrchestratorFolders.useHeaderAuth) {
|
||||||
|
return `# git auth: using token-in-URL mode (legacy)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `# git auth: configuring http.extraHeader (secure mode)
|
||||||
|
if [ -n "$GIT_PRIVATE_TOKEN" ]; then
|
||||||
|
git config --global http.https://github.com/.extraHeader "Authorization: Basic $(printf '%s' "x-access-token:$GIT_PRIVATE_TOKEN" | base64 -w 0)"
|
||||||
|
fi`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure git authentication via http.extraHeader in the current Node process.
|
||||||
|
* For use in the remote-client where shell scripts aren't used.
|
||||||
|
* Only configures when gitAuthMode is 'header' (default).
|
||||||
|
*/
|
||||||
|
public static async configureGitAuth(): Promise<void> {
|
||||||
|
if (!OrchestratorFolders.useHeaderAuth) return;
|
||||||
|
|
||||||
|
const token = Orchestrator.buildParameters.gitPrivateToken || process.env.GIT_PRIVATE_TOKEN || '';
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
|
||||||
|
const { OrchestratorSystem } = await import('../services/core/orchestrator-system');
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get buildVolumeFolder() {
|
||||||
|
return 'data';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get cacheFolder() {
|
||||||
|
return 'cache';
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/model/orchestrator/options/orchestrator-guid.test.ts
Normal file
53
src/model/orchestrator/options/orchestrator-guid.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import OrchestratorNamespace from './orchestrator-guid';
|
||||||
|
|
||||||
|
describe('OrchestratorNamespace', () => {
|
||||||
|
describe('generateGuid', () => {
|
||||||
|
it('generates a guid with correct format', () => {
|
||||||
|
const guid = OrchestratorNamespace.generateGuid('42', 'StandaloneLinux64');
|
||||||
|
// Format: {runNumber}-{platform}-{nanoid4}
|
||||||
|
expect(guid).toMatch(/^42-linux64-[a-z0-9]{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips "standalone" prefix from platform (case-insensitive)', () => {
|
||||||
|
const guid = OrchestratorNamespace.generateGuid('1', 'StandaloneWindows64');
|
||||||
|
expect(guid).toMatch(/^1-windows64-[a-z0-9]{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lowercases platform name', () => {
|
||||||
|
const guid = OrchestratorNamespace.generateGuid('5', 'Android');
|
||||||
|
expect(guid).toMatch(/^5-android-[a-z0-9]{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles numeric run number', () => {
|
||||||
|
const guid = OrchestratorNamespace.generateGuid(100, 'iOS');
|
||||||
|
expect(guid).toMatch(/^100-ios-[a-z0-9]{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates unique guids on repeated calls', () => {
|
||||||
|
const guids = new Set<string>();
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
guids.add(OrchestratorNamespace.generateGuid('1', 'StandaloneLinux64'));
|
||||||
|
}
|
||||||
|
// With 4 alphanumeric chars (36^4 = ~1.7M possibilities), 20 calls should almost certainly be unique
|
||||||
|
expect(guids.size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles StandaloneOSX platform', () => {
|
||||||
|
const guid = OrchestratorNamespace.generateGuid('7', 'StandaloneOSX');
|
||||||
|
expect(guid).toMatch(/^7-osx-[a-z0-9]{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles WebGL platform (no standalone prefix)', () => {
|
||||||
|
const guid = OrchestratorNamespace.generateGuid('3', 'WebGL');
|
||||||
|
expect(guid).toMatch(/^3-webgl-[a-z0-9]{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses only lowercase alphanumeric characters in nanoid portion', () => {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const guid = OrchestratorNamespace.generateGuid('1', 'test');
|
||||||
|
const nanoidPart = guid.split('-').pop()!;
|
||||||
|
expect(nanoidPart).toMatch(/^[0-9a-z]{4}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
11
src/model/orchestrator/options/orchestrator-guid.ts
Normal file
11
src/model/orchestrator/options/orchestrator-guid.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import Input from '../../input';
|
||||||
|
import OrchestratorOptions from './orchestrator-options';
|
||||||
|
|
||||||
|
class OrchestratorOptionsReader {
|
||||||
|
static GetProperties() {
|
||||||
|
return [...Object.getOwnPropertyNames(Input), ...Object.getOwnPropertyNames(OrchestratorOptions)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrchestratorOptionsReader;
|
||||||
372
src/model/orchestrator/options/orchestrator-options.ts
Normal file
372
src/model/orchestrator/options/orchestrator-options.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
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 gitAuthMode(): string {
|
||||||
|
return OrchestratorOptions.getInput('gitAuthMode') || 'header';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get fallbackProviderStrategy(): string {
|
||||||
|
return OrchestratorOptions.getInput('fallbackProviderStrategy') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get runnerCheckEnabled(): boolean {
|
||||||
|
return OrchestratorOptions.getInput('runnerCheckEnabled') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get runnerCheckLabels(): string[] {
|
||||||
|
const labels = OrchestratorOptions.getInput('runnerCheckLabels');
|
||||||
|
|
||||||
|
return labels ? labels.split(',').map((l) => l.trim()) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get runnerCheckMinAvailable(): number {
|
||||||
|
return Number(OrchestratorOptions.getInput('runnerCheckMinAvailable')) || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get retryOnFallback(): boolean {
|
||||||
|
return OrchestratorOptions.getInput('retryOnFallback') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get providerInitTimeout(): number {
|
||||||
|
return Number(OrchestratorOptions.getInput('providerInitTimeout')) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 secretSource(): string {
|
||||||
|
return OrchestratorOptions.getInput('secretSource') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
116
src/model/orchestrator/options/orchestrator-query-override.ts
Normal file
116
src/model/orchestrator/options/orchestrator-query-override.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import * as core from '@actions/core';
|
||||||
|
import Input from '../../input';
|
||||||
|
import { GenericInputReader } from '../../input-readers/generic-input-reader';
|
||||||
|
import OrchestratorOptions from './orchestrator-options';
|
||||||
|
import { SecretSourceService, validateSecretKey } from '../services/secrets/secret-source-service';
|
||||||
|
import OrchestratorLogger from '../services/core/orchestrator-logger';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the query key before interpolating it into a shell command
|
||||||
|
validateSecretKey(query);
|
||||||
|
|
||||||
|
const result = await GenericInputReader.Run(
|
||||||
|
formatFunction(OrchestratorOptions.inputPullCommand, [{ key: 0, value: query }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mask the fetched secret value so it does not appear in GitHub Actions logs
|
||||||
|
if (result && result.trim().length > 0) {
|
||||||
|
core.setSecret(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate query overrides using either:
|
||||||
|
* 1. Premade/custom secret sources (via secretSource input), or
|
||||||
|
* 2. Shell command (via inputPullCommand, legacy approach)
|
||||||
|
*
|
||||||
|
* The secretSource input takes precedence if set. It supports:
|
||||||
|
* - Premade names: 'aws-secrets-manager', 'aws-parameter-store', 'gcp-secret-manager', 'azure-key-vault', 'env'
|
||||||
|
* - Custom commands: any string containing {0} placeholder
|
||||||
|
* - YAML file path: a path ending in .yml or .yaml containing custom source definitions
|
||||||
|
*/
|
||||||
|
public static async PopulateQueryOverrideInput() {
|
||||||
|
const queries = OrchestratorOptions.pullInputList;
|
||||||
|
OrchestratorQueryOverride.queryOverrides = {};
|
||||||
|
|
||||||
|
const secretSource = OrchestratorOptions.secretSource;
|
||||||
|
|
||||||
|
// Use SecretSourceService if secretSource is configured
|
||||||
|
if (secretSource) {
|
||||||
|
OrchestratorLogger.log(`Using secret source: ${secretSource}`);
|
||||||
|
|
||||||
|
// YAML file: load definitions and use the first source
|
||||||
|
if (secretSource.endsWith('.yml') || secretSource.endsWith('.yaml')) {
|
||||||
|
const definitions = SecretSourceService.loadFromYaml(secretSource);
|
||||||
|
if (definitions.length > 0) {
|
||||||
|
OrchestratorLogger.log(`Loaded ${definitions.length} secret source(s) from ${secretSource}`);
|
||||||
|
for (const key of queries) {
|
||||||
|
OrchestratorQueryOverride.queryOverrides[key] = await SecretSourceService.fetchSecret(definitions[0], key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Premade or custom command source
|
||||||
|
const results = await SecretSourceService.fetchAll(secretSource, queries);
|
||||||
|
Object.assign(OrchestratorQueryOverride.queryOverrides, results);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy: use inputPullCommand if set
|
||||||
|
for (const element of queries) {
|
||||||
|
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
|
||||||
|
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default OrchestratorQueryOverride;
|
||||||
6
src/model/orchestrator/options/orchestrator-secret.ts
Normal file
6
src/model/orchestrator/options/orchestrator-secret.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class OrchestratorSecret {
|
||||||
|
public ParameterKey!: string;
|
||||||
|
public EnvironmentVariable!: string;
|
||||||
|
public ParameterValue!: string;
|
||||||
|
}
|
||||||
|
export default OrchestratorSecret;
|
||||||
3
src/model/orchestrator/options/orchestrator-statics.ts
Normal file
3
src/model/orchestrator/options/orchestrator-statics.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export class OrchestratorStatics {
|
||||||
|
public static readonly logPrefix = `Orchestrator`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
473
src/model/orchestrator/orchestrator.ts
Normal file
473
src/model/orchestrator/orchestrator.ts
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
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 GcpCloudRunProvider from './providers/gcp-cloud-run';
|
||||||
|
import AzureAciProvider from './providers/azure-aci';
|
||||||
|
import RemotePowershellProvider from './providers/remote-powershell';
|
||||||
|
import GitHubActionsProvider from './providers/github-actions';
|
||||||
|
import GitLabCIProvider from './providers/gitlab-ci';
|
||||||
|
import AnsibleProvider from './providers/ansible';
|
||||||
|
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';
|
||||||
|
import { RunnerAvailabilityService } from './services/core/runner-availability-service';
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
// Check runner availability and apply fallback if needed
|
||||||
|
if (Orchestrator.buildParameters.runnerCheckEnabled && Orchestrator.buildParameters.fallbackProviderStrategy) {
|
||||||
|
const owner = OrchestratorOptions.githubOwner;
|
||||||
|
const repo = OrchestratorOptions.githubRepoName;
|
||||||
|
const token = Orchestrator.buildParameters.gitPrivateToken || process.env.GITHUB_TOKEN || '';
|
||||||
|
|
||||||
|
OrchestratorLogger.log(
|
||||||
|
`Checking runner availability (labels: [${Orchestrator.buildParameters.runnerCheckLabels.join(', ')}], min: ${
|
||||||
|
Orchestrator.buildParameters.runnerCheckMinAvailable
|
||||||
|
})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
token,
|
||||||
|
Orchestrator.buildParameters.runnerCheckLabels,
|
||||||
|
Orchestrator.buildParameters.runnerCheckMinAvailable,
|
||||||
|
);
|
||||||
|
|
||||||
|
OrchestratorLogger.log(
|
||||||
|
`Runner check: ${result.totalRunners} total, ${result.matchingRunners} matching, ${result.idleRunners} idle — ${result.reason}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.shouldFallback) {
|
||||||
|
const original = Orchestrator.buildParameters.providerStrategy;
|
||||||
|
const fallback = Orchestrator.buildParameters.fallbackProviderStrategy;
|
||||||
|
OrchestratorLogger.log(`Falling back from '${original}' to '${fallback}' — ${result.reason}`);
|
||||||
|
Orchestrator.buildParameters.providerStrategy = fallback;
|
||||||
|
core.setOutput('providerFallbackUsed', 'true');
|
||||||
|
core.setOutput('providerFallbackReason', result.reason);
|
||||||
|
} else {
|
||||||
|
core.setOutput('providerFallbackUsed', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Check for CLI provider executable
|
||||||
|
if (Orchestrator.buildParameters.providerExecutable) {
|
||||||
|
const { default: CliProvider } = await import('./providers/cli');
|
||||||
|
Orchestrator.Provider = new CliProvider(
|
||||||
|
Orchestrator.buildParameters.providerExecutable,
|
||||||
|
Orchestrator.buildParameters,
|
||||||
|
);
|
||||||
|
OrchestratorLogger.log(`Using CLI provider executable: ${Orchestrator.buildParameters.providerExecutable}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
case 'gcp-cloud-run':
|
||||||
|
OrchestratorLogger.log('⚠ EXPERIMENTAL: GCP Cloud Run Jobs provider');
|
||||||
|
Orchestrator.Provider = new GcpCloudRunProvider(Orchestrator.buildParameters);
|
||||||
|
break;
|
||||||
|
case 'azure-aci':
|
||||||
|
OrchestratorLogger.log('⚠ EXPERIMENTAL: Azure Container Instances provider');
|
||||||
|
Orchestrator.Provider = new AzureAciProvider(Orchestrator.buildParameters);
|
||||||
|
case 'remote-powershell':
|
||||||
|
Orchestrator.Provider = new RemotePowershellProvider(Orchestrator.buildParameters);
|
||||||
|
break;
|
||||||
|
case 'github-actions':
|
||||||
|
Orchestrator.Provider = new GitHubActionsProvider(Orchestrator.buildParameters);
|
||||||
|
break;
|
||||||
|
case 'gitlab-ci':
|
||||||
|
Orchestrator.Provider = new GitLabCIProvider(Orchestrator.buildParameters);
|
||||||
|
break;
|
||||||
|
case 'ansible':
|
||||||
|
Orchestrator.Provider = new AnsibleProvider(Orchestrator.buildParameters);
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Orchestrator.runWithProvider(buildParameters, baseImage);
|
||||||
|
} catch (primaryError: any) {
|
||||||
|
// Retry on fallback provider if enabled and a fallback is configured
|
||||||
|
const fallback = buildParameters.fallbackProviderStrategy;
|
||||||
|
const alreadyOnFallback = buildParameters.providerStrategy === fallback;
|
||||||
|
if (buildParameters.retryOnFallback && fallback && !alreadyOnFallback) {
|
||||||
|
OrchestratorLogger.log(
|
||||||
|
`Primary provider '${buildParameters.providerStrategy}' failed: ${primaryError.message}`,
|
||||||
|
);
|
||||||
|
OrchestratorLogger.log(`Retrying build on fallback provider '${fallback}'...`);
|
||||||
|
buildParameters.providerStrategy = fallback;
|
||||||
|
core.setOutput('providerFallbackUsed', 'true');
|
||||||
|
core.setOutput('providerFallbackReason', `Primary provider failed: ${primaryError.message}`);
|
||||||
|
|
||||||
|
return await Orchestrator.runWithProvider(buildParameters, baseImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw primaryError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async runWithProvider(buildParameters: BuildParameters, baseImage: string) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup workflow with optional init timeout
|
||||||
|
await Orchestrator.setupWorkflowWithTimeout();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs setupWorkflow with an optional timeout. If providerInitTimeout is set and the
|
||||||
|
* provider takes longer than that to initialize, throws an error that triggers
|
||||||
|
* retry-on-fallback (if enabled).
|
||||||
|
*/
|
||||||
|
private static async setupWorkflowWithTimeout() {
|
||||||
|
const timeoutSeconds = Orchestrator.buildParameters.providerInitTimeout;
|
||||||
|
|
||||||
|
const setupPromise = Orchestrator.Provider.setupWorkflow(
|
||||||
|
Orchestrator.buildParameters.buildGuid,
|
||||||
|
Orchestrator.buildParameters,
|
||||||
|
Orchestrator.buildParameters.branch,
|
||||||
|
Orchestrator.defaultSecrets,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (timeoutSeconds <= 0) {
|
||||||
|
await setupPromise;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OrchestratorLogger.log(`Provider init timeout: ${timeoutSeconds}s`);
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error(`Provider initialization timed out after ${timeoutSeconds}s`)),
|
||||||
|
timeoutSeconds * 1000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.race([setupPromise, timeoutPromise]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
222
src/model/orchestrator/providers/README.md
Normal file
222
src/model/orchestrator/providers/README.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import AnsibleProvider from '.';
|
||||||
|
import BuildParameters from '../../../build-parameters';
|
||||||
|
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
|
||||||
|
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||||
|
import * as core from '@actions/core';
|
||||||
|
|
||||||
|
jest.mock('../../services/core/orchestrator-system');
|
||||||
|
jest.mock('../../services/core/orchestrator-logger');
|
||||||
|
jest.mock('@actions/core', () => ({
|
||||||
|
info: jest.fn(),
|
||||||
|
warning: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
setOutput: jest.fn(),
|
||||||
|
getInput: jest.fn(() => ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
|
||||||
|
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
|
||||||
|
const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction<typeof OrchestratorLogger.logWarning>;
|
||||||
|
|
||||||
|
function createBuildParameters(overrides: Partial<BuildParameters> = {}): BuildParameters {
|
||||||
|
return {
|
||||||
|
ansibleInventory: '/etc/ansible/hosts',
|
||||||
|
ansiblePlaybook: '/playbooks/unity-build.yml',
|
||||||
|
ansibleExtraVars: '',
|
||||||
|
ansibleVaultPassword: '',
|
||||||
|
...overrides,
|
||||||
|
} as BuildParameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AnsibleProvider', () => {
|
||||||
|
let provider: AnsibleProvider;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
provider = new AnsibleProvider(createBuildParameters());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('initializes with all provided parameters', () => {
|
||||||
|
const params = createBuildParameters({
|
||||||
|
ansibleInventory: '/custom/inventory',
|
||||||
|
ansiblePlaybook: '/custom/playbook.yml',
|
||||||
|
ansibleExtraVars: '{"key":"value"}',
|
||||||
|
ansibleVaultPassword: '/vault/pass',
|
||||||
|
});
|
||||||
|
const p = new AnsibleProvider(params);
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing optional parameters gracefully', () => {
|
||||||
|
const params = createBuildParameters({
|
||||||
|
ansiblePlaybook: undefined,
|
||||||
|
ansibleExtraVars: undefined,
|
||||||
|
ansibleVaultPassword: undefined,
|
||||||
|
});
|
||||||
|
const p = new AnsibleProvider(params);
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setupWorkflow', () => {
|
||||||
|
it('verifies ansible binary, ansible-playbook binary, and inventory exist', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible --version
|
||||||
|
mockRun.mockResolvedValueOnce('/usr/bin/ansible-playbook'); // ansible-playbook check
|
||||||
|
mockRun.mockResolvedValueOnce(''); // test -e inventory
|
||||||
|
|
||||||
|
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
|
||||||
|
|
||||||
|
expect(mockRun).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockRun.mock.calls[0][0]).toContain('ansible --version');
|
||||||
|
expect(mockRun.mock.calls[1][0]).toContain('ansible-playbook');
|
||||||
|
expect(mockRun.mock.calls[2][0]).toContain('test -e "/etc/ansible/hosts"');
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ansible'));
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ansible-playbook binary verified'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when inventory is not configured', async () => {
|
||||||
|
const params = createBuildParameters({ ansibleInventory: '' });
|
||||||
|
provider = new AnsibleProvider(params);
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
|
||||||
|
'ansibleInventory is required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when ansible binary is not found on PATH', async () => {
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('command not found: ansible'));
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
|
||||||
|
'Ansible not found on PATH',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when ansible-playbook binary is not found', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible version OK
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('command not found')); // ansible-playbook missing
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
|
||||||
|
'ansible-playbook not found on PATH',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(core.error).toHaveBeenCalledWith('ansible-playbook not found. Install Ansible or ensure it is in PATH.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when inventory file does not exist', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible version OK
|
||||||
|
mockRun.mockResolvedValueOnce('/usr/bin/ansible-playbook'); // ansible-playbook OK
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('test -e failed')); // inventory missing
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
|
||||||
|
'Inventory not found: /etc/ansible/hosts',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runTaskInWorkflow', () => {
|
||||||
|
it('constructs ansible-playbook command with correct variables and returns output', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('PLAY [build] *****\nok: [server1]\nPLAY RECAP');
|
||||||
|
|
||||||
|
const result = await provider.runTaskInWorkflow(
|
||||||
|
'guid-run1',
|
||||||
|
'unityci/editor:2021.3',
|
||||||
|
'echo build',
|
||||||
|
'/mount',
|
||||||
|
'/workspace',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain('PLAY [build]');
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain('ansible-playbook');
|
||||||
|
expect(command).toContain('-i "/etc/ansible/hosts"');
|
||||||
|
expect(command).toContain('"/playbooks/unity-build.yml"');
|
||||||
|
expect(command).toContain('--no-color');
|
||||||
|
expect(command).toContain('build_guid');
|
||||||
|
expect(command).toContain('guid-run1');
|
||||||
|
expect(command).toContain('build_image');
|
||||||
|
expect(command).toContain('unityci/editor:2021.3');
|
||||||
|
expect(command).toContain('build_commands');
|
||||||
|
expect(command).toContain('mount_dir');
|
||||||
|
expect(command).toContain('working_dir');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when playbook is not configured', async () => {
|
||||||
|
const params = createBuildParameters({ ansiblePlaybook: '' });
|
||||||
|
provider = new AnsibleProvider(params);
|
||||||
|
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-nopb', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'ansiblePlaybook is required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes environment variables as extra-vars in snake_case', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('ok');
|
||||||
|
|
||||||
|
const env = [
|
||||||
|
{ name: 'UNITY_LICENSE', value: 'lic-data' },
|
||||||
|
{ name: 'BUILD_TARGET', value: 'Linux64' },
|
||||||
|
];
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-env', 'img', 'cmd', '/m', '/w', env as any, []);
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
// Environment variable names are lowercased as Ansible variables
|
||||||
|
expect(command).toContain('unity_license');
|
||||||
|
expect(command).toContain('lic-data');
|
||||||
|
expect(command).toContain('build_target');
|
||||||
|
expect(command).toContain('Linux64');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges user-provided extra vars from JSON string', async () => {
|
||||||
|
const params = createBuildParameters({
|
||||||
|
ansibleExtraVars: JSON.stringify({ custom_var: 'custom_value', another: '42' }),
|
||||||
|
});
|
||||||
|
provider = new AnsibleProvider(params);
|
||||||
|
mockRun.mockResolvedValueOnce('ok');
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-extra', 'img', 'cmd', '/m', '/w', [], []);
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain('custom_var');
|
||||||
|
expect(command).toContain('custom_value');
|
||||||
|
expect(command).toContain('another');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs warning when extra vars JSON is invalid but continues', async () => {
|
||||||
|
const params = createBuildParameters({ ansibleExtraVars: 'not-valid-json{{{' });
|
||||||
|
provider = new AnsibleProvider(params);
|
||||||
|
mockRun.mockResolvedValueOnce('ok');
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-badjson', 'img', 'cmd', '/m', '/w', [], []);
|
||||||
|
|
||||||
|
expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Failed to parse ansibleExtraVars'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes vault password file flag when configured', async () => {
|
||||||
|
const params = createBuildParameters({ ansibleVaultPassword: '/secure/vault-pass.txt' });
|
||||||
|
provider = new AnsibleProvider(params);
|
||||||
|
mockRun.mockResolvedValueOnce('ok');
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-vault', 'img', 'cmd', '/m', '/w', [], []);
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain('--vault-password-file "/secure/vault-pass.txt"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include vault password flag when not configured', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('ok');
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-novault', 'img', 'cmd', '/m', '/w', [], []);
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).not.toContain('--vault-password-file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefixes secrets as environment variables in the command', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('ok');
|
||||||
|
|
||||||
|
const secrets = [
|
||||||
|
{ ParameterKey: 'key1', EnvironmentVariable: 'SECRET_TOKEN', ParameterValue: 'tok-abc' },
|
||||||
|
{ ParameterKey: 'key2', EnvironmentVariable: 'DEPLOY_KEY', ParameterValue: 'dk-xyz' },
|
||||||
|
];
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-secrets', 'img', 'cmd', '/m', '/w', [], secrets as any);
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toMatch(/^SECRET_TOKEN='tok-abc'/);
|
||||||
|
expect(command).toContain("DEPLOY_KEY='dk-xyz'");
|
||||||
|
expect(command).toContain('ansible-playbook');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws and logs warning when playbook execution fails', async () => {
|
||||||
|
const execError = new Error('UNREACHABLE! Host unreachable');
|
||||||
|
mockRun.mockRejectedValueOnce(execError);
|
||||||
|
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-hostfail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'UNREACHABLE',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Playbook failed'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanupWorkflow', () => {
|
||||||
|
it('completes without error and logs cleanup message', async () => {
|
||||||
|
await provider.cleanupWorkflow(createBuildParameters(), 'main', []);
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleanup complete'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('garbageCollect', () => {
|
||||||
|
it('returns empty string (no-op)', async () => {
|
||||||
|
const result = await provider.garbageCollect('', false, 0, false, false);
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listResources', () => {
|
||||||
|
it('returns inventory path as a resource when configured', async () => {
|
||||||
|
const resources = await provider.listResources();
|
||||||
|
|
||||||
|
expect(resources).toHaveLength(1);
|
||||||
|
expect(resources[0].Name).toBe('/etc/ansible/hosts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when inventory is not configured', async () => {
|
||||||
|
const params = createBuildParameters({ ansibleInventory: '' });
|
||||||
|
provider = new AnsibleProvider(params);
|
||||||
|
|
||||||
|
const resources = await provider.listResources();
|
||||||
|
expect(resources).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listWorkflow', () => {
|
||||||
|
it('returns empty array (not implemented)', async () => {
|
||||||
|
const workflows = await provider.listWorkflow();
|
||||||
|
expect(workflows).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('watchWorkflow', () => {
|
||||||
|
it('returns empty string (not implemented)', async () => {
|
||||||
|
const result = await provider.watchWorkflow();
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
197
src/model/orchestrator/providers/ansible/index.ts
Normal file
197
src/model/orchestrator/providers/ansible/index.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import * as core from '@actions/core';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ansible provider — executes Unity builds via Ansible playbooks
|
||||||
|
* against managed inventory.
|
||||||
|
*
|
||||||
|
* Use case: Teams with existing Ansible infrastructure for server
|
||||||
|
* management who want to leverage their inventory for build distribution.
|
||||||
|
*/
|
||||||
|
class AnsibleProvider implements ProviderInterface {
|
||||||
|
private buildParameters: BuildParameters;
|
||||||
|
private inventory: string;
|
||||||
|
private playbook: string;
|
||||||
|
private extraVariables: string;
|
||||||
|
private vaultPassword: string;
|
||||||
|
|
||||||
|
constructor(buildParameters: BuildParameters) {
|
||||||
|
this.buildParameters = buildParameters;
|
||||||
|
this.inventory = buildParameters.ansibleInventory || '';
|
||||||
|
this.playbook = buildParameters.ansiblePlaybook || '';
|
||||||
|
this.extraVariables = buildParameters.ansibleExtraVars || '';
|
||||||
|
this.vaultPassword = buildParameters.ansibleVaultPassword || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }[],
|
||||||
|
): Promise<void> {
|
||||||
|
OrchestratorLogger.log(`[Ansible] Setting up playbook execution`);
|
||||||
|
|
||||||
|
if (!this.inventory) {
|
||||||
|
throw new Error('ansibleInventory is required for the ansible provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ansible is available
|
||||||
|
try {
|
||||||
|
const version = await OrchestratorSystem.Run('ansible --version | head -1');
|
||||||
|
OrchestratorLogger.log(`[Ansible] ${version.trim()}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Ansible not found on PATH: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ansible-playbook binary exists (may be separate from ansible)
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run('command -v ansible-playbook || which ansible-playbook || where ansible-playbook');
|
||||||
|
OrchestratorLogger.log(`[Ansible] ansible-playbook binary verified`);
|
||||||
|
} catch (error: any) {
|
||||||
|
core.error('ansible-playbook not found. Install Ansible or ensure it is in PATH.');
|
||||||
|
throw new Error(`ansible-playbook not found on PATH: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify inventory exists
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(`test -e "${this.inventory}"`);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Inventory not found: ${this.inventory}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runTaskInWorkflow(
|
||||||
|
buildGuid: string,
|
||||||
|
image: string,
|
||||||
|
commands: string,
|
||||||
|
mountdir: string,
|
||||||
|
workingdir: string,
|
||||||
|
environment: OrchestratorEnvironmentVariable[],
|
||||||
|
secrets: OrchestratorSecret[],
|
||||||
|
): Promise<string> {
|
||||||
|
OrchestratorLogger.log(`[Ansible] Running playbook against inventory ${this.inventory}`);
|
||||||
|
|
||||||
|
if (!this.playbook) {
|
||||||
|
throw new Error(
|
||||||
|
'ansiblePlaybook is required — no default playbook is provided yet. ' +
|
||||||
|
'Provide a playbook that accepts build_guid, build_image, build_commands, mount_dir, and working_dir variables.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build extra-vars JSON
|
||||||
|
// These use snake_case because they are Ansible variable names passed to playbooks
|
||||||
|
const playbookVariables: Record<string, string> = {
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
build_guid: buildGuid,
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
build_image: image,
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
build_commands: commands,
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
mount_dir: mountdir,
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
working_dir: workingdir,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const element of environment) {
|
||||||
|
playbookVariables[element.name.toLowerCase()] = element.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge user-provided extra vars
|
||||||
|
if (this.extraVariables) {
|
||||||
|
try {
|
||||||
|
const userVariables = JSON.parse(this.extraVariables);
|
||||||
|
Object.assign(playbookVariables, userVariables);
|
||||||
|
} catch {
|
||||||
|
OrchestratorLogger.logWarning(`[Ansible] Failed to parse ansibleExtraVars as JSON, using as-is`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraVariablesJson = JSON.stringify(playbookVariables).replace(/'/g, "'\\''");
|
||||||
|
|
||||||
|
// Build ansible-playbook command
|
||||||
|
const commandParts = [
|
||||||
|
'ansible-playbook',
|
||||||
|
`-i "${this.inventory}"`,
|
||||||
|
`"${this.playbook}"`,
|
||||||
|
`-e '${extraVariablesJson}'`,
|
||||||
|
'--no-color',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.vaultPassword) {
|
||||||
|
commandParts.push(`--vault-password-file "${this.vaultPassword}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add secret variables as extra environment
|
||||||
|
const environmentPrefix = secrets
|
||||||
|
.map((secret) => `${secret.EnvironmentVariable}='${secret.ParameterValue}'`)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const fullCommand = environmentPrefix ? `${environmentPrefix} ${commandParts.join(' ')}` : commandParts.join(' ');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await OrchestratorSystem.Run(fullCommand);
|
||||||
|
OrchestratorLogger.log(`[Ansible] Playbook completed successfully`);
|
||||||
|
|
||||||
|
return output;
|
||||||
|
} catch (error: any) {
|
||||||
|
OrchestratorLogger.logWarning(`[Ansible] Playbook failed: ${error.message || error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }[],
|
||||||
|
): Promise<void> {
|
||||||
|
OrchestratorLogger.log(`[Ansible] Cleanup complete`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async 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 '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResources(): Promise<ProviderResource[]> {
|
||||||
|
if (!this.inventory) return [];
|
||||||
|
|
||||||
|
const resource = new ProviderResource();
|
||||||
|
resource.Name = this.inventory;
|
||||||
|
|
||||||
|
return [resource];
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWorkflow(): Promise<ProviderWorkflow[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async watchWorkflow(): Promise<string> {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default AnsibleProvider;
|
||||||
170
src/model/orchestrator/providers/aws/aws-base-stack.ts
Normal file
170
src/model/orchestrator/providers/aws/aws-base-stack.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/model/orchestrator/providers/aws/aws-client-factory.ts
Normal file
93
src/model/orchestrator/providers/aws/aws-client-factory.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/model/orchestrator/providers/aws/aws-error.ts
Normal file
16
src/model/orchestrator/providers/aws/aws-error.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
242
src/model/orchestrator/providers/aws/aws-job-stack.ts
Normal file
242
src/model/orchestrator/providers/aws/aws-job-stack.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
335
src/model/orchestrator/providers/aws/aws-task-runner.ts
Normal file
335
src/model/orchestrator/providers/aws/aws-task-runner.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
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
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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'
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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
|
||||||
|
`;
|
||||||
|
}
|
||||||
176
src/model/orchestrator/providers/aws/index.ts
Normal file
176
src/model/orchestrator/providers/aws/index.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// 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;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src/model/orchestrator/providers/aws/services/task-service.ts
Normal file
220
src/model/orchestrator/providers/aws/services/task-service.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
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 || '' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
536
src/model/orchestrator/providers/azure-aci/index.ts
Normal file
536
src/model/orchestrator/providers/azure-aci/index.ts
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
/**
|
||||||
|
* Azure Container Instances (ACI) Provider (Experimental)
|
||||||
|
*
|
||||||
|
* Executes Unity builds as Azure Container Instances with configurable storage backends.
|
||||||
|
*
|
||||||
|
* Storage types:
|
||||||
|
* - azure-files: SMB file share mount via Azure Files. Up to 100 TiB per share,
|
||||||
|
* premium throughput. Default.
|
||||||
|
* Requires: azureStorageAccount, azureFileShareName
|
||||||
|
* - blob-copy: Copy artifacts in/out of Azure Blob Storage before/after the build.
|
||||||
|
* No mount overhead, simpler.
|
||||||
|
* Requires: azureStorageAccount, azureBlobContainer
|
||||||
|
* - azure-files-nfs: NFS 4.1 file share mount. True POSIX semantics, no SMB lock overhead,
|
||||||
|
* better for Unity Library caching (many small random reads).
|
||||||
|
* Requires: azureStorageAccount, azureFileShareName, Premium FileStorage,
|
||||||
|
* VNet integration (azureSubnetId)
|
||||||
|
* - in-memory: emptyDir volume (tmpfs). Fastest I/O but volatile, size limited by
|
||||||
|
* container memory allocation.
|
||||||
|
*
|
||||||
|
* Prerequisites:
|
||||||
|
* - Azure CLI authenticated (az login or service principal)
|
||||||
|
* - A resource group for build resources
|
||||||
|
* - Contributor role on the resource group
|
||||||
|
*
|
||||||
|
* @experimental This provider is experimental. APIs and behavior may change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ProviderInterface } from '../provider-interface';
|
||||||
|
import BuildParameters from '../../../build-parameters';
|
||||||
|
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||||
|
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
||||||
|
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||||
|
import { ProviderResource } from '../provider-resource';
|
||||||
|
import { ProviderWorkflow } from '../provider-workflow';
|
||||||
|
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
|
||||||
|
import { Input } from '../../..';
|
||||||
|
import ResourceTracking from '../../services/core/resource-tracking';
|
||||||
|
|
||||||
|
type AzureStorageType = 'azure-files' | 'blob-copy' | 'azure-files-nfs' | 'in-memory';
|
||||||
|
|
||||||
|
class AzureAciProvider implements ProviderInterface {
|
||||||
|
private readonly resourceGroup: string;
|
||||||
|
private readonly location: string;
|
||||||
|
private readonly storageType: AzureStorageType;
|
||||||
|
private readonly storageAccount: string;
|
||||||
|
private readonly blobContainer: string;
|
||||||
|
private readonly fileShareName: string;
|
||||||
|
private readonly subscriptionId: string;
|
||||||
|
private readonly cpu: number;
|
||||||
|
private readonly memoryGb: number;
|
||||||
|
private readonly diskSizeGb: number;
|
||||||
|
private readonly subnetId: string;
|
||||||
|
private buildParameters: BuildParameters;
|
||||||
|
|
||||||
|
constructor(buildParameters: BuildParameters) {
|
||||||
|
this.buildParameters = buildParameters;
|
||||||
|
this.resourceGroup = buildParameters.azureResourceGroup || process.env.AZURE_RESOURCE_GROUP || '';
|
||||||
|
this.location = buildParameters.azureLocation || Input.region || 'eastus';
|
||||||
|
this.storageType = (buildParameters.azureStorageType || 'azure-files') as AzureStorageType;
|
||||||
|
this.storageAccount = buildParameters.azureStorageAccount || process.env.AZURE_STORAGE_ACCOUNT || '';
|
||||||
|
this.blobContainer = buildParameters.azureBlobContainer || 'unity-builds';
|
||||||
|
this.fileShareName = buildParameters.azureFileShareName || 'unity-builds';
|
||||||
|
this.subscriptionId = buildParameters.azureSubscriptionId || process.env.AZURE_SUBSCRIPTION_ID || '';
|
||||||
|
this.cpu = Number.parseInt(buildParameters.azureCpu || '4', 10);
|
||||||
|
this.memoryGb = Number.parseInt(buildParameters.azureMemoryGb || '16', 10);
|
||||||
|
this.diskSizeGb = Number.parseInt(buildParameters.azureDiskSizeGb || '100', 10);
|
||||||
|
this.subnetId = buildParameters.azureSubnetId || '';
|
||||||
|
|
||||||
|
OrchestratorLogger.log('[Azure ACI] Provider initialized (EXPERIMENTAL)');
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Resource Group: ${this.resourceGroup || '(not set)'}`);
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Location: ${this.location}`);
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Storage: ${this.storageType}`);
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Resources: ${this.cpu} CPU, ${this.memoryGb}GB RAM`);
|
||||||
|
|
||||||
|
this.validateStorageConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateStorageConfig(): void {
|
||||||
|
switch (this.storageType) {
|
||||||
|
case 'azure-files':
|
||||||
|
if (!this.storageAccount) {
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
'[Azure ACI] Storage type "azure-files" requires azureStorageAccount to be set.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] File Share: ${this.storageAccount}/${this.fileShareName} (SMB)`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'azure-files-nfs':
|
||||||
|
if (!this.storageAccount) {
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
'[Azure ACI] Storage type "azure-files-nfs" requires azureStorageAccount (Premium FileStorage).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!this.subnetId) {
|
||||||
|
OrchestratorLogger.logWarning('[Azure ACI] NFS file shares require VNet integration. Set azureSubnetId.');
|
||||||
|
} else {
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] File Share: ${this.storageAccount}/${this.fileShareName} (NFS 4.1)`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'blob-copy':
|
||||||
|
if (!this.storageAccount) {
|
||||||
|
OrchestratorLogger.logWarning('[Azure ACI] Storage type "blob-copy" requires azureStorageAccount to be set.');
|
||||||
|
} else {
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Blob container: ${this.storageAccount}/${this.blobContainer}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'in-memory':
|
||||||
|
OrchestratorLogger.log(
|
||||||
|
`[Azure ACI] In-memory volume (emptyDir): limited by ${this.memoryGb}GB container memory`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
`[Azure ACI] Unknown storage type '${this.storageType}'. Valid: azure-files, blob-copy, azure-files-nfs, in-memory`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.resourceGroup) {
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
'[Azure ACI] No resource group specified. Set azureResourceGroup input or AZURE_RESOURCE_GROUP env var.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupWorkflow(
|
||||||
|
buildGuid: string,
|
||||||
|
buildParameters: BuildParameters,
|
||||||
|
branchName: string,
|
||||||
|
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
||||||
|
) {
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Setting up workflow for build ${buildGuid}`);
|
||||||
|
ResourceTracking.logAllocationSummary('azure-aci setup');
|
||||||
|
|
||||||
|
// Verify Azure CLI is available
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run('az version --output json', false, true);
|
||||||
|
OrchestratorLogger.log('[Azure ACI] Azure CLI detected');
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
'[Azure ACI] Azure CLI not found. Install Azure CLI: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.subscriptionId) {
|
||||||
|
await OrchestratorSystem.Run(`az account set --subscription="${this.subscriptionId}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure resource group exists
|
||||||
|
if (this.resourceGroup) {
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(`az group show --name "${this.resourceGroup}" --output json`, false, true);
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Resource group ${this.resourceGroup} exists`);
|
||||||
|
} catch {
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Creating resource group ${this.resourceGroup}`);
|
||||||
|
await OrchestratorSystem.Run(`az group create --name "${this.resourceGroup}" --location "${this.location}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage-specific setup
|
||||||
|
switch (this.storageType) {
|
||||||
|
case 'azure-files':
|
||||||
|
await this.setupStorageAccount('Standard_LRS', 'StorageV2');
|
||||||
|
await this.setupFileShare();
|
||||||
|
break;
|
||||||
|
case 'azure-files-nfs':
|
||||||
|
await this.setupStorageAccount('Premium_LRS', 'FileStorage');
|
||||||
|
await this.setupNfsFileShare();
|
||||||
|
break;
|
||||||
|
case 'blob-copy':
|
||||||
|
await this.setupStorageAccount('Standard_LRS', 'StorageV2');
|
||||||
|
await this.setupBlobContainer();
|
||||||
|
break;
|
||||||
|
case 'in-memory':
|
||||||
|
// No storage setup needed
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupStorageAccount(sku: string, kind: string): Promise<void> {
|
||||||
|
if (!this.storageAccount || !this.resourceGroup) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`az storage account show --name "${this.storageAccount}" --resource-group "${this.resourceGroup}" --output json`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Storage account ${this.storageAccount} exists`);
|
||||||
|
} catch {
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Creating storage account ${this.storageAccount} (${sku}, ${kind})`);
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`az storage account create --name "${this.storageAccount}" --resource-group "${this.resourceGroup}" --location "${this.location}" --sku ${sku} --kind ${kind}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupFileShare(): Promise<void> {
|
||||||
|
if (!this.storageAccount || !this.resourceGroup) return;
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`az storage share-rm show --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --output json`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Creating file share ${this.fileShareName} (${this.diskSizeGb}GB)`);
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`az storage share-rm create --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --quota ${this.diskSizeGb}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupNfsFileShare(): Promise<void> {
|
||||||
|
if (!this.storageAccount || !this.resourceGroup) return;
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`az storage share-rm show --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --output json`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Creating NFS file share ${this.fileShareName} (${this.diskSizeGb}GB)`);
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`az storage share-rm create --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --quota ${this.diskSizeGb} --enabled-protocols NFS`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupBlobContainer(): Promise<void> {
|
||||||
|
if (!this.storageAccount || !this.resourceGroup) return;
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`az storage container show --name "${this.blobContainer}" --account-name "${this.storageAccount}" --output json`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Creating blob container ${this.blobContainer}`);
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`az storage container create --name "${this.blobContainer}" --account-name "${this.storageAccount}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStorageKey(): Promise<string> {
|
||||||
|
if (!this.storageAccount || !this.resourceGroup) return '';
|
||||||
|
try {
|
||||||
|
const keyJson = await OrchestratorSystem.Run(
|
||||||
|
`az storage account keys list --account-name "${this.storageAccount}" --resource-group "${this.resourceGroup}" --output json`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const keys = JSON.parse(keyJson);
|
||||||
|
return keys[0]?.value || '';
|
||||||
|
} catch (error: any) {
|
||||||
|
OrchestratorLogger.logWarning(`[Azure ACI] Could not get storage key: ${error.message}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildVolumeFlags(mountdir: string): Promise<string> {
|
||||||
|
switch (this.storageType) {
|
||||||
|
case 'azure-files': {
|
||||||
|
const storageKey = await this.getStorageKey();
|
||||||
|
if (!storageKey) return '';
|
||||||
|
return [
|
||||||
|
`--azure-file-volume-account-name "${this.storageAccount}"`,
|
||||||
|
`--azure-file-volume-account-key "${storageKey}"`,
|
||||||
|
`--azure-file-volume-share-name "${this.fileShareName}"`,
|
||||||
|
`--azure-file-volume-mount-path "${mountdir}"`,
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'azure-files-nfs': {
|
||||||
|
// ACI NFS mount uses a YAML deployment template; for CLI we use the same
|
||||||
|
// azure-file-volume flags but the share must be NFS-enabled and
|
||||||
|
// the container must be in a VNet
|
||||||
|
const storageKey = await this.getStorageKey();
|
||||||
|
if (!storageKey) return '';
|
||||||
|
return [
|
||||||
|
`--azure-file-volume-account-name "${this.storageAccount}"`,
|
||||||
|
`--azure-file-volume-account-key "${storageKey}"`,
|
||||||
|
`--azure-file-volume-share-name "${this.fileShareName}"`,
|
||||||
|
`--azure-file-volume-mount-path "${mountdir}"`,
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'in-memory':
|
||||||
|
// ACI emptyDir volumes require YAML deployment; for simplicity we skip
|
||||||
|
// the volume mount and let the container use its own filesystem
|
||||||
|
OrchestratorLogger.log('[Azure ACI] In-memory mode: using container filesystem (no persistent mount)');
|
||||||
|
return '';
|
||||||
|
|
||||||
|
case 'blob-copy':
|
||||||
|
// No volume mount — artifacts are copied in/out via az storage blob commands
|
||||||
|
return '';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runTaskInWorkflow(
|
||||||
|
buildGuid: string,
|
||||||
|
image: string,
|
||||||
|
commands: string,
|
||||||
|
mountdir: string,
|
||||||
|
workingdir: string,
|
||||||
|
environment: OrchestratorEnvironmentVariable[],
|
||||||
|
secrets: OrchestratorSecret[],
|
||||||
|
): Promise<string> {
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Running task for build ${buildGuid}`);
|
||||||
|
ResourceTracking.logAllocationSummary('azure-aci task');
|
||||||
|
|
||||||
|
const containerName = `unity-build-${buildGuid}`
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]/g, '-')
|
||||||
|
.slice(0, 63);
|
||||||
|
|
||||||
|
// Build environment variable flags
|
||||||
|
const allEnvVars = [
|
||||||
|
...environment.map((env) => `${env.name}=${env.value}`),
|
||||||
|
...secrets.map((s) => `${s.EnvironmentVariable}=${s.ParameterValue}`),
|
||||||
|
];
|
||||||
|
const envFlag = allEnvVars.length > 0 ? `--environment-variables ${allEnvVars.map((e) => `"${e}"`).join(' ')}` : '';
|
||||||
|
|
||||||
|
// Build volume flags based on storage type
|
||||||
|
const volumeFlags = await this.buildVolumeFlags(mountdir);
|
||||||
|
|
||||||
|
const subnetFlag = this.subnetId ? `--subnet "${this.subnetId}"` : '';
|
||||||
|
|
||||||
|
// For blob-copy, wrap the user command with copy-in/copy-out steps
|
||||||
|
let effectiveCommands = commands;
|
||||||
|
if (this.storageType === 'blob-copy' && this.storageAccount && commands) {
|
||||||
|
effectiveCommands = [
|
||||||
|
`az storage blob download-batch --destination "${mountdir}" --source "${this.blobContainer}" --account-name "${this.storageAccount}" 2>/dev/null || true`,
|
||||||
|
commands,
|
||||||
|
`az storage blob upload-batch --source "${mountdir}" --destination "${this.blobContainer}" --account-name "${this.storageAccount}" --overwrite`,
|
||||||
|
].join(' && ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandFlag = effectiveCommands
|
||||||
|
? `--command-line "/bin/sh -c '${effectiveCommands.replace(/'/g, "'\\''")}'"`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const createCmd = [
|
||||||
|
'az container create',
|
||||||
|
`--resource-group "${this.resourceGroup}"`,
|
||||||
|
`--name "${containerName}"`,
|
||||||
|
`--image "${image}"`,
|
||||||
|
`--location "${this.location}"`,
|
||||||
|
`--cpu ${this.cpu}`,
|
||||||
|
`--memory ${this.memoryGb}`,
|
||||||
|
'--restart-policy Never',
|
||||||
|
'--os-type Linux',
|
||||||
|
volumeFlags,
|
||||||
|
envFlag,
|
||||||
|
subnetFlag,
|
||||||
|
commandFlag,
|
||||||
|
'--output json',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(createCmd);
|
||||||
|
OrchestratorLogger.log(
|
||||||
|
`[Azure ACI] Container ${containerName} created (storage: ${this.storageType}), waiting for completion...`,
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`[Azure ACI] Failed to create container: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await this.waitForContainerCompletion(containerName);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForContainerCompletion(containerName: string): Promise<string> {
|
||||||
|
const maxWaitMs = 24 * 60 * 60 * 1000;
|
||||||
|
const pollIntervalMs = 15_000;
|
||||||
|
const startTime = Date.now();
|
||||||
|
let lastLogLength = 0;
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
|
try {
|
||||||
|
const stateJson = await OrchestratorSystem.Run(
|
||||||
|
`az container show --resource-group "${this.resourceGroup}" --name "${containerName}" --output json`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = JSON.parse(stateJson);
|
||||||
|
const containerState =
|
||||||
|
state.containers?.[0]?.instanceView?.currentState?.state || state.instanceView?.state || 'Unknown';
|
||||||
|
const provisioningState = state.provisioningState || 'Unknown';
|
||||||
|
|
||||||
|
// Stream logs incrementally
|
||||||
|
try {
|
||||||
|
const logs = await OrchestratorSystem.Run(
|
||||||
|
`az container logs --resource-group "${this.resourceGroup}" --name "${containerName}"`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (logs && logs.length > lastLogLength) {
|
||||||
|
const newLogs = logs.slice(lastLogLength);
|
||||||
|
for (const line of newLogs.split('\n')) {
|
||||||
|
if (line.trim()) {
|
||||||
|
OrchestratorLogger.log(`[Build] ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastLogLength = logs.length;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Logs may not be available yet
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerState === 'Terminated' || provisioningState === 'Succeeded') {
|
||||||
|
const exitCode = state.containers?.[0]?.instanceView?.currentState?.exitCode;
|
||||||
|
if (exitCode !== undefined && exitCode !== 0) {
|
||||||
|
throw new Error(`[Azure ACI] Container exited with code ${exitCode}`);
|
||||||
|
}
|
||||||
|
OrchestratorLogger.log('[Azure ACI] Container completed successfully');
|
||||||
|
try {
|
||||||
|
return await OrchestratorSystem.Run(
|
||||||
|
`az container logs --resource-group "${this.resourceGroup}" --name "${containerName}"`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provisioningState === 'Failed') {
|
||||||
|
const detail =
|
||||||
|
state.containers?.[0]?.instanceView?.currentState?.detailStatus ||
|
||||||
|
state.containers?.[0]?.instanceView?.events?.map((e: any) => e.message).join('; ') ||
|
||||||
|
'Unknown error';
|
||||||
|
throw new Error(`[Azure ACI] Container provisioning failed: ${detail}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message?.includes('Container provisioning failed') || error.message?.includes('exited with code')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
OrchestratorLogger.logWarning(`[Azure ACI] Polling error: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('[Azure ACI] Container execution timed out after 24 hours');
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupWorkflow(
|
||||||
|
buildParameters: BuildParameters,
|
||||||
|
branchName: string,
|
||||||
|
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
||||||
|
) {
|
||||||
|
OrchestratorLogger.log('[Azure ACI] Cleaning up workflow');
|
||||||
|
}
|
||||||
|
|
||||||
|
async garbageCollect(
|
||||||
|
filter: string,
|
||||||
|
previewOnly: boolean,
|
||||||
|
olderThan: Number,
|
||||||
|
fullCache: boolean,
|
||||||
|
baseDependencies: boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
OrchestratorLogger.log('[Azure ACI] Garbage collecting old container groups');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const containersJson = await OrchestratorSystem.Run(
|
||||||
|
`az container list --resource-group "${this.resourceGroup}" --output json`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const containers = JSON.parse(containersJson || '[]');
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - Number(olderThan));
|
||||||
|
|
||||||
|
let deletedCount = 0;
|
||||||
|
for (const container of containers) {
|
||||||
|
const name = container.name || '';
|
||||||
|
if (!name.startsWith('unity-build-')) continue;
|
||||||
|
|
||||||
|
const createdAt = new Date(container.tags?.createdAt || container.properties?.provisioningState || 0);
|
||||||
|
const state = container.containers?.[0]?.instanceView?.currentState?.state || '';
|
||||||
|
|
||||||
|
if (state === 'Terminated' || createdAt < cutoffDate) {
|
||||||
|
if (previewOnly) {
|
||||||
|
OrchestratorLogger.log(`[Azure ACI] Would delete: ${name}`);
|
||||||
|
} else {
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`az container delete --resource-group "${this.resourceGroup}" --name "${name}" --yes`,
|
||||||
|
);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Garbage collected ${deletedCount} Azure container instances`;
|
||||||
|
} catch (error: any) {
|
||||||
|
OrchestratorLogger.logWarning(`[Azure ACI] Garbage collection failed: ${error.message}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResources(): Promise<ProviderResource[]> {
|
||||||
|
try {
|
||||||
|
const containersJson = await OrchestratorSystem.Run(
|
||||||
|
`az container list --resource-group "${this.resourceGroup}" --output json`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const containers = JSON.parse(containersJson || '[]');
|
||||||
|
return containers
|
||||||
|
.filter((c: any) => (c.name || '').startsWith('unity-build-'))
|
||||||
|
.map((c: any) => ({ Name: c.name || '' }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listWorkflow(): Promise<ProviderWorkflow[]> {
|
||||||
|
throw new Error('[Azure ACI] listWorkflow not implemented for this experimental provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
async watchWorkflow(): Promise<string> {
|
||||||
|
throw new Error('[Azure ACI] watchWorkflow not implemented for this experimental provider');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AzureAciProvider;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export interface CliProviderRequest {
|
||||||
|
command: CliProviderSubcommand;
|
||||||
|
params: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliProviderResponse {
|
||||||
|
success: boolean;
|
||||||
|
result?: any;
|
||||||
|
error?: string;
|
||||||
|
output?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CliProviderSubcommand =
|
||||||
|
| 'setup-workflow'
|
||||||
|
| 'cleanup-workflow'
|
||||||
|
| 'run-task'
|
||||||
|
| 'garbage-collect'
|
||||||
|
| 'list-resources'
|
||||||
|
| 'list-workflow'
|
||||||
|
| 'watch-workflow';
|
||||||
532
src/model/orchestrator/providers/cli/cli-provider.test.ts
Normal file
532
src/model/orchestrator/providers/cli/cli-provider.test.ts
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { ProviderLoader } from '../provider-loader';
|
||||||
|
|
||||||
|
// Mock child_process
|
||||||
|
jest.mock('child_process', () => ({
|
||||||
|
spawn: jest.fn(),
|
||||||
|
exec: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock @actions/core to prevent GitHub Actions API calls
|
||||||
|
jest.mock('@actions/core', () => ({
|
||||||
|
info: jest.fn(),
|
||||||
|
warning: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
setOutput: jest.fn(),
|
||||||
|
getInput: jest.fn(() => ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock provider-git-manager (required by provider-loader)
|
||||||
|
jest.mock('../provider-git-manager');
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import * as core from '@actions/core';
|
||||||
|
import CliProvider from './cli-provider';
|
||||||
|
|
||||||
|
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock child process with stdin, stdout, stderr as EventEmitters.
|
||||||
|
*/
|
||||||
|
function createMockChildProcess() {
|
||||||
|
const child = new EventEmitter() as any;
|
||||||
|
child.stdin = { write: jest.fn(), end: jest.fn() };
|
||||||
|
child.stdout = new EventEmitter();
|
||||||
|
child.stderr = new EventEmitter();
|
||||||
|
child.kill = jest.fn();
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CliProvider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('validates that executable path is non-empty', () => {
|
||||||
|
expect(() => new CliProvider('', {} as any)).toThrow('executablePath must be a non-empty string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates that executable path is not just whitespace', () => {
|
||||||
|
expect(() => new CliProvider(' ', {} as any)).toThrow('executablePath must be a non-empty string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a valid executable path', () => {
|
||||||
|
const provider = new CliProvider('/usr/bin/my-provider', {} as any);
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('request serialization', () => {
|
||||||
|
it('sends JSON request to stdin with correct command and params', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.listResources();
|
||||||
|
|
||||||
|
// Simulate successful response
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: [] }) + '\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(child.stdin.write).toHaveBeenCalledTimes(1);
|
||||||
|
const writtenData = child.stdin.write.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(writtenData);
|
||||||
|
expect(parsed.command).toBe('list-resources');
|
||||||
|
expect(parsed.params).toEqual({});
|
||||||
|
expect(child.stdin.end).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes setupWorkflow params correctly', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.setupWorkflow('guid-123', { editorVersion: '2022.3' } as any, 'main', []);
|
||||||
|
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: {} }) + '\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
const writtenData = child.stdin.write.mock.calls[0][0];
|
||||||
|
const parsed = JSON.parse(writtenData);
|
||||||
|
expect(parsed.command).toBe('setup-workflow');
|
||||||
|
expect(parsed.params.buildGuid).toBe('guid-123');
|
||||||
|
expect(parsed.params.branchName).toBe('main');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('response parsing', () => {
|
||||||
|
it('resolves on successful JSON response', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.listResources();
|
||||||
|
|
||||||
|
const resources = [{ Name: 'resource-1' }, { Name: 'resource-2' }];
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: resources }) + '\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result).toEqual(resources);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects on error JSON response', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.garbageCollect('', false, 30, false, false);
|
||||||
|
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: false, error: 'something went wrong' }) + '\n'));
|
||||||
|
child.emit('close', 1);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('something went wrong');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when process exits with non-zero code and no JSON response', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.listWorkflow();
|
||||||
|
|
||||||
|
child.stderr.emit('data', Buffer.from('segfault\n'));
|
||||||
|
child.emit('close', 139);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('exited with code 139');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves when process exits with code 0 and no JSON response', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.listResources();
|
||||||
|
|
||||||
|
child.stdout.emit('data', Buffer.from('some plain text output\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
// listResources falls back to empty array when result is missing
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects on spawn error', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/nonexistent/path', {} as any);
|
||||||
|
const promise = provider.listResources();
|
||||||
|
|
||||||
|
child.emit('error', new Error('ENOENT'));
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('failed to spawn executable');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runTaskInWorkflow', () => {
|
||||||
|
it('forwards non-JSON stdout lines as build output and returns final response', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
|
||||||
|
|
||||||
|
// Simulate build output followed by JSON response
|
||||||
|
child.stdout.emit('data', Buffer.from('Building project...\nCompiling scripts...\n'));
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: 'Build succeeded' }) + '\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result).toBe('Build succeeded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects on run-task failure', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
|
||||||
|
|
||||||
|
child.stdout.emit(
|
||||||
|
'data',
|
||||||
|
Buffer.from(JSON.stringify({ success: false, error: 'Build failed: compilation errors' }) + '\n'),
|
||||||
|
);
|
||||||
|
child.emit('close', 1);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('Build failed: compilation errors');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns collected output lines when no JSON response and exit code 0', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
|
||||||
|
|
||||||
|
child.stdout.emit('data', Buffer.from('line 1\nline 2\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result).toBe('line 1\nline 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects on spawn error', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/nonexistent/path', {} as any);
|
||||||
|
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
|
||||||
|
|
||||||
|
child.emit('error', new Error('ENOENT'));
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('failed to spawn executable');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanupWorkflow', () => {
|
||||||
|
it('sends cleanup-workflow command and returns result', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.cleanupWorkflow({ editorVersion: '2022.3' } as any, 'main', []);
|
||||||
|
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: 'cleaned' }) + '\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result).toBe('cleaned');
|
||||||
|
|
||||||
|
const parsed = JSON.parse(child.stdin.write.mock.calls[0][0]);
|
||||||
|
expect(parsed.command).toBe('cleanup-workflow');
|
||||||
|
expect(parsed.params.branchName).toBe('main');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('garbageCollect', () => {
|
||||||
|
it('sends garbage-collect command with correct params', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.garbageCollect('filter*', true, 30, false, true);
|
||||||
|
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: '3 items removed' }) + '\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result).toBe('3 items removed');
|
||||||
|
|
||||||
|
const parsed = JSON.parse(child.stdin.write.mock.calls[0][0]);
|
||||||
|
expect(parsed.command).toBe('garbage-collect');
|
||||||
|
expect(parsed.params.filter).toBe('filter*');
|
||||||
|
expect(parsed.params.previewOnly).toBe(true);
|
||||||
|
expect(parsed.params.olderThan).toBe(30);
|
||||||
|
expect(parsed.params.baseDependencies).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string when response has no output', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.garbageCollect('', false, 7, false, false);
|
||||||
|
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true }) + '\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listWorkflow', () => {
|
||||||
|
it('returns workflow list from response', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.listWorkflow();
|
||||||
|
|
||||||
|
const workflows = [{ Name: 'wf-1' }];
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: workflows }) + '\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result).toEqual(workflows);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when result is missing', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.listWorkflow();
|
||||||
|
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true }) + '\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('watchWorkflow', () => {
|
||||||
|
it('forwards streaming output and resolves on success', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.watchWorkflow();
|
||||||
|
|
||||||
|
child.stdout.emit('data', Buffer.from('watching...\nstatus: running\n'));
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: 'completed' }) + '\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result).toBe('completed');
|
||||||
|
|
||||||
|
const parsed = JSON.parse(child.stdin.write.mock.calls[0][0]);
|
||||||
|
expect(parsed.command).toBe('watch-workflow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects on watch-workflow failure', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.watchWorkflow();
|
||||||
|
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: false, error: 'lost connection' }) + '\n'));
|
||||||
|
child.emit('close', 1);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('lost connection');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects on spawn error', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.watchWorkflow();
|
||||||
|
|
||||||
|
child.emit('error', new Error('ENOENT'));
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('failed to spawn executable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves with collected output when exit code 0 and no JSON', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.watchWorkflow();
|
||||||
|
|
||||||
|
child.stdout.emit('data', Buffer.from('line A\nline B\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result).toBe('line A\nline B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stderr forwarding', () => {
|
||||||
|
it('forwards stderr lines to logger during execute', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.listResources();
|
||||||
|
|
||||||
|
child.stderr.emit('data', Buffer.from('warning: something\n'));
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: [] }) + '\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
// stderr content included in error message if process fails
|
||||||
|
// Here it succeeds, so we just verify no rejection
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes stderr in error message when process fails without JSON', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.listResources();
|
||||||
|
|
||||||
|
child.stderr.emit('data', Buffer.from('fatal: segfault\n'));
|
||||||
|
child.emit('close', 1);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('fatal: segfault');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('timeout handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects and kills process when execute command times out', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.listResources();
|
||||||
|
|
||||||
|
// Advance past the 300s default timeout
|
||||||
|
jest.advanceTimersByTime(301_000);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('timed out');
|
||||||
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects and kills process when runTaskInWorkflow times out', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
|
||||||
|
|
||||||
|
// Advance past the 2-hour timeout (7_200_000ms)
|
||||||
|
jest.advanceTimersByTime(7_200_001);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('run-task timed out');
|
||||||
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||||
|
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('CLI provider timed out after 120 minutes'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects and kills process when watchWorkflow times out', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.watchWorkflow();
|
||||||
|
|
||||||
|
// Advance past the 1-hour timeout (3_600_000ms)
|
||||||
|
jest.advanceTimersByTime(3_600_001);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('watch-workflow timed out');
|
||||||
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||||
|
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('CLI provider timed out after 60 minutes'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escalates to SIGKILL after grace period on runTaskInWorkflow timeout', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
|
||||||
|
|
||||||
|
// Trigger the timeout
|
||||||
|
jest.advanceTimersByTime(7_200_001);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('timed out');
|
||||||
|
|
||||||
|
// SIGTERM was sent
|
||||||
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||||
|
|
||||||
|
// Advance past the 10s grace period — SIGKILL should fire
|
||||||
|
jest.advanceTimersByTime(10_001);
|
||||||
|
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send SIGKILL if process exits before grace period', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
|
||||||
|
|
||||||
|
// Trigger the timeout
|
||||||
|
jest.advanceTimersByTime(7_200_001);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('timed out');
|
||||||
|
|
||||||
|
// Process exits voluntarily after SIGTERM
|
||||||
|
child.emit('close', 143);
|
||||||
|
|
||||||
|
// Advance past the grace period — SIGKILL should NOT fire because process already exited
|
||||||
|
jest.advanceTimersByTime(10_001);
|
||||||
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||||
|
// SIGKILL should not have been called because the close event cleared the timer
|
||||||
|
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears timeout when runTaskInWorkflow completes normally', async () => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||||
|
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
|
||||||
|
|
||||||
|
// Process completes before timeout
|
||||||
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: 'done' }) + '\n'));
|
||||||
|
child.emit('close', 0);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result).toBe('done');
|
||||||
|
|
||||||
|
// Advance far past timeout — should NOT reject
|
||||||
|
jest.advanceTimersByTime(8_000_000);
|
||||||
|
expect(child.kill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('available providers list', () => {
|
||||||
|
it('includes cli in the available providers', () => {
|
||||||
|
const providers = ProviderLoader.getAvailableProviders();
|
||||||
|
expect(providers).toContain('cli');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
406
src/model/orchestrator/providers/cli/cli-provider.ts
Normal file
406
src/model/orchestrator/providers/cli/cli-provider.ts
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
|
import * as core from '@actions/core';
|
||||||
|
import { ProviderInterface } from '../provider-interface';
|
||||||
|
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';
|
||||||
|
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||||
|
import { CliProviderRequest, CliProviderResponse, CliProviderSubcommand } from './cli-provider-protocol';
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 300_000; // 300 seconds
|
||||||
|
const RUN_TASK_TIMEOUT_MS = 7_200_000; // 2 hours
|
||||||
|
const WATCH_WORKFLOW_TIMEOUT_MS = 3_600_000; // 1 hour
|
||||||
|
const SIGKILL_GRACE_MS = 10_000; // 10 seconds grace period before SIGKILL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gracefully kill a child process: SIGTERM first, then SIGKILL after a grace period.
|
||||||
|
*/
|
||||||
|
function gracefulKill(child: ChildProcess, graceMs: number = SIGKILL_GRACE_MS): void {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
|
||||||
|
const forceKillTimer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
} catch {
|
||||||
|
// Process may already be dead
|
||||||
|
}
|
||||||
|
}, graceMs);
|
||||||
|
|
||||||
|
// Clear the force-kill timer if the process exits on its own
|
||||||
|
child.on('close', () => {
|
||||||
|
clearTimeout(forceKillTimer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class CliProvider implements ProviderInterface {
|
||||||
|
private readonly executablePath: string;
|
||||||
|
private readonly buildParameters: BuildParameters;
|
||||||
|
|
||||||
|
constructor(executablePath: string, buildParameters: BuildParameters) {
|
||||||
|
if (!executablePath || executablePath.trim() === '') {
|
||||||
|
throw new Error('CliProvider: executablePath must be a non-empty string');
|
||||||
|
}
|
||||||
|
this.executablePath = executablePath;
|
||||||
|
this.buildParameters = buildParameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupWorkflow(
|
||||||
|
buildGuid: string,
|
||||||
|
buildParameters: BuildParameters,
|
||||||
|
branchName: string,
|
||||||
|
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
||||||
|
): Promise<any> {
|
||||||
|
const response = await this.execute('setup-workflow', {
|
||||||
|
buildGuid,
|
||||||
|
buildParameters,
|
||||||
|
branchName,
|
||||||
|
defaultSecretsArray,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupWorkflow(
|
||||||
|
buildParameters: BuildParameters,
|
||||||
|
branchName: string,
|
||||||
|
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
||||||
|
): Promise<any> {
|
||||||
|
const response = await this.execute('cleanup-workflow', {
|
||||||
|
buildParameters,
|
||||||
|
branchName,
|
||||||
|
defaultSecretsArray,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async runTaskInWorkflow(
|
||||||
|
buildGuid: string,
|
||||||
|
image: string,
|
||||||
|
commands: string,
|
||||||
|
mountdir: string,
|
||||||
|
workingdir: string,
|
||||||
|
environment: OrchestratorEnvironmentVariable[],
|
||||||
|
secrets: OrchestratorSecret[],
|
||||||
|
): Promise<string> {
|
||||||
|
const request: CliProviderRequest = {
|
||||||
|
command: 'run-task',
|
||||||
|
params: {
|
||||||
|
buildGuid,
|
||||||
|
image,
|
||||||
|
commands,
|
||||||
|
mountdir,
|
||||||
|
workingdir,
|
||||||
|
environment,
|
||||||
|
secrets,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutMs = RUN_TASK_TIMEOUT_MS;
|
||||||
|
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const child = spawn(this.executablePath, ['run-task'], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastJsonResponse: CliProviderResponse | undefined;
|
||||||
|
const outputLines: string[] = [];
|
||||||
|
let stderrOutput = '';
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
// Set up timeout to prevent indefinite hangs
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
const minutes = Math.round(timeoutMs / 60_000);
|
||||||
|
const message = `CLI provider timed out after ${minutes} minutes. The external provider may be unresponsive.`;
|
||||||
|
core.error(message);
|
||||||
|
gracefulKill(child);
|
||||||
|
reject(new Error(`CliProvider run-task timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.stdin.write(JSON.stringify(request));
|
||||||
|
child.stdin.end();
|
||||||
|
|
||||||
|
child.stdout.on('data', (data: Buffer) => {
|
||||||
|
const lines = data.toString().split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
// Try to parse as JSON response
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
|
||||||
|
lastJsonResponse = parsed as CliProviderResponse;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON — treat as build output
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward non-JSON lines as real-time build output
|
||||||
|
OrchestratorLogger.log(trimmed);
|
||||||
|
outputLines.push(trimmed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (data: Buffer) => {
|
||||||
|
const text = data.toString();
|
||||||
|
stderrOutput += text;
|
||||||
|
// Forward stderr to logger
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
OrchestratorLogger.log(`[cli-provider stderr] ${trimmed}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error: Error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!timedOut) {
|
||||||
|
reject(new Error(`CliProvider: failed to spawn executable '${this.executablePath}': ${error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code: number | null) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (timedOut) return;
|
||||||
|
|
||||||
|
if (lastJsonResponse) {
|
||||||
|
if (lastJsonResponse.success) {
|
||||||
|
resolve(lastJsonResponse.output || outputLines.join('\n'));
|
||||||
|
} else {
|
||||||
|
reject(
|
||||||
|
new Error(`CliProvider run-task failed: ${lastJsonResponse.error || 'Unknown error from CLI provider'}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (code === 0) {
|
||||||
|
resolve(outputLines.join('\n'));
|
||||||
|
} else {
|
||||||
|
reject(
|
||||||
|
new Error(`CliProvider run-task exited with code ${code}${stderrOutput ? ': ' + stderrOutput.trim() : ''}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async garbageCollect(
|
||||||
|
filter: string,
|
||||||
|
previewOnly: boolean,
|
||||||
|
olderThan: Number,
|
||||||
|
fullCache: boolean,
|
||||||
|
baseDependencies: boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await this.execute('garbage-collect', {
|
||||||
|
filter,
|
||||||
|
previewOnly,
|
||||||
|
olderThan,
|
||||||
|
fullCache,
|
||||||
|
baseDependencies,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.output || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResources(): Promise<ProviderResource[]> {
|
||||||
|
const response = await this.execute('list-resources', {});
|
||||||
|
|
||||||
|
return (response.result as ProviderResource[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWorkflow(): Promise<ProviderWorkflow[]> {
|
||||||
|
const response = await this.execute('list-workflow', {});
|
||||||
|
|
||||||
|
return (response.result as ProviderWorkflow[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async watchWorkflow(): Promise<string> {
|
||||||
|
const request: CliProviderRequest = {
|
||||||
|
command: 'watch-workflow',
|
||||||
|
params: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutMs = WATCH_WORKFLOW_TIMEOUT_MS;
|
||||||
|
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const child = spawn(this.executablePath, ['watch-workflow'], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastJsonResponse: CliProviderResponse | undefined;
|
||||||
|
const outputLines: string[] = [];
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
// Set up timeout to prevent indefinite hangs
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
const minutes = Math.round(timeoutMs / 60_000);
|
||||||
|
const message = `CLI provider timed out after ${minutes} minutes. The external provider may be unresponsive.`;
|
||||||
|
core.error(message);
|
||||||
|
gracefulKill(child);
|
||||||
|
reject(new Error(`CliProvider watch-workflow timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.stdin.write(JSON.stringify(request));
|
||||||
|
child.stdin.end();
|
||||||
|
|
||||||
|
child.stdout.on('data', (data: Buffer) => {
|
||||||
|
const lines = data.toString().split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
|
||||||
|
lastJsonResponse = parsed as CliProviderResponse;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
OrchestratorLogger.log(trimmed);
|
||||||
|
outputLines.push(trimmed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (data: Buffer) => {
|
||||||
|
for (const line of data.toString().split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
OrchestratorLogger.log(`[cli-provider stderr] ${trimmed}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error: Error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!timedOut) {
|
||||||
|
reject(new Error(`CliProvider: failed to spawn executable '${this.executablePath}': ${error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code: number | null) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (timedOut) return;
|
||||||
|
|
||||||
|
if (lastJsonResponse) {
|
||||||
|
if (lastJsonResponse.success) {
|
||||||
|
resolve(lastJsonResponse.output || outputLines.join('\n'));
|
||||||
|
} else {
|
||||||
|
reject(new Error(`CliProvider watch-workflow failed: ${lastJsonResponse.error || 'Unknown error'}`));
|
||||||
|
}
|
||||||
|
} else if (code === 0) {
|
||||||
|
resolve(outputLines.join('\n'));
|
||||||
|
} else {
|
||||||
|
reject(new Error(`CliProvider watch-workflow exited with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a CLI provider subcommand with a default timeout.
|
||||||
|
* Timeout applies a graceful SIGTERM followed by SIGKILL after a grace period.
|
||||||
|
*/
|
||||||
|
private execute(
|
||||||
|
command: CliProviderSubcommand,
|
||||||
|
params: Record<string, any>,
|
||||||
|
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||||
|
): Promise<CliProviderResponse> {
|
||||||
|
const request: CliProviderRequest = { command, params };
|
||||||
|
|
||||||
|
return new Promise<CliProviderResponse>((resolve, reject) => {
|
||||||
|
const child = spawn(this.executablePath, [command], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdoutData = '';
|
||||||
|
let stderrData = '';
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
// Set up timeout with graceful kill
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
gracefulKill(child);
|
||||||
|
reject(new Error(`CliProvider: command '${command}' timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.stdin.write(JSON.stringify(request));
|
||||||
|
child.stdin.end();
|
||||||
|
|
||||||
|
child.stdout.on('data', (data: Buffer) => {
|
||||||
|
stdoutData += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (data: Buffer) => {
|
||||||
|
const text = data.toString();
|
||||||
|
stderrData += text;
|
||||||
|
// Forward stderr to logger
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
OrchestratorLogger.log(`[cli-provider stderr] ${trimmed}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error: Error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!timedOut) {
|
||||||
|
reject(new Error(`CliProvider: failed to spawn executable '${this.executablePath}': ${error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code: number | null) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (timedOut) return;
|
||||||
|
|
||||||
|
// Find the last JSON line in stdout
|
||||||
|
const lines = stdoutData.split('\n').filter((l) => l.trim());
|
||||||
|
let response: CliProviderResponse | undefined;
|
||||||
|
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(lines[i].trim());
|
||||||
|
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
|
||||||
|
response = parsed as CliProviderResponse;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
if (response.success) {
|
||||||
|
resolve(response);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`CliProvider ${command} failed: ${response.error || 'Unknown error from CLI provider'}`));
|
||||||
|
}
|
||||||
|
} else if (code === 0) {
|
||||||
|
// No JSON response but exit code 0 — treat as success with raw output
|
||||||
|
resolve({ success: true, output: stdoutData.trim() });
|
||||||
|
} else {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`CliProvider ${command} exited with code ${code}` +
|
||||||
|
(stderrData ? `: ${stderrData.trim()}` : '') +
|
||||||
|
(!stderrData && stdoutData ? `: ${stdoutData.trim()}` : ''),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CliProvider;
|
||||||
1
src/model/orchestrator/providers/cli/index.ts
Normal file
1
src/model/orchestrator/providers/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './cli-provider';
|
||||||
196
src/model/orchestrator/providers/docker/index.ts
Normal file
196
src/model/orchestrator/providers/docker/index.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
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;
|
||||||
435
src/model/orchestrator/providers/gcp-cloud-run/index.ts
Normal file
435
src/model/orchestrator/providers/gcp-cloud-run/index.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
/**
|
||||||
|
* Google Cloud Run Jobs Provider (Experimental)
|
||||||
|
*
|
||||||
|
* Executes Unity builds as Cloud Run Jobs with configurable storage backends.
|
||||||
|
*
|
||||||
|
* Storage types:
|
||||||
|
* - gcs-fuse: Mount a GCS bucket as a POSIX filesystem via GCS FUSE sidecar.
|
||||||
|
* Unlimited size, best for large sequential reads/writes.
|
||||||
|
* Requires: gcpBucket
|
||||||
|
* - gcs-copy: Copy artifacts in/out of GCS before/after the build via gsutil.
|
||||||
|
* No mount overhead, simpler, works everywhere.
|
||||||
|
* Requires: gcpBucket
|
||||||
|
* - nfs: Mount a Filestore NFS share. True POSIX semantics, good random I/O,
|
||||||
|
* up to 100 TiB. Best for Library caching (many small random reads).
|
||||||
|
* Requires: gcpFilestoreIp, gcpFilestoreShare
|
||||||
|
* - in-memory: tmpfs volume (emptyDir). Fastest I/O but volatile and limited to 32 GiB.
|
||||||
|
* Good for scratch/temp space during builds.
|
||||||
|
*
|
||||||
|
* Prerequisites:
|
||||||
|
* - Google Cloud SDK authenticated (GOOGLE_APPLICATION_CREDENTIALS or gcloud auth)
|
||||||
|
* - Cloud Run Jobs API enabled
|
||||||
|
* - Service account with roles: Cloud Run Admin, Storage Admin, Logs Viewer
|
||||||
|
*
|
||||||
|
* @experimental This provider is experimental. APIs and behavior may change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ProviderInterface } from '../provider-interface';
|
||||||
|
import BuildParameters from '../../../build-parameters';
|
||||||
|
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||||
|
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
||||||
|
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||||
|
import { ProviderResource } from '../provider-resource';
|
||||||
|
import { ProviderWorkflow } from '../provider-workflow';
|
||||||
|
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
|
||||||
|
import { Input } from '../../..';
|
||||||
|
import ResourceTracking from '../../services/core/resource-tracking';
|
||||||
|
|
||||||
|
type GcpStorageType = 'gcs-fuse' | 'gcs-copy' | 'nfs' | 'in-memory';
|
||||||
|
|
||||||
|
class GcpCloudRunProvider implements ProviderInterface {
|
||||||
|
private readonly project: string;
|
||||||
|
private readonly region: string;
|
||||||
|
private readonly storageType: GcpStorageType;
|
||||||
|
private readonly bucket: string;
|
||||||
|
private readonly filestoreIp: string;
|
||||||
|
private readonly filestoreShare: string;
|
||||||
|
private readonly machineType: string;
|
||||||
|
private readonly diskSizeGb: number;
|
||||||
|
private readonly serviceAccount: string;
|
||||||
|
private readonly vpcConnector: string;
|
||||||
|
private buildParameters: BuildParameters;
|
||||||
|
|
||||||
|
constructor(buildParameters: BuildParameters) {
|
||||||
|
this.buildParameters = buildParameters;
|
||||||
|
this.project = buildParameters.gcpProject || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || '';
|
||||||
|
this.region = buildParameters.gcpRegion || Input.region || 'us-central1';
|
||||||
|
this.storageType = (buildParameters.gcpStorageType || 'gcs-fuse') as GcpStorageType;
|
||||||
|
this.bucket = buildParameters.gcpBucket || '';
|
||||||
|
this.filestoreIp = buildParameters.gcpFilestoreIp || '';
|
||||||
|
this.filestoreShare = buildParameters.gcpFilestoreShare || '/share1';
|
||||||
|
this.machineType = buildParameters.gcpMachineType || 'e2-standard-4';
|
||||||
|
this.diskSizeGb = Number.parseInt(buildParameters.gcpDiskSizeGb || '100', 10);
|
||||||
|
this.serviceAccount = buildParameters.gcpServiceAccount || '';
|
||||||
|
this.vpcConnector = buildParameters.gcpVpcConnector || '';
|
||||||
|
|
||||||
|
OrchestratorLogger.log('[GCP Cloud Run] Provider initialized (EXPERIMENTAL)');
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Project: ${this.project || '(auto-detect)'}`);
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Region: ${this.region}`);
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Storage: ${this.storageType}`);
|
||||||
|
|
||||||
|
this.validateStorageConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateStorageConfig(): void {
|
||||||
|
switch (this.storageType) {
|
||||||
|
case 'gcs-fuse':
|
||||||
|
case 'gcs-copy':
|
||||||
|
if (!this.bucket) {
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
`[GCP Cloud Run] Storage type '${this.storageType}' requires gcpBucket to be set.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Bucket: gs://${this.bucket}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'nfs':
|
||||||
|
if (!this.filestoreIp) {
|
||||||
|
OrchestratorLogger.logWarning('[GCP Cloud Run] Storage type "nfs" requires gcpFilestoreIp to be set.');
|
||||||
|
} else {
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Filestore: ${this.filestoreIp}:${this.filestoreShare}`);
|
||||||
|
}
|
||||||
|
if (!this.vpcConnector) {
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
'[GCP Cloud Run] NFS storage usually requires gcpVpcConnector for private network access to Filestore.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'in-memory':
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] In-memory volume: ${Math.min(this.diskSizeGb, 32)} GiB (max 32)`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
`[GCP Cloud Run] Unknown storage type '${this.storageType}'. Valid: gcs-fuse, gcs-copy, nfs, in-memory`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.project) {
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
'[GCP Cloud Run] No project specified. Set gcpProject input or GOOGLE_CLOUD_PROJECT env var.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupWorkflow(
|
||||||
|
buildGuid: string,
|
||||||
|
buildParameters: BuildParameters,
|
||||||
|
branchName: string,
|
||||||
|
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
||||||
|
) {
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Setting up workflow for build ${buildGuid}`);
|
||||||
|
ResourceTracking.logAllocationSummary('gcp-cloud-run setup');
|
||||||
|
|
||||||
|
// Verify gcloud CLI is available
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run('gcloud --version', false, true);
|
||||||
|
OrchestratorLogger.log('[GCP Cloud Run] gcloud CLI detected');
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
'[GCP Cloud Run] gcloud CLI not found. Install Google Cloud SDK: https://cloud.google.com/sdk/docs/install',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Cloud Run Jobs API is enabled
|
||||||
|
try {
|
||||||
|
const projectFlag = this.project ? `--project=${this.project}` : '';
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`gcloud services list --enabled --filter="name:run.googleapis.com" ${projectFlag} --format="value(name)"`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
'[GCP Cloud Run] Could not verify Cloud Run API status. Ensure run.googleapis.com is enabled.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage-specific setup
|
||||||
|
if ((this.storageType === 'gcs-fuse' || this.storageType === 'gcs-copy') && this.bucket) {
|
||||||
|
await this.ensureBucketExists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureBucketExists(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`gcloud storage buckets describe gs://${this.bucket} --format="value(name)"`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Bucket gs://${this.bucket} exists`);
|
||||||
|
} catch {
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Creating bucket gs://${this.bucket}`);
|
||||||
|
const projectFlag = this.project ? `--project=${this.project}` : '';
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`gcloud storage buckets create gs://${this.bucket} --location=${this.region} ${projectFlag}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildVolumeFlags(mountdir: string): { volumeFlags: string; mountFlags: string } {
|
||||||
|
switch (this.storageType) {
|
||||||
|
case 'gcs-fuse':
|
||||||
|
if (!this.bucket) return { volumeFlags: '', mountFlags: '' };
|
||||||
|
return {
|
||||||
|
volumeFlags: `--add-volume=name=gcs-fuse,type=cloud-storage,bucket=${this.bucket}`,
|
||||||
|
mountFlags: `--add-volume-mount=volume=gcs-fuse,mount-path=${mountdir}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'nfs':
|
||||||
|
if (!this.filestoreIp) return { volumeFlags: '', mountFlags: '' };
|
||||||
|
return {
|
||||||
|
volumeFlags: `--add-volume=name=nfs-vol,type=nfs,location=${this.filestoreIp}:${this.filestoreShare}`,
|
||||||
|
mountFlags: `--add-volume-mount=volume=nfs-vol,mount-path=${mountdir}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'in-memory': {
|
||||||
|
const sizeGib = Math.min(this.diskSizeGb, 32);
|
||||||
|
return {
|
||||||
|
volumeFlags: `--add-volume=name=tmpfs-vol,type=in-memory,size-limit=${sizeGib}Gi`,
|
||||||
|
mountFlags: `--add-volume-mount=volume=tmpfs-vol,mount-path=${mountdir}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'gcs-copy':
|
||||||
|
// No volume mount — artifacts are copied in/out via gsutil commands
|
||||||
|
return { volumeFlags: '', mountFlags: '' };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { volumeFlags: '', mountFlags: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyArtifactsIn(mountdir: string): Promise<void> {
|
||||||
|
if (this.storageType !== 'gcs-copy' || !this.bucket) return;
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Copying artifacts from gs://${this.bucket} to ${mountdir}`);
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(`gcloud storage cp -r "gs://${this.bucket}/*" "${mountdir}/" || true`, false, true);
|
||||||
|
} catch {
|
||||||
|
OrchestratorLogger.log('[GCP Cloud Run] No existing artifacts to restore (bucket may be empty)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyArtifactsOut(mountdir: string): Promise<void> {
|
||||||
|
if (this.storageType !== 'gcs-copy' || !this.bucket) return;
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Uploading artifacts from ${mountdir} to gs://${this.bucket}`);
|
||||||
|
await OrchestratorSystem.Run(`gcloud storage cp -r "${mountdir}/*" "gs://${this.bucket}/"`, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runTaskInWorkflow(
|
||||||
|
buildGuid: string,
|
||||||
|
image: string,
|
||||||
|
commands: string,
|
||||||
|
mountdir: string,
|
||||||
|
workingdir: string,
|
||||||
|
environment: OrchestratorEnvironmentVariable[],
|
||||||
|
secrets: OrchestratorSecret[],
|
||||||
|
): Promise<string> {
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Running task for build ${buildGuid}`);
|
||||||
|
ResourceTracking.logAllocationSummary('gcp-cloud-run task');
|
||||||
|
|
||||||
|
const jobName = `unity-build-${buildGuid}`
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]/g, '-')
|
||||||
|
.slice(0, 63);
|
||||||
|
const projectFlag = this.project ? `--project=${this.project}` : '';
|
||||||
|
|
||||||
|
// Build environment variable flags
|
||||||
|
const envFlags = environment
|
||||||
|
.map((env) => `${env.name}=${env.value}`)
|
||||||
|
.concat(secrets.map((s) => `${s.EnvironmentVariable}=${s.ParameterValue}`));
|
||||||
|
const envString = envFlags.length > 0 ? `--set-env-vars="${envFlags.join(',')}"` : '';
|
||||||
|
|
||||||
|
// Build storage volume flags
|
||||||
|
const { volumeFlags, mountFlags } = this.buildVolumeFlags(mountdir);
|
||||||
|
|
||||||
|
// For gcs-copy, wrap the user command with copy-in/copy-out steps
|
||||||
|
let effectiveCommands = commands;
|
||||||
|
if (this.storageType === 'gcs-copy' && this.bucket && commands) {
|
||||||
|
effectiveCommands = [
|
||||||
|
`gcloud storage cp -r "gs://${this.bucket}/*" "${mountdir}/" 2>/dev/null || true`,
|
||||||
|
commands,
|
||||||
|
`gcloud storage cp -r "${mountdir}/*" "gs://${this.bucket}/"`,
|
||||||
|
].join(' && ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const saFlag = this.serviceAccount ? `--service-account=${this.serviceAccount}` : '';
|
||||||
|
const vpcFlag = this.vpcConnector ? `--vpc-connector=${this.vpcConnector}` : '';
|
||||||
|
|
||||||
|
// Create the Cloud Run Job
|
||||||
|
const createCmd = [
|
||||||
|
'gcloud run jobs create',
|
||||||
|
jobName,
|
||||||
|
`--image=${image}`,
|
||||||
|
`--region=${this.region}`,
|
||||||
|
'--task-timeout=86400s',
|
||||||
|
'--max-retries=0',
|
||||||
|
'--cpu=4',
|
||||||
|
'--memory=16Gi',
|
||||||
|
volumeFlags,
|
||||||
|
mountFlags,
|
||||||
|
envString,
|
||||||
|
saFlag,
|
||||||
|
vpcFlag,
|
||||||
|
projectFlag,
|
||||||
|
'--format=json',
|
||||||
|
'--quiet',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(createCmd);
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Job ${jobName} created`);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message?.includes('already exists')) {
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Job ${jobName} already exists, updating...`);
|
||||||
|
const updateCmd = createCmd.replace('jobs create', 'jobs update');
|
||||||
|
await OrchestratorSystem.Run(updateCmd);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the command if provided
|
||||||
|
if (effectiveCommands) {
|
||||||
|
const updateCmd = [
|
||||||
|
'gcloud run jobs update',
|
||||||
|
jobName,
|
||||||
|
`--region=${this.region}`,
|
||||||
|
'--command="/bin/sh"',
|
||||||
|
`--args="-c,${effectiveCommands}"`,
|
||||||
|
projectFlag,
|
||||||
|
'--quiet',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
await OrchestratorSystem.Run(updateCmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the job
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Executing job ${jobName} (storage: ${this.storageType})...`);
|
||||||
|
const executeCmd = [
|
||||||
|
'gcloud run jobs execute',
|
||||||
|
jobName,
|
||||||
|
`--region=${this.region}`,
|
||||||
|
projectFlag,
|
||||||
|
'--wait',
|
||||||
|
'--format=json',
|
||||||
|
'--quiet',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
try {
|
||||||
|
output = await OrchestratorSystem.Run(executeCmd);
|
||||||
|
OrchestratorLogger.log('[GCP Cloud Run] Job execution completed');
|
||||||
|
} catch (error: any) {
|
||||||
|
await this.streamJobLogs(jobName);
|
||||||
|
throw new Error(`[GCP Cloud Run] Job execution failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.streamJobLogs(jobName);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async streamJobLogs(jobName: string): Promise<void> {
|
||||||
|
const projectFlag = this.project ? `--project=${this.project}` : '';
|
||||||
|
try {
|
||||||
|
const logs = await OrchestratorSystem.Run(
|
||||||
|
`gcloud logging read "resource.type=cloud_run_job AND resource.labels.job_name=${jobName}" ${projectFlag} --limit=1000 --format="value(textPayload)" --order=asc`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (logs) {
|
||||||
|
for (const line of logs.split('\n')) {
|
||||||
|
if (line.trim()) {
|
||||||
|
OrchestratorLogger.log(`[Build] ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
OrchestratorLogger.logWarning('[GCP Cloud Run] Could not retrieve job logs');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupWorkflow(
|
||||||
|
buildParameters: BuildParameters,
|
||||||
|
branchName: string,
|
||||||
|
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
||||||
|
) {
|
||||||
|
OrchestratorLogger.log('[GCP Cloud Run] Cleaning up workflow');
|
||||||
|
}
|
||||||
|
|
||||||
|
async garbageCollect(
|
||||||
|
filter: string,
|
||||||
|
previewOnly: boolean,
|
||||||
|
olderThan: Number,
|
||||||
|
fullCache: boolean,
|
||||||
|
baseDependencies: boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
OrchestratorLogger.log('[GCP Cloud Run] Garbage collecting old jobs');
|
||||||
|
const projectFlag = this.project ? `--project=${this.project}` : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jobsJson = await OrchestratorSystem.Run(
|
||||||
|
`gcloud run jobs list --region=${this.region} ${projectFlag} --filter="metadata.name~unity-build-" --format="json(metadata.name,metadata.creationTimestamp)"`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const jobs = JSON.parse(jobsJson || '[]');
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - Number(olderThan));
|
||||||
|
|
||||||
|
let deletedCount = 0;
|
||||||
|
for (const job of jobs) {
|
||||||
|
const createdAt = new Date(job.metadata?.creationTimestamp || 0);
|
||||||
|
if (createdAt < cutoffDate) {
|
||||||
|
const name = job.metadata?.name;
|
||||||
|
if (previewOnly) {
|
||||||
|
OrchestratorLogger.log(`[GCP Cloud Run] Would delete: ${name}`);
|
||||||
|
} else {
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`gcloud run jobs delete ${name} --region=${this.region} ${projectFlag} --quiet`,
|
||||||
|
);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Garbage collected ${deletedCount} Cloud Run jobs`;
|
||||||
|
} catch (error: any) {
|
||||||
|
OrchestratorLogger.logWarning(`[GCP Cloud Run] Garbage collection failed: ${error.message}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResources(): Promise<ProviderResource[]> {
|
||||||
|
const projectFlag = this.project ? `--project=${this.project}` : '';
|
||||||
|
try {
|
||||||
|
const jobsJson = await OrchestratorSystem.Run(
|
||||||
|
`gcloud run jobs list --region=${this.region} ${projectFlag} --filter="metadata.name~unity-build-" --format="json(metadata.name)"`,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const jobs = JSON.parse(jobsJson || '[]');
|
||||||
|
return jobs.map((job: any) => ({ Name: job.metadata?.name || '' }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listWorkflow(): Promise<ProviderWorkflow[]> {
|
||||||
|
throw new Error('[GCP Cloud Run] listWorkflow not implemented for this experimental provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
async watchWorkflow(): Promise<string> {
|
||||||
|
throw new Error('[GCP Cloud Run] watchWorkflow not implemented for this experimental provider');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GcpCloudRunProvider;
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import GitHubActionsProvider from '.';
|
||||||
|
import BuildParameters from '../../../build-parameters';
|
||||||
|
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
|
||||||
|
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||||
|
import * as core from '@actions/core';
|
||||||
|
|
||||||
|
jest.mock('../../services/core/orchestrator-system');
|
||||||
|
jest.mock('../../services/core/orchestrator-logger');
|
||||||
|
jest.mock('@actions/core', () => ({
|
||||||
|
info: jest.fn(),
|
||||||
|
warning: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
setOutput: jest.fn(),
|
||||||
|
getInput: jest.fn(() => ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
|
||||||
|
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
|
||||||
|
|
||||||
|
function createBuildParameters(overrides: Partial<BuildParameters> = {}): BuildParameters {
|
||||||
|
return {
|
||||||
|
githubActionsRepo: 'owner/repo',
|
||||||
|
githubActionsWorkflow: 'build.yml',
|
||||||
|
githubActionsToken: 'ghp_test_token_123',
|
||||||
|
githubActionsRef: 'main',
|
||||||
|
...overrides,
|
||||||
|
} as BuildParameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override setTimeout to execute callbacks immediately so polling loops complete fast
|
||||||
|
const originalSetTimeout = global.setTimeout;
|
||||||
|
beforeAll(() => {
|
||||||
|
global.setTimeout = ((fn: (...args: any[]) => void, _ms?: number, ...args: any[]) => {
|
||||||
|
return originalSetTimeout(fn, 0, ...args);
|
||||||
|
}) as any;
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
global.setTimeout = originalSetTimeout;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GitHubActionsProvider', () => {
|
||||||
|
let provider: GitHubActionsProvider;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
provider = new GitHubActionsProvider(createBuildParameters());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('sets default ref to main when not specified', () => {
|
||||||
|
const params = createBuildParameters({ githubActionsRef: undefined });
|
||||||
|
const p = new GitHubActionsProvider(params);
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses provided ref when specified', () => {
|
||||||
|
const params = createBuildParameters({ githubActionsRef: 'develop' });
|
||||||
|
const p = new GitHubActionsProvider(params);
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setupWorkflow', () => {
|
||||||
|
it('verifies workflow exists via gh api and logs success', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('12345\n');
|
||||||
|
|
||||||
|
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
|
||||||
|
|
||||||
|
expect(mockRun).toHaveBeenCalledTimes(1);
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain('gh api repos/owner/repo/actions/workflows/build.yml');
|
||||||
|
expect(command).toContain("--jq '.id'");
|
||||||
|
expect(command).toContain('GH_TOKEN=ghp_test_token_123');
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Workflow verified'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when repo is not configured', async () => {
|
||||||
|
const params = createBuildParameters({ githubActionsRepo: '' });
|
||||||
|
provider = new GitHubActionsProvider(params);
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
|
||||||
|
'githubActionsRepo and githubActionsWorkflow are required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when workflow is not configured', async () => {
|
||||||
|
const params = createBuildParameters({ githubActionsWorkflow: '' });
|
||||||
|
provider = new GitHubActionsProvider(params);
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
|
||||||
|
'githubActionsRepo and githubActionsWorkflow are required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when token is missing', async () => {
|
||||||
|
const params = createBuildParameters({ githubActionsToken: '' });
|
||||||
|
provider = new GitHubActionsProvider(params);
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
|
||||||
|
'githubActionsToken is required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws descriptive error when workflow verification fails', async () => {
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('Not Found'));
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
|
||||||
|
'Failed to verify workflow build.yml in owner/repo',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runTaskInWorkflow', () => {
|
||||||
|
it('dispatches workflow with correct inputs and returns logs on success', async () => {
|
||||||
|
// Dispatch succeeds
|
||||||
|
mockRun.mockResolvedValueOnce('');
|
||||||
|
// First poll finds the run
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 99001, status: 'in_progress' }));
|
||||||
|
// Status poll returns completed
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' }));
|
||||||
|
// Log fetch succeeds
|
||||||
|
mockRun.mockResolvedValueOnce('Build output log content here');
|
||||||
|
|
||||||
|
const result = await provider.runTaskInWorkflow(
|
||||||
|
'guid-abc',
|
||||||
|
'unityci/editor:2021.3',
|
||||||
|
'echo build',
|
||||||
|
'/mount',
|
||||||
|
'/work',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('Build output log content here');
|
||||||
|
|
||||||
|
// Verify dispatch command
|
||||||
|
const dispatchCommand = mockRun.mock.calls[0][0];
|
||||||
|
expect(dispatchCommand).toContain('dispatches');
|
||||||
|
expect(dispatchCommand).toContain('-X POST');
|
||||||
|
expect(dispatchCommand).toContain("ref='main'");
|
||||||
|
|
||||||
|
// Verify log fetch command
|
||||||
|
const logCommand = mockRun.mock.calls[3][0];
|
||||||
|
expect(logCommand).toContain('gh run view');
|
||||||
|
expect(logCommand).toContain('--log');
|
||||||
|
expect(logCommand).toContain('--repo owner/repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('base64 encodes commands in the inputs payload', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(''); // dispatch
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 100, status: 'completed' })); // run found
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); // status
|
||||||
|
mockRun.mockResolvedValueOnce('logs'); // logs
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-1', 'image:latest', 'echo hello && build', '/mnt', '/w', [], []);
|
||||||
|
|
||||||
|
const dispatchCommand = mockRun.mock.calls[0][0];
|
||||||
|
const expectedB64 = Buffer.from('echo hello && build').toString('base64');
|
||||||
|
expect(dispatchCommand).toContain(expectedB64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes environment variables as JSON input', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(''); // dispatch
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 200, status: 'completed' })); // run found
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); // status
|
||||||
|
mockRun.mockResolvedValueOnce('logs'); // logs
|
||||||
|
|
||||||
|
const env = [
|
||||||
|
{ name: 'UNITY_LICENSE', value: 'license-data' },
|
||||||
|
{ name: 'BUILD_TARGET', value: 'StandaloneWindows64' },
|
||||||
|
];
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-2', 'img', 'cmd', '/m', '/w', env as any, []);
|
||||||
|
|
||||||
|
const dispatchCommand = mockRun.mock.calls[0][0];
|
||||||
|
expect(dispatchCommand).toContain('UNITY_LICENSE');
|
||||||
|
expect(dispatchCommand).toContain('BUILD_TARGET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when workflow dispatch fails', async () => {
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('403 Forbidden'));
|
||||||
|
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-err', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'Failed to dispatch workflow',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when workflow run does not start within timeout', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(''); // dispatch succeeds
|
||||||
|
|
||||||
|
// All 30 poll attempts fail
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('not found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'Workflow run did not start within',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when workflow run fails with non-success conclusion', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(''); // dispatch
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 300, status: 'in_progress' })); // run appears
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'failure' })); // fails
|
||||||
|
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'Workflow run failed with conclusion: failure',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallback message when log fetch fails', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(''); // dispatch
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 400, status: 'completed' })); // run appears
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); // completes
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('logs unavailable')); // log fetch fails
|
||||||
|
|
||||||
|
const result = await provider.runTaskInWorkflow('guid-nologs', 'img', 'cmd', '/m', '/w', [], []);
|
||||||
|
|
||||||
|
expect(result).toContain('completed successfully');
|
||||||
|
expect(result).toContain('logs unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles cancelled workflow run conclusion', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(''); // dispatch
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 500, status: 'in_progress' })); // run
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'cancelled' })); // cancelled
|
||||||
|
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'Workflow run failed with conclusion: cancelled',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws timeout error when polling exceeds maximum duration', async () => {
|
||||||
|
// Save real Date.now
|
||||||
|
const realDateNow = Date.now;
|
||||||
|
let callCount = 0;
|
||||||
|
|
||||||
|
// dispatch succeeds
|
||||||
|
mockRun.mockResolvedValueOnce('');
|
||||||
|
// run appears
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 600, status: 'in_progress' }));
|
||||||
|
// Status always returns in_progress
|
||||||
|
mockRun.mockImplementation(() => Promise.resolve(JSON.stringify({ status: 'in_progress' })));
|
||||||
|
|
||||||
|
// First call returns normal time, subsequent calls simulate 5 hours elapsed
|
||||||
|
Date.now = () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount <= 2) return realDateNow.call(Date);
|
||||||
|
return realDateNow.call(Date) + 14_400_001; // 4 hours + 1ms
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-poll-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'did not complete within 4 hours',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('did not complete within 4 hours'));
|
||||||
|
} finally {
|
||||||
|
Date.now = realDateNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanupWorkflow', () => {
|
||||||
|
it('completes without error and logs cleanup message', async () => {
|
||||||
|
await provider.cleanupWorkflow(createBuildParameters(), 'main', []);
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleanup complete'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('garbageCollect', () => {
|
||||||
|
it('returns empty string (no-op)', async () => {
|
||||||
|
const result = await provider.garbageCollect('', false, 0, false, false);
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listResources', () => {
|
||||||
|
it('returns runner names from the repository', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('runner-1\nrunner-2\nrunner-3\n');
|
||||||
|
|
||||||
|
const resources = await provider.listResources();
|
||||||
|
|
||||||
|
expect(resources).toHaveLength(3);
|
||||||
|
expect(resources[0].Name).toBe('runner-1');
|
||||||
|
expect(resources[1].Name).toBe('runner-2');
|
||||||
|
expect(resources[2].Name).toBe('runner-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when repo or token is missing', async () => {
|
||||||
|
const params = createBuildParameters({ githubActionsRepo: '' });
|
||||||
|
provider = new GitHubActionsProvider(params);
|
||||||
|
|
||||||
|
const resources = await provider.listResources();
|
||||||
|
expect(resources).toEqual([]);
|
||||||
|
expect(mockRun).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when API call fails', async () => {
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('API error'));
|
||||||
|
|
||||||
|
const resources = await provider.listResources();
|
||||||
|
expect(resources).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listWorkflow', () => {
|
||||||
|
it('returns recent workflow run names', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('Build Unity\nRun Tests\n');
|
||||||
|
|
||||||
|
const workflows = await provider.listWorkflow();
|
||||||
|
|
||||||
|
expect(workflows).toHaveLength(2);
|
||||||
|
expect(workflows[0].Name).toBe('Build Unity');
|
||||||
|
expect(workflows[1].Name).toBe('Run Tests');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when credentials missing', async () => {
|
||||||
|
const params = createBuildParameters({ githubActionsToken: '' });
|
||||||
|
provider = new GitHubActionsProvider(params);
|
||||||
|
|
||||||
|
const workflows = await provider.listWorkflow();
|
||||||
|
expect(workflows).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('watchWorkflow', () => {
|
||||||
|
it('returns message when no active run exists', async () => {
|
||||||
|
const result = await provider.watchWorkflow();
|
||||||
|
expect(result).toBe('No active run to watch');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
284
src/model/orchestrator/providers/github-actions/index.ts
Normal file
284
src/model/orchestrator/providers/github-actions/index.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import * as core from '@actions/core';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const MAX_POLLING_DURATION_MS = 14_400_000; // 4 hours
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub Actions provider — triggers builds as workflow_dispatch events
|
||||||
|
* on a target repository via the GitHub API.
|
||||||
|
*
|
||||||
|
* Use case: Distribute builds across orgs, use specialized runner pools,
|
||||||
|
* or trigger builds in repos with Unity licenses.
|
||||||
|
*/
|
||||||
|
class GitHubActionsProvider implements ProviderInterface {
|
||||||
|
private buildParameters: BuildParameters;
|
||||||
|
private repo: string;
|
||||||
|
private workflow: string;
|
||||||
|
private token: string;
|
||||||
|
private ref: string;
|
||||||
|
private runId: number = 0;
|
||||||
|
|
||||||
|
constructor(buildParameters: BuildParameters) {
|
||||||
|
this.buildParameters = buildParameters;
|
||||||
|
this.repo = buildParameters.githubActionsRepo || '';
|
||||||
|
this.workflow = buildParameters.githubActionsWorkflow || '';
|
||||||
|
this.token = buildParameters.githubActionsToken || '';
|
||||||
|
this.ref = buildParameters.githubActionsRef || 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }[],
|
||||||
|
): Promise<void> {
|
||||||
|
OrchestratorLogger.log(`[GitHubActions] Setting up workflow dispatch to ${this.repo}`);
|
||||||
|
|
||||||
|
if (!this.repo || !this.workflow) {
|
||||||
|
throw new Error('githubActionsRepo and githubActionsWorkflow are required for the github-actions provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.token) {
|
||||||
|
throw new Error('githubActionsToken is required (PAT with actions:write scope)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify repository and workflow exist
|
||||||
|
try {
|
||||||
|
const result = await OrchestratorSystem.Run(
|
||||||
|
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/workflows/${this.workflow} --jq '.id'`,
|
||||||
|
);
|
||||||
|
OrchestratorLogger.log(`[GitHubActions] Workflow verified: ${this.workflow} (ID: ${result.trim()})`);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to verify workflow ${this.workflow} in ${this.repo}: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runTaskInWorkflow(
|
||||||
|
buildGuid: string,
|
||||||
|
image: string,
|
||||||
|
commands: string,
|
||||||
|
mountdir: string,
|
||||||
|
workingdir: string,
|
||||||
|
environment: OrchestratorEnvironmentVariable[],
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
secrets: OrchestratorSecret[],
|
||||||
|
): Promise<string> {
|
||||||
|
OrchestratorLogger.log(`[GitHubActions] Dispatching workflow ${this.workflow} on ${this.repo}@${this.ref}`);
|
||||||
|
|
||||||
|
// Build inputs payload
|
||||||
|
const inputs: Record<string, string> = {
|
||||||
|
buildGuid,
|
||||||
|
image,
|
||||||
|
commands: Buffer.from(commands).toString('base64'),
|
||||||
|
mountdir,
|
||||||
|
workingdir,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add environment variables as a JSON input
|
||||||
|
if (environment.length > 0) {
|
||||||
|
inputs.environment = JSON.stringify(environment.map((element) => ({ name: element.name, value: element.value })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the time before dispatch to identify the run
|
||||||
|
const beforeDispatch = new Date().toISOString();
|
||||||
|
|
||||||
|
// Dispatch the workflow
|
||||||
|
const inputsJson = JSON.stringify(inputs).replace(/'/g, "'\\''");
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/workflows/${this.workflow}/dispatches -X POST -f ref='${this.ref}' -f "inputs=${inputsJson}"`,
|
||||||
|
);
|
||||||
|
OrchestratorLogger.log(`[GitHubActions] Workflow dispatched`);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to dispatch workflow: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for the run to appear
|
||||||
|
OrchestratorLogger.log(`[GitHubActions] Waiting for workflow run to start...`);
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 30;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
attempts++;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10_000));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runsJson = await OrchestratorSystem.Run(
|
||||||
|
`GH_TOKEN=${this.token} gh api "repos/${this.repo}/actions/workflows/${this.workflow}/runs?created=>${beforeDispatch}&per_page=5" --jq '.workflow_runs[0] | {id, status, conclusion}'`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = JSON.parse(runsJson.trim());
|
||||||
|
if (run.id) {
|
||||||
|
this.runId = run.id;
|
||||||
|
OrchestratorLogger.log(`[GitHubActions] Run started: ${this.runId} (status: ${run.status})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Run not yet available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.runId) {
|
||||||
|
throw new Error(`Workflow run did not start within ${maxAttempts * 10}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll until completion and stream logs (with maximum duration guard)
|
||||||
|
let status = 'in_progress';
|
||||||
|
const pollingStartTime = Date.now();
|
||||||
|
const runUrl = `https://github.com/${this.repo}/actions/runs/${this.runId}`;
|
||||||
|
|
||||||
|
while (status === 'in_progress' || status === 'queued') {
|
||||||
|
const elapsedMs = Date.now() - pollingStartTime;
|
||||||
|
if (elapsedMs >= MAX_POLLING_DURATION_MS) {
|
||||||
|
const hours = Math.round(MAX_POLLING_DURATION_MS / 3_600_000);
|
||||||
|
const message = `GitHub Actions workflow did not complete within ${hours} hours. Run URL: ${runUrl}`;
|
||||||
|
core.error(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusJson = await OrchestratorSystem.Run(
|
||||||
|
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runs/${this.runId} --jq '{status, conclusion}'`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = JSON.parse(statusJson.trim());
|
||||||
|
status = result.status;
|
||||||
|
|
||||||
|
if (status === 'completed') {
|
||||||
|
OrchestratorLogger.log(`[GitHubActions] Run ${this.runId} completed: ${result.conclusion}`);
|
||||||
|
|
||||||
|
if (result.conclusion !== 'success') {
|
||||||
|
throw new Error(`Workflow run failed with conclusion: ${result.conclusion}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
OrchestratorLogger.log(`[GitHubActions] Run ${this.runId} status: ${status}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message && error.message.includes('conclusion')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (error.message && error.message.includes('did not complete within')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
OrchestratorLogger.logWarning(`[GitHubActions] Status check error: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch logs
|
||||||
|
try {
|
||||||
|
const logs = await OrchestratorSystem.Run(
|
||||||
|
`GH_TOKEN=${this.token} gh run view ${this.runId} --repo ${this.repo} --log`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return logs;
|
||||||
|
} catch {
|
||||||
|
return `Run ${this.runId} completed successfully (logs unavailable)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }[],
|
||||||
|
): Promise<void> {
|
||||||
|
OrchestratorLogger.log(`[GitHubActions] Cleanup complete (no resources to tear down)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async 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 '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResources(): Promise<ProviderResource[]> {
|
||||||
|
if (!this.repo || !this.token) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runnersJson = await OrchestratorSystem.Run(
|
||||||
|
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runners --jq '.runners[] | .name'`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return runnersJson
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((name) => {
|
||||||
|
const resource = new ProviderResource();
|
||||||
|
resource.Name = name.trim();
|
||||||
|
|
||||||
|
return resource;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWorkflow(): Promise<ProviderWorkflow[]> {
|
||||||
|
if (!this.repo || !this.token) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runsJson = await OrchestratorSystem.Run(
|
||||||
|
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runs?per_page=10 --jq '.workflow_runs[] | .name'`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return runsJson
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((name) => {
|
||||||
|
const workflow = new ProviderWorkflow();
|
||||||
|
workflow.Name = name.trim();
|
||||||
|
|
||||||
|
return workflow;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async watchWorkflow(): Promise<string> {
|
||||||
|
if (!this.runId) return 'No active run to watch';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await OrchestratorSystem.Run(
|
||||||
|
`GH_TOKEN=${this.token} gh run watch ${this.runId} --repo ${this.repo}`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default GitHubActionsProvider;
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import GitLabCIProvider from '.';
|
||||||
|
import BuildParameters from '../../../build-parameters';
|
||||||
|
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
|
||||||
|
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||||
|
import * as core from '@actions/core';
|
||||||
|
|
||||||
|
jest.mock('../../services/core/orchestrator-system');
|
||||||
|
jest.mock('../../services/core/orchestrator-logger');
|
||||||
|
jest.mock('@actions/core', () => ({
|
||||||
|
info: jest.fn(),
|
||||||
|
warning: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
setOutput: jest.fn(),
|
||||||
|
getInput: jest.fn(() => ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
|
||||||
|
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
|
||||||
|
const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction<typeof OrchestratorLogger.logWarning>;
|
||||||
|
|
||||||
|
function createBuildParameters(overrides: Partial<BuildParameters> = {}): BuildParameters {
|
||||||
|
return {
|
||||||
|
gitlabProjectId: 'my-group/my-project',
|
||||||
|
gitlabTriggerToken: 'glptt-test-token-456',
|
||||||
|
gitlabApiUrl: 'https://gitlab.example.com',
|
||||||
|
gitlabRef: 'main',
|
||||||
|
...overrides,
|
||||||
|
} as BuildParameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override setTimeout to execute callbacks immediately so polling loops complete fast
|
||||||
|
const originalSetTimeout = global.setTimeout;
|
||||||
|
beforeAll(() => {
|
||||||
|
global.setTimeout = ((fn: (...args: any[]) => void, _ms?: number, ...args: any[]) => {
|
||||||
|
return originalSetTimeout(fn, 0, ...args);
|
||||||
|
}) as any;
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
global.setTimeout = originalSetTimeout;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GitLabCIProvider', () => {
|
||||||
|
let provider: GitLabCIProvider;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
provider = new GitLabCIProvider(createBuildParameters());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('strips trailing slashes from apiUrl', () => {
|
||||||
|
const params = createBuildParameters({ gitlabApiUrl: 'https://gitlab.example.com///' });
|
||||||
|
const p = new GitLabCIProvider(params);
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults apiUrl to https://gitlab.com when not provided', () => {
|
||||||
|
const params = createBuildParameters({ gitlabApiUrl: undefined });
|
||||||
|
const p = new GitLabCIProvider(params);
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults ref to main when not provided', () => {
|
||||||
|
const params = createBuildParameters({ gitlabRef: undefined });
|
||||||
|
const p = new GitLabCIProvider(params);
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setupWorkflow', () => {
|
||||||
|
it('verifies project access via curl and logs success', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('');
|
||||||
|
|
||||||
|
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
|
||||||
|
|
||||||
|
expect(mockRun).toHaveBeenCalledTimes(1);
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain('curl -sf');
|
||||||
|
expect(command).toContain('PRIVATE-TOKEN: glptt-test-token-456');
|
||||||
|
expect(command).toContain('gitlab.example.com/api/v4/projects/');
|
||||||
|
expect(command).toContain(encodeURIComponent('my-group/my-project'));
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Project access verified'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when projectId is not configured', async () => {
|
||||||
|
const params = createBuildParameters({ gitlabProjectId: '' });
|
||||||
|
provider = new GitLabCIProvider(params);
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
|
||||||
|
'gitlabProjectId and gitlabTriggerToken are required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when triggerToken is not configured', async () => {
|
||||||
|
const params = createBuildParameters({ gitlabTriggerToken: '' });
|
||||||
|
provider = new GitLabCIProvider(params);
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
|
||||||
|
'gitlabProjectId and gitlabTriggerToken are required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws descriptive error when project access check fails', async () => {
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('401 Unauthorized'));
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
|
||||||
|
'Failed to access GitLab project my-group/my-project',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runTaskInWorkflow', () => {
|
||||||
|
it('triggers pipeline and returns job logs on success', async () => {
|
||||||
|
// Pipeline trigger response
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5001, status: 'pending' }));
|
||||||
|
// Status poll returns success
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
|
||||||
|
// Jobs list
|
||||||
|
mockRun.mockResolvedValueOnce(
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: 10001, name: 'build-unity', status: 'success' },
|
||||||
|
{ id: 10002, name: 'test-unity', status: 'success' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
// Job traces
|
||||||
|
mockRun.mockResolvedValueOnce('Building Unity project...\nDone.');
|
||||||
|
mockRun.mockResolvedValueOnce('Running tests...\nAll passed.');
|
||||||
|
|
||||||
|
const result = await provider.runTaskInWorkflow(
|
||||||
|
'guid-gl1',
|
||||||
|
'unityci/editor:2021.3',
|
||||||
|
'echo build',
|
||||||
|
'/mount',
|
||||||
|
'/work',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain('build-unity');
|
||||||
|
expect(result).toContain('test-unity');
|
||||||
|
expect(result).toContain('Building Unity project');
|
||||||
|
expect(result).toContain('Running tests');
|
||||||
|
|
||||||
|
// Verify trigger command
|
||||||
|
const triggerCommand = mockRun.mock.calls[0][0];
|
||||||
|
expect(triggerCommand).toContain('trigger/pipeline');
|
||||||
|
expect(triggerCommand).toContain(`token=${createBuildParameters().gitlabTriggerToken}`);
|
||||||
|
expect(triggerCommand).toContain('ref=main');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes build variables including base64-encoded commands', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5002, status: 'success' }));
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify([]));
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow(
|
||||||
|
'guid-vars',
|
||||||
|
'ubuntu:20.04',
|
||||||
|
'make build',
|
||||||
|
'/mnt/data',
|
||||||
|
'/workspace',
|
||||||
|
[{ name: 'UNITY_VERSION', value: '2021.3.1f1' } as any],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const triggerCommand = mockRun.mock.calls[0][0];
|
||||||
|
const expectedB64 = Buffer.from('make build').toString('base64');
|
||||||
|
expect(triggerCommand).toContain(`variables[BUILD_COMMANDS]=${expectedB64}`);
|
||||||
|
expect(triggerCommand).toContain('variables[BUILD_GUID]=guid-vars');
|
||||||
|
expect(triggerCommand).toContain('variables[BUILD_IMAGE]=ubuntu:20.04');
|
||||||
|
expect(triggerCommand).toContain('variables[MOUNT_DIR]=/mnt/data');
|
||||||
|
expect(triggerCommand).toContain('variables[WORKING_DIR]=/workspace');
|
||||||
|
expect(triggerCommand).toContain('variables[UNITY_VERSION]=2021.3.1f1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when pipeline trigger fails', async () => {
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('404 Not Found'));
|
||||||
|
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-err', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'Failed to trigger pipeline',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when pipeline finishes with failure status', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5003, status: 'pending' }));
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'failed' }));
|
||||||
|
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'Pipeline 5003 finished with status: failed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when pipeline is canceled', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5004, status: 'pending' }));
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'canceled' }));
|
||||||
|
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'Pipeline 5004 finished with status: canceled',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles job log fetch failures gracefully', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5005, status: 'success' }));
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify([{ id: 20001, name: 'build', status: 'success' }]));
|
||||||
|
// Job trace fetch fails
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('trace unavailable'));
|
||||||
|
|
||||||
|
const result = await provider.runTaskInWorkflow('guid-nologs', 'img', 'cmd', '/m', '/w', [], []);
|
||||||
|
|
||||||
|
expect(result).toContain('build');
|
||||||
|
expect(result).toContain('logs unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallback message when entire job fetch fails', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5006, status: 'success' }));
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
|
||||||
|
// Jobs list fails
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('API error'));
|
||||||
|
|
||||||
|
const result = await provider.runTaskInWorkflow('guid-noapi', 'img', 'cmd', '/m', '/w', [], []);
|
||||||
|
|
||||||
|
expect(result).toContain('Pipeline 5006 completed successfully');
|
||||||
|
expect(result).toContain('logs unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues polling through status check errors until completion', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5007, status: 'pending' }));
|
||||||
|
// First status check fails
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('network blip'));
|
||||||
|
// Second status check succeeds
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
|
||||||
|
// Jobs/logs
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify([]));
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-retry', 'img', 'cmd', '/m', '/w', [], []);
|
||||||
|
|
||||||
|
expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Status check error'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws timeout error when polling exceeds maximum duration', async () => {
|
||||||
|
const realDateNow = Date.now;
|
||||||
|
let callCount = 0;
|
||||||
|
|
||||||
|
// Trigger pipeline succeeds
|
||||||
|
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5008, status: 'running' }));
|
||||||
|
// Status always returns running
|
||||||
|
mockRun.mockImplementation(() => Promise.resolve(JSON.stringify({ status: 'running' })));
|
||||||
|
|
||||||
|
// After first call, simulate 5 hours elapsed
|
||||||
|
Date.now = () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount <= 1) return realDateNow.call(Date);
|
||||||
|
return realDateNow.call(Date) + 14_400_001; // 4 hours + 1ms
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-poll-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'did not complete within 4 hours',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('did not complete within 4 hours'));
|
||||||
|
} finally {
|
||||||
|
Date.now = realDateNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanupWorkflow', () => {
|
||||||
|
it('completes without error and logs cleanup message', async () => {
|
||||||
|
await provider.cleanupWorkflow(createBuildParameters(), 'main', []);
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleanup complete'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('garbageCollect', () => {
|
||||||
|
it('returns empty string (no-op)', async () => {
|
||||||
|
const result = await provider.garbageCollect('', false, 0, false, false);
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listResources', () => {
|
||||||
|
it('returns empty array (not implemented)', async () => {
|
||||||
|
const resources = await provider.listResources();
|
||||||
|
expect(resources).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listWorkflow', () => {
|
||||||
|
it('returns recent pipeline names when credentials are available', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce(
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: 100, status: 'success' },
|
||||||
|
{ id: 101, status: 'failed' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const workflows = await provider.listWorkflow();
|
||||||
|
|
||||||
|
expect(workflows).toHaveLength(2);
|
||||||
|
expect(workflows[0].Name).toBe('Pipeline #100 (success)');
|
||||||
|
expect(workflows[1].Name).toBe('Pipeline #101 (failed)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when credentials are missing', async () => {
|
||||||
|
const params = createBuildParameters({ gitlabProjectId: '' });
|
||||||
|
provider = new GitLabCIProvider(params);
|
||||||
|
|
||||||
|
const workflows = await provider.listWorkflow();
|
||||||
|
expect(workflows).toEqual([]);
|
||||||
|
expect(mockRun).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when API call fails', async () => {
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('API error'));
|
||||||
|
|
||||||
|
const workflows = await provider.listWorkflow();
|
||||||
|
expect(workflows).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('watchWorkflow', () => {
|
||||||
|
it('returns empty string (not implemented)', async () => {
|
||||||
|
const result = await provider.watchWorkflow();
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
224
src/model/orchestrator/providers/gitlab-ci/index.ts
Normal file
224
src/model/orchestrator/providers/gitlab-ci/index.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import * as core from '@actions/core';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const MAX_POLLING_DURATION_MS = 14_400_000; // 4 hours
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitLab CI provider — triggers builds as GitLab CI pipelines
|
||||||
|
* via the GitLab API.
|
||||||
|
*
|
||||||
|
* Use case: Teams using GitLab CI, hybrid GitHub/GitLab setups,
|
||||||
|
* or GitLab runners with Unity licenses.
|
||||||
|
*/
|
||||||
|
class GitLabCIProvider implements ProviderInterface {
|
||||||
|
private buildParameters: BuildParameters;
|
||||||
|
private projectId: string;
|
||||||
|
private triggerToken: string;
|
||||||
|
private apiUrl: string;
|
||||||
|
private ref: string;
|
||||||
|
private pipelineId: number = 0;
|
||||||
|
|
||||||
|
constructor(buildParameters: BuildParameters) {
|
||||||
|
this.buildParameters = buildParameters;
|
||||||
|
this.projectId = buildParameters.gitlabProjectId || '';
|
||||||
|
this.triggerToken = buildParameters.gitlabTriggerToken || '';
|
||||||
|
this.apiUrl = (buildParameters.gitlabApiUrl || 'https://gitlab.com').replace(/\/+$/, '');
|
||||||
|
this.ref = buildParameters.gitlabRef || 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }[],
|
||||||
|
): Promise<void> {
|
||||||
|
OrchestratorLogger.log(`[GitLabCI] Setting up pipeline trigger for project ${this.projectId}`);
|
||||||
|
|
||||||
|
if (!this.projectId || !this.triggerToken) {
|
||||||
|
throw new Error('gitlabProjectId and gitlabTriggerToken are required for the gitlab-ci provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify project access
|
||||||
|
const encodedProject = encodeURIComponent(this.projectId);
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}" -o /dev/null`,
|
||||||
|
);
|
||||||
|
OrchestratorLogger.log(`[GitLabCI] Project access verified`);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to access GitLab project ${this.projectId}: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runTaskInWorkflow(
|
||||||
|
buildGuid: string,
|
||||||
|
image: string,
|
||||||
|
commands: string,
|
||||||
|
mountdir: string,
|
||||||
|
workingdir: string,
|
||||||
|
environment: OrchestratorEnvironmentVariable[],
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
secrets: OrchestratorSecret[],
|
||||||
|
): Promise<string> {
|
||||||
|
OrchestratorLogger.log(`[GitLabCI] Triggering pipeline on project ${this.projectId}@${this.ref}`);
|
||||||
|
|
||||||
|
const encodedProject = encodeURIComponent(this.projectId);
|
||||||
|
|
||||||
|
// Build variables for the pipeline
|
||||||
|
const pipelineVariables: string[] = [
|
||||||
|
`-f "variables[BUILD_GUID]=${buildGuid}"`,
|
||||||
|
`-f "variables[BUILD_IMAGE]=${image}"`,
|
||||||
|
`-f "variables[BUILD_COMMANDS]=${Buffer.from(commands).toString('base64')}"`,
|
||||||
|
`-f "variables[MOUNT_DIR]=${mountdir}"`,
|
||||||
|
`-f "variables[WORKING_DIR]=${workingdir}"`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const element of environment) {
|
||||||
|
pipelineVariables.push(`-f "variables[${element.name}]=${element.value}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger pipeline
|
||||||
|
try {
|
||||||
|
const response = await OrchestratorSystem.Run(
|
||||||
|
`curl -sf -X POST "${this.apiUrl}/api/v4/projects/${encodedProject}/trigger/pipeline" -f "token=${
|
||||||
|
this.triggerToken
|
||||||
|
}" -f "ref=${this.ref}" ${pipelineVariables.join(' ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipeline = JSON.parse(response);
|
||||||
|
this.pipelineId = pipeline.id;
|
||||||
|
OrchestratorLogger.log(`[GitLabCI] Pipeline triggered: ${this.pipelineId} (status: ${pipeline.status})`);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to trigger pipeline: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll until completion (with maximum duration guard)
|
||||||
|
let status = 'pending';
|
||||||
|
const terminalStatuses = new Set(['success', 'failed', 'canceled', 'skipped']);
|
||||||
|
const pollingStartTime = Date.now();
|
||||||
|
const pipelineUrl = `${this.apiUrl}/${this.projectId}/-/pipelines/${this.pipelineId}`;
|
||||||
|
|
||||||
|
while (!terminalStatuses.has(status)) {
|
||||||
|
const elapsedMs = Date.now() - pollingStartTime;
|
||||||
|
if (elapsedMs >= MAX_POLLING_DURATION_MS) {
|
||||||
|
const hours = Math.round(MAX_POLLING_DURATION_MS / 3_600_000);
|
||||||
|
const message = `GitLab CI pipeline did not complete within ${hours} hours. Pipeline URL: ${pipelineUrl}`;
|
||||||
|
core.error(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusResponse = await OrchestratorSystem.Run(
|
||||||
|
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines/${this.pipelineId}"`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipelineStatus = JSON.parse(statusResponse);
|
||||||
|
status = pipelineStatus.status;
|
||||||
|
OrchestratorLogger.log(`[GitLabCI] Pipeline ${this.pipelineId} status: ${status}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
OrchestratorLogger.logWarning(`[GitLabCI] Status check error: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== 'success') {
|
||||||
|
throw new Error(`Pipeline ${this.pipelineId} finished with status: ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch job logs
|
||||||
|
try {
|
||||||
|
const jobsResponse = await OrchestratorSystem.Run(
|
||||||
|
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines/${this.pipelineId}/jobs"`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const jobs = JSON.parse(jobsResponse);
|
||||||
|
const logs: string[] = [];
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
try {
|
||||||
|
const jobLog = await OrchestratorSystem.Run(
|
||||||
|
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/jobs/${job.id}/trace"`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
logs.push(`=== Job: ${job.name} (${job.status}) ===\n${jobLog}`);
|
||||||
|
} catch {
|
||||||
|
logs.push(`=== Job: ${job.name} (${job.status}) === (logs unavailable)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs.join('\n\n');
|
||||||
|
} catch {
|
||||||
|
return `Pipeline ${this.pipelineId} completed successfully (logs unavailable)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }[],
|
||||||
|
): Promise<void> {
|
||||||
|
OrchestratorLogger.log(`[GitLabCI] Cleanup complete`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async 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 '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResources(): Promise<ProviderResource[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWorkflow(): Promise<ProviderWorkflow[]> {
|
||||||
|
if (!this.projectId || !this.triggerToken) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encodedProject = encodeURIComponent(this.projectId);
|
||||||
|
const response = await OrchestratorSystem.Run(
|
||||||
|
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines?per_page=10"`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return JSON.parse(response).map((pipeline: any) => {
|
||||||
|
const workflow = new ProviderWorkflow();
|
||||||
|
workflow.Name = `Pipeline #${pipeline.id} (${pipeline.status})`;
|
||||||
|
|
||||||
|
return workflow;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async watchWorkflow(): Promise<string> {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default GitLabCIProvider;
|
||||||
460
src/model/orchestrator/providers/k8s/index.ts
Normal file
460
src/model/orchestrator/providers/k8s/index.ts
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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;
|
||||||
194
src/model/orchestrator/providers/k8s/kubernetes-pods.ts
Normal file
194
src/model/orchestrator/providers/k8s/kubernetes-pods.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
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;
|
||||||
53
src/model/orchestrator/providers/k8s/kubernetes-role.ts
Normal file
53
src/model/orchestrator/providers/k8s/kubernetes-role.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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 };
|
||||||
45
src/model/orchestrator/providers/k8s/kubernetes-secret.ts
Normal file
45
src/model/orchestrator/providers/k8s/kubernetes-secret.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
276
src/model/orchestrator/providers/k8s/kubernetes-storage.ts
Normal file
276
src/model/orchestrator/providers/k8s/kubernetes-storage.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
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;
|
||||||
763
src/model/orchestrator/providers/k8s/kubernetes-task-runner.ts
Normal file
763
src/model/orchestrator/providers/k8s/kubernetes-task-runner.ts
Normal file
@@ -0,0 +1,763 @@
|
|||||||
|
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;
|
||||||
87
src/model/orchestrator/providers/local/index.ts
Normal file
87
src/model/orchestrator/providers/local/index.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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;
|
||||||
278
src/model/orchestrator/providers/provider-git-manager.ts
Normal file
278
src/model/orchestrator/providers/provider-git-manager.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/model/orchestrator/providers/provider-interface.ts
Normal file
57
src/model/orchestrator/providers/provider-interface.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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>;
|
||||||
|
}
|
||||||
162
src/model/orchestrator/providers/provider-loader.ts
Normal file
162
src/model/orchestrator/providers/provider-loader.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
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',
|
||||||
|
cli: './cli',
|
||||||
|
test: './test',
|
||||||
|
'local-docker': './docker',
|
||||||
|
'local-system': './local',
|
||||||
|
local: './local',
|
||||||
|
'gcp-cloud-run': './gcp-cloud-run',
|
||||||
|
'azure-aci': './azure-aci',
|
||||||
|
};
|
||||||
|
|
||||||
|
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', 'cli', 'test', 'local-docker', 'local-system', 'local'];
|
||||||
|
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local', 'gcp-cloud-run', 'azure-aci'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/model/orchestrator/providers/provider-resource.ts
Normal file
3
src/model/orchestrator/providers/provider-resource.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export class ProviderResource {
|
||||||
|
public Name!: string;
|
||||||
|
}
|
||||||
164
src/model/orchestrator/providers/provider-selection.test.ts
Normal file
164
src/model/orchestrator/providers/provider-selection.test.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import BuildParameters from '../../build-parameters';
|
||||||
|
import RemotePowershellProvider from './remote-powershell';
|
||||||
|
import GitHubActionsProvider from './github-actions';
|
||||||
|
import GitLabCIProvider from './gitlab-ci';
|
||||||
|
import AnsibleProvider from './ansible';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for provider selection logic in Orchestrator.setProvider.
|
||||||
|
*
|
||||||
|
* These tests verify that the correct provider class is instantiated based on
|
||||||
|
* the providerStrategy field in BuildParameters. Rather than invoking the full
|
||||||
|
* Orchestrator.setProvider (which has heavy dependencies on OrchestratorOptions,
|
||||||
|
* AWS detection, etc.), we test the provider constructors directly to verify
|
||||||
|
* they produce the right provider type from the same build parameters the
|
||||||
|
* orchestrator switch statement uses.
|
||||||
|
*/
|
||||||
|
describe('Provider Selection', () => {
|
||||||
|
describe('remote-powershell provider', () => {
|
||||||
|
it('creates RemotePowershellProvider from build parameters', () => {
|
||||||
|
const params = {
|
||||||
|
providerStrategy: 'remote-powershell',
|
||||||
|
remotePowershellHost: 'build-server.local',
|
||||||
|
remotePowershellTransport: 'wsman',
|
||||||
|
remotePowershellCredential: 'user:pass',
|
||||||
|
} as BuildParameters;
|
||||||
|
|
||||||
|
const provider = new RemotePowershellProvider(params);
|
||||||
|
|
||||||
|
expect(provider).toBeInstanceOf(RemotePowershellProvider);
|
||||||
|
expect(provider.constructor.name).toBe('RemotePowershellProvider');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('github-actions provider', () => {
|
||||||
|
it('creates GitHubActionsProvider from build parameters', () => {
|
||||||
|
const params = {
|
||||||
|
providerStrategy: 'github-actions',
|
||||||
|
githubActionsRepo: 'org/repo',
|
||||||
|
githubActionsWorkflow: 'ci.yml',
|
||||||
|
githubActionsToken: 'ghp_token',
|
||||||
|
githubActionsRef: 'main',
|
||||||
|
} as BuildParameters;
|
||||||
|
|
||||||
|
const provider = new GitHubActionsProvider(params);
|
||||||
|
|
||||||
|
expect(provider).toBeInstanceOf(GitHubActionsProvider);
|
||||||
|
expect(provider.constructor.name).toBe('GitHubActionsProvider');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gitlab-ci provider', () => {
|
||||||
|
it('creates GitLabCIProvider from build parameters', () => {
|
||||||
|
const params = {
|
||||||
|
providerStrategy: 'gitlab-ci',
|
||||||
|
gitlabProjectId: 'group/project',
|
||||||
|
gitlabTriggerToken: 'glptt-token',
|
||||||
|
gitlabApiUrl: 'https://gitlab.com',
|
||||||
|
gitlabRef: 'main',
|
||||||
|
} as BuildParameters;
|
||||||
|
|
||||||
|
const provider = new GitLabCIProvider(params);
|
||||||
|
|
||||||
|
expect(provider).toBeInstanceOf(GitLabCIProvider);
|
||||||
|
expect(provider.constructor.name).toBe('GitLabCIProvider');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ansible provider', () => {
|
||||||
|
it('creates AnsibleProvider from build parameters', () => {
|
||||||
|
const params = {
|
||||||
|
providerStrategy: 'ansible',
|
||||||
|
ansibleInventory: '/etc/ansible/hosts',
|
||||||
|
ansiblePlaybook: '/playbooks/build.yml',
|
||||||
|
ansibleExtraVars: '',
|
||||||
|
ansibleVaultPassword: '',
|
||||||
|
} as BuildParameters;
|
||||||
|
|
||||||
|
const provider = new AnsibleProvider(params);
|
||||||
|
|
||||||
|
expect(provider).toBeInstanceOf(AnsibleProvider);
|
||||||
|
expect(provider.constructor.name).toBe('AnsibleProvider');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('provider strategy routing', () => {
|
||||||
|
it('each provider strategy maps to a distinct provider class', () => {
|
||||||
|
const strategies: Record<string, new (params: BuildParameters) => any> = {
|
||||||
|
'remote-powershell': RemotePowershellProvider,
|
||||||
|
'github-actions': GitHubActionsProvider,
|
||||||
|
'gitlab-ci': GitLabCIProvider,
|
||||||
|
ansible: AnsibleProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
remotePowershellHost: 'host',
|
||||||
|
remotePowershellTransport: 'wsman',
|
||||||
|
remotePowershellCredential: '',
|
||||||
|
githubActionsRepo: 'org/repo',
|
||||||
|
githubActionsWorkflow: 'ci.yml',
|
||||||
|
githubActionsToken: 'token',
|
||||||
|
githubActionsRef: 'main',
|
||||||
|
gitlabProjectId: 'proj',
|
||||||
|
gitlabTriggerToken: 'tok',
|
||||||
|
gitlabApiUrl: 'https://gitlab.com',
|
||||||
|
gitlabRef: 'main',
|
||||||
|
ansibleInventory: '/inv',
|
||||||
|
ansiblePlaybook: '/pb.yml',
|
||||||
|
ansibleExtraVars: '',
|
||||||
|
ansibleVaultPassword: '',
|
||||||
|
} as BuildParameters;
|
||||||
|
|
||||||
|
const instances = Object.entries(strategies).map(([strategy, ProviderClass]) => {
|
||||||
|
const provider = new ProviderClass(params);
|
||||||
|
return { strategy, className: provider.constructor.name };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify all four strategies produce different provider classes
|
||||||
|
const classNames = instances.map((i) => i.className);
|
||||||
|
const uniqueClassNames = new Set(classNames);
|
||||||
|
expect(uniqueClassNames.size).toBe(4);
|
||||||
|
|
||||||
|
// Verify expected mapping
|
||||||
|
expect(instances.find((i) => i.strategy === 'remote-powershell')!.className).toBe('RemotePowershellProvider');
|
||||||
|
expect(instances.find((i) => i.strategy === 'github-actions')!.className).toBe('GitHubActionsProvider');
|
||||||
|
expect(instances.find((i) => i.strategy === 'gitlab-ci')!.className).toBe('GitLabCIProvider');
|
||||||
|
expect(instances.find((i) => i.strategy === 'ansible')!.className).toBe('AnsibleProvider');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all providers implement ProviderInterface methods', () => {
|
||||||
|
const params = {
|
||||||
|
remotePowershellHost: 'host',
|
||||||
|
githubActionsRepo: 'org/repo',
|
||||||
|
githubActionsWorkflow: 'ci.yml',
|
||||||
|
githubActionsToken: 'token',
|
||||||
|
gitlabProjectId: 'proj',
|
||||||
|
gitlabTriggerToken: 'tok',
|
||||||
|
ansibleInventory: '/inv',
|
||||||
|
} as BuildParameters;
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
new RemotePowershellProvider(params),
|
||||||
|
new GitHubActionsProvider(params),
|
||||||
|
new GitLabCIProvider(params),
|
||||||
|
new AnsibleProvider(params),
|
||||||
|
];
|
||||||
|
|
||||||
|
const requiredMethods = [
|
||||||
|
'setupWorkflow',
|
||||||
|
'runTaskInWorkflow',
|
||||||
|
'cleanupWorkflow',
|
||||||
|
'garbageCollect',
|
||||||
|
'listResources',
|
||||||
|
'listWorkflow',
|
||||||
|
'watchWorkflow',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
for (const method of requiredMethods) {
|
||||||
|
expect(typeof (provider as any)[method]).toBe('function');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
138
src/model/orchestrator/providers/provider-url-parser.ts
Normal file
138
src/model/orchestrator/providers/provider-url-parser.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/model/orchestrator/providers/provider-workflow.ts
Normal file
3
src/model/orchestrator/providers/provider-workflow.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export class ProviderWorkflow {
|
||||||
|
public Name!: string;
|
||||||
|
}
|
||||||
166
src/model/orchestrator/providers/remote-powershell/index.ts
Normal file
166
src/model/orchestrator/providers/remote-powershell/index.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote PowerShell provider — executes Unity builds on remote machines
|
||||||
|
* via PowerShell Remoting (WinRM or SSH).
|
||||||
|
*
|
||||||
|
* Use case: Teams with dedicated build machines not part of a CI system.
|
||||||
|
*/
|
||||||
|
class RemotePowershellProvider implements ProviderInterface {
|
||||||
|
private buildParameters: BuildParameters;
|
||||||
|
private host: string;
|
||||||
|
private transport: string;
|
||||||
|
private credential: string;
|
||||||
|
private sessionId: string = '';
|
||||||
|
|
||||||
|
constructor(buildParameters: BuildParameters) {
|
||||||
|
this.buildParameters = buildParameters;
|
||||||
|
this.host = buildParameters.remotePowershellHost || '';
|
||||||
|
this.transport = buildParameters.remotePowershellTransport || 'wsman';
|
||||||
|
this.credential = buildParameters.remotePowershellCredential || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupWorkflow(
|
||||||
|
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 }[],
|
||||||
|
): Promise<void> {
|
||||||
|
OrchestratorLogger.log(`[RemotePowershell] Setting up remote session to ${this.host} via ${this.transport}`);
|
||||||
|
|
||||||
|
if (!this.host) {
|
||||||
|
throw new Error('remotePowershellHost is required for the remote-powershell provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connectivity
|
||||||
|
const testCommand = this.buildPwshCommand(`Test-WSMan -ComputerName "${this.host}" -ErrorAction Stop`);
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(testCommand);
|
||||||
|
OrchestratorLogger.log(`[RemotePowershell] Connection test passed`);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to connect to remote host ${this.host}: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionId = buildGuid;
|
||||||
|
OrchestratorLogger.log(`[RemotePowershell] Session ${this.sessionId} ready`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runTaskInWorkflow(
|
||||||
|
buildGuid: string,
|
||||||
|
image: string,
|
||||||
|
commands: string,
|
||||||
|
mountdir: string,
|
||||||
|
workingdir: string,
|
||||||
|
environment: OrchestratorEnvironmentVariable[],
|
||||||
|
secrets: OrchestratorSecret[],
|
||||||
|
): Promise<string> {
|
||||||
|
OrchestratorLogger.log(`[RemotePowershell] Executing task on ${this.host}`);
|
||||||
|
|
||||||
|
// Build environment variable block for remote session
|
||||||
|
const environmentBlock = environment.map((element) => `$env:${element.name} = '${element.value}'`).join('; ');
|
||||||
|
|
||||||
|
const secretBlock = secrets
|
||||||
|
.map((secret) => `$env:${secret.EnvironmentVariable} = '${secret.ParameterValue}'`)
|
||||||
|
.join('; ');
|
||||||
|
|
||||||
|
// Wrap commands for remote execution
|
||||||
|
const remoteScript = [environmentBlock, secretBlock, `Set-Location "${workingdir}"`, commands]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('; ');
|
||||||
|
|
||||||
|
const invokeCommand = this.buildInvokeCommand(remoteScript);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await OrchestratorSystem.Run(invokeCommand);
|
||||||
|
OrchestratorLogger.log(`[RemotePowershell] Task completed successfully`);
|
||||||
|
|
||||||
|
return output;
|
||||||
|
} catch (error: any) {
|
||||||
|
OrchestratorLogger.logWarning(`[RemotePowershell] Task failed: ${error.message || error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }[],
|
||||||
|
): Promise<void> {
|
||||||
|
OrchestratorLogger.log(`[RemotePowershell] Cleaning up session ${this.sessionId}`);
|
||||||
|
|
||||||
|
// Remote sessions are stateless per invocation — no cleanup needed
|
||||||
|
}
|
||||||
|
|
||||||
|
async 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> {
|
||||||
|
OrchestratorLogger.log(`[RemotePowershell] Garbage collection not supported for remote PowerShell provider`);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResources(): Promise<ProviderResource[]> {
|
||||||
|
const resource = new ProviderResource();
|
||||||
|
resource.Name = this.host;
|
||||||
|
|
||||||
|
return [resource];
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWorkflow(): Promise<ProviderWorkflow[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async watchWorkflow(): Promise<string> {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPwshCommand(script: string): string {
|
||||||
|
return `pwsh -NoProfile -NonInteractive -Command "${script.replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildInvokeCommand(remoteScript: string): string {
|
||||||
|
const escapedScript = remoteScript.replace(/"/g, '\\"').replace(/'/g, "''");
|
||||||
|
|
||||||
|
if (this.transport === 'ssh') {
|
||||||
|
return `pwsh -NoProfile -NonInteractive -Command "Invoke-Command -HostName '${this.host}' -ScriptBlock { ${escapedScript} }"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WinRM (default)
|
||||||
|
// Split on the FIRST colon only — passwords may contain colons
|
||||||
|
let credentialPart = '';
|
||||||
|
if (this.credential) {
|
||||||
|
const colonIndex = this.credential.indexOf(':');
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
throw new Error('remotePowershellCredential must be in "username:password" format (no colon found)');
|
||||||
|
}
|
||||||
|
const user = this.credential.substring(0, colonIndex);
|
||||||
|
const pass = this.credential.substring(colonIndex + 1);
|
||||||
|
credentialPart = `-Credential (New-Object PSCredential('${user}', (ConvertTo-SecureString '${pass}' -AsPlainText -Force)))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `pwsh -NoProfile -NonInteractive -Command "Invoke-Command -ComputerName '${this.host}' ${credentialPart} -ScriptBlock { ${escapedScript} }"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default RemotePowershellProvider;
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import RemotePowershellProvider from '.';
|
||||||
|
import BuildParameters from '../../../build-parameters';
|
||||||
|
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
|
||||||
|
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||||
|
|
||||||
|
jest.mock('../../services/core/orchestrator-system');
|
||||||
|
jest.mock('../../services/core/orchestrator-logger');
|
||||||
|
|
||||||
|
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
|
||||||
|
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
|
||||||
|
const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction<typeof OrchestratorLogger.logWarning>;
|
||||||
|
|
||||||
|
function createBuildParameters(overrides: Partial<BuildParameters> = {}): BuildParameters {
|
||||||
|
return {
|
||||||
|
remotePowershellHost: 'build-server-01.internal',
|
||||||
|
remotePowershellTransport: 'wsman',
|
||||||
|
remotePowershellCredential: 'admin:P@ssw0rd!',
|
||||||
|
...overrides,
|
||||||
|
} as BuildParameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RemotePowershellProvider', () => {
|
||||||
|
let provider: RemotePowershellProvider;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
provider = new RemotePowershellProvider(createBuildParameters());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('defaults transport to wsman when not specified', () => {
|
||||||
|
const params = createBuildParameters({ remotePowershellTransport: undefined });
|
||||||
|
const p = new RemotePowershellProvider(params);
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts ssh transport', () => {
|
||||||
|
const params = createBuildParameters({ remotePowershellTransport: 'ssh' });
|
||||||
|
const p = new RemotePowershellProvider(params);
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setupWorkflow', () => {
|
||||||
|
it('tests WinRM connectivity via Test-WSMan and logs success', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('wsman output');
|
||||||
|
|
||||||
|
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
|
||||||
|
|
||||||
|
expect(mockRun).toHaveBeenCalledTimes(1);
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain('pwsh -NoProfile -NonInteractive');
|
||||||
|
expect(command).toContain('Test-WSMan');
|
||||||
|
expect(command).toContain('build-server-01.internal');
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Connection test passed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets session ID to the build GUID', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('');
|
||||||
|
|
||||||
|
await provider.setupWorkflow('my-build-guid', createBuildParameters(), 'main', []);
|
||||||
|
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('my-build-guid'));
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ready'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when host is not configured', async () => {
|
||||||
|
const params = createBuildParameters({ remotePowershellHost: '' });
|
||||||
|
provider = new RemotePowershellProvider(params);
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
|
||||||
|
'remotePowershellHost is required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws descriptive error when connectivity test fails', async () => {
|
||||||
|
mockRun.mockRejectedValueOnce(new Error('WinRM service not running'));
|
||||||
|
|
||||||
|
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
|
||||||
|
'Failed to connect to remote host build-server-01.internal',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runTaskInWorkflow', () => {
|
||||||
|
it('constructs WinRM Invoke-Command with credential and returns output', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('Build succeeded!');
|
||||||
|
|
||||||
|
const result = await provider.runTaskInWorkflow(
|
||||||
|
'guid-run1',
|
||||||
|
'unused-image',
|
||||||
|
'Unity.exe -batchmode -buildTarget Win64',
|
||||||
|
'/mount',
|
||||||
|
'C:\\Projects\\MyGame',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('Build succeeded!');
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain('pwsh -NoProfile -NonInteractive');
|
||||||
|
expect(command).toContain("Invoke-Command -ComputerName 'build-server-01.internal'");
|
||||||
|
expect(command).toContain('-Credential');
|
||||||
|
expect(command).toContain('New-Object PSCredential');
|
||||||
|
expect(command).toContain('-ScriptBlock');
|
||||||
|
expect(command).toContain('Set-Location');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('constructs SSH Invoke-Command when transport is ssh', async () => {
|
||||||
|
const params = createBuildParameters({ remotePowershellTransport: 'ssh' });
|
||||||
|
provider = new RemotePowershellProvider(params);
|
||||||
|
mockRun.mockResolvedValueOnce('SSH build output');
|
||||||
|
|
||||||
|
const result = await provider.runTaskInWorkflow('guid-ssh', 'img', 'build', '/m', '/w', [], []);
|
||||||
|
|
||||||
|
expect(result).toBe('SSH build output');
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain("Invoke-Command -HostName 'build-server-01.internal'");
|
||||||
|
expect(command).not.toContain('-ComputerName');
|
||||||
|
expect(command).not.toContain('-Credential');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes environment variables in the remote script block', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('output');
|
||||||
|
|
||||||
|
const env = [
|
||||||
|
{ name: 'UNITY_LICENSE', value: 'license-data-abc' },
|
||||||
|
{ name: 'BUILD_TARGET', value: 'StandaloneWindows64' },
|
||||||
|
];
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-env', 'img', 'build-cmd', '/m', '/w', env as any, []);
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain('$env:UNITY_LICENSE');
|
||||||
|
expect(command).toContain('$env:BUILD_TARGET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes secrets in the remote script block', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('output');
|
||||||
|
|
||||||
|
const secrets = [{ ParameterKey: 'key1', EnvironmentVariable: 'SECRET_KEY', ParameterValue: 'secret-val-123' }];
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-sec', 'img', 'build-cmd', '/m', '/w', [], secrets as any);
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain('$env:SECRET_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include credential in plaintext log output when using WinRM', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('output');
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-cred', 'img', 'cmd', '/m', '/w', [], []);
|
||||||
|
|
||||||
|
// The credential is used via ConvertTo-SecureString, not logged directly
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain('ConvertTo-SecureString');
|
||||||
|
expect(command).toContain('-AsPlainText -Force');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits credential part when no credential is configured (WinRM)', async () => {
|
||||||
|
const params = createBuildParameters({ remotePowershellCredential: '' });
|
||||||
|
provider = new RemotePowershellProvider(params);
|
||||||
|
mockRun.mockResolvedValueOnce('output');
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-nocred', 'img', 'cmd', '/m', '/w', [], []);
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain("Invoke-Command -ComputerName 'build-server-01.internal'");
|
||||||
|
expect(command).not.toContain('-Credential');
|
||||||
|
expect(command).not.toContain('PSCredential');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws and logs warning when remote execution fails', async () => {
|
||||||
|
const execError = new Error('Remote execution failed: access denied');
|
||||||
|
mockRun.mockRejectedValueOnce(execError);
|
||||||
|
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'Remote execution failed',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Task failed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves passwords containing colons when splitting credentials', async () => {
|
||||||
|
const params = createBuildParameters({
|
||||||
|
remotePowershellCredential: 'admin:P@ss:w0rd:with:colons!',
|
||||||
|
});
|
||||||
|
provider = new RemotePowershellProvider(params);
|
||||||
|
mockRun.mockResolvedValueOnce('output');
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-colon', 'img', 'cmd', '/m', '/w', [], []);
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain("PSCredential('admin'");
|
||||||
|
expect(command).toContain("ConvertTo-SecureString 'P@ss:w0rd:with:colons!'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when credential has no colon separator', async () => {
|
||||||
|
const params = createBuildParameters({
|
||||||
|
remotePowershellCredential: 'nocolonhere',
|
||||||
|
});
|
||||||
|
provider = new RemotePowershellProvider(params);
|
||||||
|
|
||||||
|
await expect(provider.runTaskInWorkflow('guid-badcred', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
|
||||||
|
'username:password',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets working directory in the remote script', async () => {
|
||||||
|
mockRun.mockResolvedValueOnce('output');
|
||||||
|
|
||||||
|
await provider.runTaskInWorkflow('guid-wd', 'img', 'cmd', '/m', 'D:\\Builds\\Project', [], []);
|
||||||
|
|
||||||
|
const command = mockRun.mock.calls[0][0];
|
||||||
|
expect(command).toContain('Set-Location');
|
||||||
|
expect(command).toContain('D:\\Builds\\Project');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanupWorkflow', () => {
|
||||||
|
it('completes without error and logs session cleanup', async () => {
|
||||||
|
// Setup first to set sessionId
|
||||||
|
mockRun.mockResolvedValueOnce('');
|
||||||
|
await provider.setupWorkflow('guid-cleanup', createBuildParameters(), 'main', []);
|
||||||
|
|
||||||
|
await provider.cleanupWorkflow(createBuildParameters(), 'main', []);
|
||||||
|
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleaning up session'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('garbageCollect', () => {
|
||||||
|
it('returns empty string and logs not-supported message', async () => {
|
||||||
|
const result = await provider.garbageCollect('', false, 0, false, false);
|
||||||
|
expect(result).toBe('');
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('not supported'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listResources', () => {
|
||||||
|
it('returns the configured host as a resource', async () => {
|
||||||
|
const resources = await provider.listResources();
|
||||||
|
|
||||||
|
expect(resources).toHaveLength(1);
|
||||||
|
expect(resources[0].Name).toBe('build-server-01.internal');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listWorkflow', () => {
|
||||||
|
it('returns empty array (not implemented)', async () => {
|
||||||
|
const workflows = await provider.listWorkflow();
|
||||||
|
expect(workflows).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('watchWorkflow', () => {
|
||||||
|
it('returns empty string (not implemented)', async () => {
|
||||||
|
const result = await provider.watchWorkflow();
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
src/model/orchestrator/providers/test/index.ts
Normal file
67
src/model/orchestrator/providers/test/index.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user