mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-13 17:33:54 -07:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 936ca76c4e | |||
| 5f63107fa7 | |||
| cdb17b2a9d | |||
| 6f751bf476 | |||
| 49c3bcf0a5 | |||
| 54a6c80784 | |||
| c08d13e3a5 | |||
| f05cfe7036 | |||
| 18e20aaff1 | |||
| 52a5bc4a6d | |||
| 67fd293725 | |||
| f77a1350e6 | |||
| 7307bea200 | |||
| 3e1547170b | |||
| 2ef2275ae3 | |||
| 6c548cd3f7 | |||
| 02d4ec0dd2 | |||
| 81ba9c38af | |||
| 79ae55802d | |||
| e9c247f04f | |||
| 9789eb5c3b | |||
| b3bd405399 | |||
| 4d7e8717e9 | |||
| 120c3c5b24 | |||
| 40dd436000 | |||
| cff759721a | |||
| 1f3affe097 | |||
| ff56194b30 | |||
| 47670cf3ce | |||
| 4f07508484 | |||
| 7db70a712f | |||
| 12f287168d | |||
| 7f895304f4 | |||
| e4c156e7b0 | |||
| 8a41533779 | |||
| f4451060a7 | |||
| 17a0ea3776 | |||
| 7e9d0bf53e | |||
| d17b099593 | |||
| 8194790728 | |||
| 786ee3799c | |||
| f4bc5d20c4 | |||
| d8563369e1 |
+243
-9
@@ -105,6 +105,12 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
description: '[Orchestrator] Github private token to pull from github'
|
description: '[Orchestrator] Github private token to pull from github'
|
||||||
|
gitAuthMode:
|
||||||
|
required: false
|
||||||
|
default: 'header'
|
||||||
|
description:
|
||||||
|
'[Orchestrator] How git authentication is configured. "header" (default) uses http.extraHeader so the token
|
||||||
|
never appears in clone URLs or git config. "url" embeds the token in clone URLs (legacy behavior).'
|
||||||
githubOwner:
|
githubOwner:
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
@@ -194,6 +200,51 @@ inputs:
|
|||||||
description:
|
description:
|
||||||
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
|
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
|
||||||
be configured.'
|
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:
|
resourceTracking:
|
||||||
default: 'false'
|
default: 'false'
|
||||||
required: false
|
required: false
|
||||||
@@ -387,21 +438,204 @@ inputs:
|
|||||||
description:
|
description:
|
||||||
'Path to an external CLI executable that implements the provider protocol. Enables providers written in any
|
'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.'
|
language (Go, Python, Rust, shell). Uses JSON-over-stdin/stdout communication.'
|
||||||
middleware:
|
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
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
description:
|
description:
|
||||||
'[Orchestrator] Inline YAML middleware pipeline definitions. Middleware wraps around build phases with
|
'[Orchestrator] [Experimental] Google Cloud project ID for Cloud Run Jobs provider. Falls back to
|
||||||
before/after semantics, supports trigger conditions (phase, provider, platform, environment expressions), and
|
GOOGLE_CLOUD_PROJECT env var.'
|
||||||
resolves to either command hooks (inline in build container) or container hooks (separate Docker containers).
|
gcpRegion:
|
||||||
Middleware executes in priority order — lower priority values run first for before phases and last for after
|
|
||||||
phases (wrapping pattern). See documentation for full YAML format.'
|
|
||||||
middlewareFiles:
|
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
description:
|
description:
|
||||||
'[Orchestrator] Comma-separated list of middleware file names (without extension) to load from the
|
'[Orchestrator] [Experimental] Google Cloud region for Cloud Run Jobs (e.g. us-central1). Defaults to the region
|
||||||
game-ci/middleware/ directory in your repository. Each file should contain YAML middleware definitions.'
|
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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+3076
-458
File diff suppressed because it is too large
Load Diff
+1
-1
File diff suppressed because one or more lines are too long
@@ -4,6 +4,7 @@ import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output
|
|||||||
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 { BuildReliabilityService } from './model/orchestrator/services/reliability';
|
||||||
|
|
||||||
async function runMain() {
|
async function runMain() {
|
||||||
try {
|
try {
|
||||||
@@ -15,11 +16,38 @@ 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);
|
||||||
|
|
||||||
|
// Pre-build reliability checks
|
||||||
|
if (buildParameters.gitIntegrityCheck) {
|
||||||
|
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 (buildParameters.providerStrategy === 'local') {
|
if (buildParameters.providerStrategy === 'local') {
|
||||||
@@ -149,6 +177,13 @@ async function runMain() {
|
|||||||
exitCode = 0;
|
exitCode = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Set output
|
||||||
await Output.setBuildVersion(buildParameters.buildVersion);
|
await Output.setBuildVersion(buildParameters.buildVersion);
|
||||||
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
|
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ class BuildParameters {
|
|||||||
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 awsStackName!: string;
|
||||||
public awsEndpoint?: string;
|
public awsEndpoint?: string;
|
||||||
@@ -125,8 +132,60 @@ class BuildParameters {
|
|||||||
public gitHooksSkipList!: string;
|
public gitHooksSkipList!: string;
|
||||||
public gitHooksRunBeforeBuild!: string;
|
public gitHooksRunBeforeBuild!: string;
|
||||||
public providerExecutable!: string;
|
public providerExecutable!: string;
|
||||||
public middleware!: string;
|
public gitIntegrityCheck!: boolean;
|
||||||
public middlewareFiles!: string[];
|
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) {
|
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
|
||||||
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
|
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
|
||||||
@@ -215,6 +274,13 @@ class BuildParameters {
|
|||||||
containerRegistryRepository: Input.containerRegistryRepository,
|
containerRegistryRepository: Input.containerRegistryRepository,
|
||||||
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
||||||
providerStrategy: OrchestratorOptions.providerStrategy,
|
providerStrategy: OrchestratorOptions.providerStrategy,
|
||||||
|
gitAuthMode: OrchestratorOptions.gitAuthMode,
|
||||||
|
fallbackProviderStrategy: OrchestratorOptions.fallbackProviderStrategy,
|
||||||
|
runnerCheckEnabled: OrchestratorOptions.runnerCheckEnabled,
|
||||||
|
runnerCheckLabels: OrchestratorOptions.runnerCheckLabels,
|
||||||
|
runnerCheckMinAvailable: OrchestratorOptions.runnerCheckMinAvailable,
|
||||||
|
retryOnFallback: OrchestratorOptions.retryOnFallback,
|
||||||
|
providerInitTimeout: OrchestratorOptions.providerInitTimeout,
|
||||||
buildPlatform: OrchestratorOptions.buildPlatform,
|
buildPlatform: OrchestratorOptions.buildPlatform,
|
||||||
kubeConfig: OrchestratorOptions.kubeConfig,
|
kubeConfig: OrchestratorOptions.kubeConfig,
|
||||||
containerMemory: OrchestratorOptions.containerMemory,
|
containerMemory: OrchestratorOptions.containerMemory,
|
||||||
@@ -249,6 +315,27 @@ class BuildParameters {
|
|||||||
inputPullCommand: OrchestratorOptions.inputPullCommand,
|
inputPullCommand: OrchestratorOptions.inputPullCommand,
|
||||||
pullInputList: OrchestratorOptions.pullInputList,
|
pullInputList: OrchestratorOptions.pullInputList,
|
||||||
kubeStorageClass: OrchestratorOptions.kubeStorageClass,
|
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,
|
cacheKey: OrchestratorOptions.cacheKey,
|
||||||
maxRetainedWorkspaces: Number.parseInt(OrchestratorOptions.maxRetainedWorkspaces),
|
maxRetainedWorkspaces: Number.parseInt(OrchestratorOptions.maxRetainedWorkspaces),
|
||||||
useLargePackages: OrchestratorOptions.useLargePackages,
|
useLargePackages: OrchestratorOptions.useLargePackages,
|
||||||
@@ -282,8 +369,35 @@ class BuildParameters {
|
|||||||
gitHooksSkipList: Input.gitHooksSkipList,
|
gitHooksSkipList: Input.gitHooksSkipList,
|
||||||
gitHooksRunBeforeBuild: Input.gitHooksRunBeforeBuild,
|
gitHooksRunBeforeBuild: Input.gitHooksRunBeforeBuild,
|
||||||
providerExecutable: Input.providerExecutable,
|
providerExecutable: Input.providerExecutable,
|
||||||
middleware: OrchestratorOptions.middleware,
|
gitIntegrityCheck: Input.gitIntegrityCheck,
|
||||||
middlewareFiles: OrchestratorOptions.middlewareFiles,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+190
-4
@@ -358,12 +358,198 @@ class Input {
|
|||||||
return Input.getInput('providerExecutable') ?? '';
|
return Input.getInput('providerExecutable') ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get middleware(): string {
|
static get gitIntegrityCheck(): boolean {
|
||||||
return Input.getInput('middleware') ?? '';
|
const input = Input.getInput('gitIntegrityCheck') ?? 'false';
|
||||||
|
|
||||||
|
return input === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get middlewareFiles(): string {
|
static get gitAutoRecover(): boolean {
|
||||||
return Input.getInput('middlewareFiles') ?? '';
|
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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,14 +72,67 @@ export class OrchestratorFolders {
|
|||||||
return path.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `Library`);
|
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 {
|
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`;
|
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static get targetBuildRepoUrl(): string {
|
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`;
|
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() {
|
public static get buildVolumeFolder() {
|
||||||
return 'data';
|
return 'data';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -138,6 +138,36 @@ class OrchestratorOptions {
|
|||||||
return provider || 'local';
|
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 {
|
static get containerCpu(): string {
|
||||||
return OrchestratorOptions.getInput('containerCpu') || `1024`;
|
return OrchestratorOptions.getInput('containerCpu') || `1024`;
|
||||||
}
|
}
|
||||||
@@ -166,14 +196,6 @@ class OrchestratorOptions {
|
|||||||
return OrchestratorOptions.getInput('commandHookFiles')?.split(`,`) || [];
|
return OrchestratorOptions.getInput('commandHookFiles')?.split(`,`) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get middleware(): string {
|
|
||||||
return OrchestratorOptions.getInput('middleware') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get middlewareFiles(): string[] {
|
|
||||||
return OrchestratorOptions.getInput('middlewareFiles')?.split(',') || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### ### ###
|
// ### ### ###
|
||||||
// Custom commands from yaml parameters
|
// Custom commands from yaml parameters
|
||||||
// ### ### ###
|
// ### ### ###
|
||||||
@@ -198,6 +220,10 @@ class OrchestratorOptions {
|
|||||||
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
|
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get secretSource(): string {
|
||||||
|
return OrchestratorOptions.getInput('secretSource') || '';
|
||||||
|
}
|
||||||
|
|
||||||
static get inputPullCommand(): string {
|
static get inputPullCommand(): string {
|
||||||
const value = OrchestratorOptions.getInput('inputPullCommand');
|
const value = OrchestratorOptions.getInput('inputPullCommand');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import * as core from '@actions/core';
|
||||||
import Input from '../../input';
|
import Input from '../../input';
|
||||||
import { GenericInputReader } from '../../input-readers/generic-input-reader';
|
import { GenericInputReader } from '../../input-readers/generic-input-reader';
|
||||||
import OrchestratorOptions from './orchestrator-options';
|
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[]) => {
|
const formatFunction = (value: string, arguments_: any[]) => {
|
||||||
for (const element of arguments_) {
|
for (const element of arguments_) {
|
||||||
@@ -13,8 +16,6 @@ const formatFunction = (value: string, arguments_: any[]) => {
|
|||||||
class OrchestratorQueryOverride {
|
class OrchestratorQueryOverride {
|
||||||
static queryOverrides: { [key: string]: string } | undefined;
|
static queryOverrides: { [key: string]: string } | undefined;
|
||||||
|
|
||||||
// TODO accept premade secret sources or custom secret source definition yamls
|
|
||||||
|
|
||||||
public static query(key: string, alternativeKey: string) {
|
public static query(key: string, alternativeKey: string) {
|
||||||
if (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) {
|
if (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) {
|
||||||
return OrchestratorQueryOverride.queryOverrides[key];
|
return OrchestratorQueryOverride.queryOverrides[key];
|
||||||
@@ -49,14 +50,62 @@ class OrchestratorQueryOverride {
|
|||||||
throw new Error(`Should not be trying to run override query on ${query}`);
|
throw new Error(`Should not be trying to run override query on ${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await GenericInputReader.Run(
|
// 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 }]),
|
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() {
|
public static async PopulateQueryOverrideInput() {
|
||||||
const queries = OrchestratorOptions.pullInputList;
|
const queries = OrchestratorOptions.pullInputList;
|
||||||
OrchestratorQueryOverride.queryOverrides = {};
|
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) {
|
for (const element of queries) {
|
||||||
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
|
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
|
||||||
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);
|
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import OrchestratorEnvironmentVariable from './options/orchestrator-environment-
|
|||||||
import TestOrchestrator from './providers/test';
|
import TestOrchestrator from './providers/test';
|
||||||
import LocalOrchestrator from './providers/local';
|
import LocalOrchestrator from './providers/local';
|
||||||
import LocalDockerOrchestrator from './providers/docker';
|
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 loadProvider from './providers/provider-loader';
|
||||||
import GitHub from '../github';
|
import GitHub from '../github';
|
||||||
import SharedWorkspaceLocking from './services/core/shared-workspace-locking';
|
import SharedWorkspaceLocking from './services/core/shared-workspace-locking';
|
||||||
@@ -20,6 +26,7 @@ import { FollowLogStreamService } from './services/core/follow-log-stream-servic
|
|||||||
import OrchestratorResult from './services/core/orchestrator-result';
|
import OrchestratorResult from './services/core/orchestrator-result';
|
||||||
import OrchestratorOptions from './options/orchestrator-options';
|
import OrchestratorOptions from './options/orchestrator-options';
|
||||||
import ResourceTracking from './services/core/resource-tracking';
|
import ResourceTracking from './services/core/resource-tracking';
|
||||||
|
import { RunnerAvailabilityService } from './services/core/runner-availability-service';
|
||||||
|
|
||||||
class Orchestrator {
|
class Orchestrator {
|
||||||
public static Provider: ProviderInterface;
|
public static Provider: ProviderInterface;
|
||||||
@@ -76,6 +83,42 @@ class Orchestrator {
|
|||||||
private static async setupSelectedBuildPlatform() {
|
private static async setupSelectedBuildPlatform() {
|
||||||
OrchestratorLogger.log(`Orchestrator platform selected ${Orchestrator.buildParameters.providerStrategy}`);
|
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
|
// Detect LocalStack endpoints and handle AWS provider appropriately
|
||||||
// AWS_FORCE_PROVIDER options:
|
// AWS_FORCE_PROVIDER options:
|
||||||
// - 'aws': Force AWS provider (requires LocalStack Pro with ECS support)
|
// - 'aws': Force AWS provider (requires LocalStack Pro with ECS support)
|
||||||
@@ -169,6 +212,25 @@ class Orchestrator {
|
|||||||
case 'local':
|
case 'local':
|
||||||
Orchestrator.Provider = new LocalOrchestrator();
|
Orchestrator.Provider = new LocalOrchestrator();
|
||||||
break;
|
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:
|
default:
|
||||||
// Try to load provider using the dynamic loader for unknown providers
|
// Try to load provider using the dynamic loader for unknown providers
|
||||||
try {
|
try {
|
||||||
@@ -193,6 +255,30 @@ class Orchestrator {
|
|||||||
if (baseImage.includes(`undefined`)) {
|
if (baseImage.includes(`undefined`)) {
|
||||||
throw new Error(`baseImage is 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);
|
await Orchestrator.setup(buildParameters);
|
||||||
|
|
||||||
// When aws-local mode is enabled, validate AWS CloudFormation templates
|
// When aws-local mode is enabled, validate AWS CloudFormation templates
|
||||||
@@ -200,12 +286,10 @@ class Orchestrator {
|
|||||||
if (Orchestrator.validateAwsTemplates) {
|
if (Orchestrator.validateAwsTemplates) {
|
||||||
await Orchestrator.validateAwsCloudFormationTemplates();
|
await Orchestrator.validateAwsCloudFormationTemplates();
|
||||||
}
|
}
|
||||||
await Orchestrator.Provider.setupWorkflow(
|
|
||||||
Orchestrator.buildParameters.buildGuid,
|
// Setup workflow with optional init timeout
|
||||||
Orchestrator.buildParameters,
|
await Orchestrator.setupWorkflowWithTimeout();
|
||||||
Orchestrator.buildParameters.branch,
|
|
||||||
Orchestrator.defaultSecrets,
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
if (buildParameters.maxRetainedWorkspaces > 0) {
|
if (buildParameters.maxRetainedWorkspaces > 0) {
|
||||||
Orchestrator.lockedWorkspace = SharedWorkspaceLocking.NewWorkspaceName();
|
Orchestrator.lockedWorkspace = SharedWorkspaceLocking.NewWorkspaceName();
|
||||||
@@ -286,6 +370,39 @@ class Orchestrator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
private static async updateStatusWithBuildParameters() {
|
||||||
const content = { ...Orchestrator.buildParameters };
|
const content = { ...Orchestrator.buildParameters };
|
||||||
content.gitPrivateToken = ``;
|
content.gitPrivateToken = ``;
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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,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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -63,6 +63,8 @@ export default async function loadProvider(
|
|||||||
'local-docker': './docker',
|
'local-docker': './docker',
|
||||||
'local-system': './local',
|
'local-system': './local',
|
||||||
local: './local',
|
local: './local',
|
||||||
|
'gcp-cloud-run': './gcp-cloud-run',
|
||||||
|
'azure-aci': './azure-aci',
|
||||||
};
|
};
|
||||||
|
|
||||||
modulePath = providerModuleMap[providerSource] || providerSource;
|
modulePath = providerModuleMap[providerSource] || providerSource;
|
||||||
@@ -138,6 +140,7 @@ export class ProviderLoader {
|
|||||||
*/
|
*/
|
||||||
static getAvailableProviders(): string[] {
|
static getAvailableProviders(): string[] {
|
||||||
return ['aws', 'k8s', 'cli', 'test', 'local-docker', 'local-system', 'local'];
|
return ['aws', 'k8s', 'cli', 'test', 'local-docker', 'local-system', 'local'];
|
||||||
|
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local', 'gcp-cloud-run', 'azure-aci'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
+264
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -332,6 +332,7 @@ export class RemoteClient {
|
|||||||
|
|
||||||
RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
|
RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
|
||||||
await OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
|
await OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
|
||||||
|
await OrchestratorFolders.configureGitAuth();
|
||||||
RemoteClientLogger.log(`Cloning the repository being built:`);
|
RemoteClientLogger.log(`Cloning the repository being built:`);
|
||||||
await OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
|
await OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
|
||||||
await OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
|
await OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
|
||||||
@@ -441,12 +442,7 @@ export class RemoteClient {
|
|||||||
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
|
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
|
||||||
if (gitPrivateToken) {
|
if (gitPrivateToken) {
|
||||||
RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
|
RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
|
||||||
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
|
await RemoteClient.configureTokenAuth(gitPrivateToken);
|
||||||
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
|
|
||||||
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`git config --global url."https://${gitPrivateToken}@github.com/".insteadOf "https://github.com/"`,
|
|
||||||
);
|
|
||||||
await OrchestratorSystem.Run(`git lfs pull`, true);
|
await OrchestratorSystem.Run(`git lfs pull`, true);
|
||||||
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
||||||
RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
|
RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
|
||||||
@@ -462,12 +458,7 @@ export class RemoteClient {
|
|||||||
const githubToken = process.env.GITHUB_TOKEN;
|
const githubToken = process.env.GITHUB_TOKEN;
|
||||||
if (githubToken) {
|
if (githubToken) {
|
||||||
RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
|
RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
|
||||||
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
|
await RemoteClient.configureTokenAuth(githubToken);
|
||||||
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
|
|
||||||
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
|
|
||||||
await OrchestratorSystem.Run(
|
|
||||||
`git config --global url."https://${githubToken}@github.com/".insteadOf "https://github.com/"`,
|
|
||||||
);
|
|
||||||
await OrchestratorSystem.Run(`git lfs pull`, true);
|
await OrchestratorSystem.Run(`git lfs pull`, true);
|
||||||
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
||||||
RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
|
RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
|
||||||
@@ -531,4 +522,25 @@ export class RemoteClient {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure git authentication for a token. In header mode (default), uses
|
||||||
|
* http.extraHeader so the token never appears in URLs or git config output.
|
||||||
|
* In url mode (legacy), uses url.insteadOf to embed the token in URLs.
|
||||||
|
*/
|
||||||
|
private static async configureTokenAuth(token: string): Promise<void> {
|
||||||
|
if (OrchestratorFolders.useHeaderAuth) {
|
||||||
|
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
|
||||||
|
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
|
||||||
|
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
|
||||||
|
await OrchestratorSystem.Run(
|
||||||
|
`git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { FollowLogStreamService } from './follow-log-stream-service';
|
||||||
|
import * as core from '@actions/core';
|
||||||
|
import GitHub from '../../../github';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('../../../github', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
updateGitHubCheck: jest.fn(),
|
||||||
|
githubInputEnabled: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@actions/core', () => ({
|
||||||
|
warning: jest.fn(),
|
||||||
|
setOutput: jest.fn(),
|
||||||
|
setFailed: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
getInput: jest.fn().mockReturnValue(''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../orchestrator', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
buildParameters: {
|
||||||
|
logId: 'test-log-id-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../options/orchestrator-statics', () => ({
|
||||||
|
OrchestratorStatics: {
|
||||||
|
logPrefix: 'TEST',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./orchestrator-logger', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
log: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('FollowLogStreamService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
FollowLogStreamService.Reset();
|
||||||
|
FollowLogStreamService.errors = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reset', () => {
|
||||||
|
it('resets DidReceiveEndOfTransmission to false', () => {
|
||||||
|
FollowLogStreamService.DidReceiveEndOfTransmission = true;
|
||||||
|
FollowLogStreamService.Reset();
|
||||||
|
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleIteration', () => {
|
||||||
|
it('detects end of transmission marker', () => {
|
||||||
|
const result = FollowLogStreamService.handleIteration('---test-log-id-123', true, false, '');
|
||||||
|
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(true);
|
||||||
|
expect(result.shouldReadLogs).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not trigger end of transmission for non-matching log ID', () => {
|
||||||
|
const result = FollowLogStreamService.handleIteration('---different-log-id', true, false, '');
|
||||||
|
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(false);
|
||||||
|
expect(result.shouldReadLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Library rebuild message', () => {
|
||||||
|
FollowLogStreamService.handleIteration(
|
||||||
|
'Rebuilding Library because the asset database could not be found!',
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
expect(GitHub.updateGitHubCheck).toHaveBeenCalledWith('Library was not found, importing new Library', '');
|
||||||
|
expect(core.warning).toHaveBeenCalledWith('LIBRARY NOT FOUND!');
|
||||||
|
expect(core.setOutput).toHaveBeenCalledWith('library-found', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Build succeeded message', () => {
|
||||||
|
FollowLogStreamService.handleIteration('Build succeeded', true, false, '');
|
||||||
|
expect(GitHub.updateGitHubCheck).toHaveBeenCalledWith('Build succeeded', 'Build succeeded');
|
||||||
|
expect(core.setOutput).toHaveBeenCalledWith('build-result', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Build fail message', () => {
|
||||||
|
FollowLogStreamService.handleIteration('Build fail', true, false, '');
|
||||||
|
expect(GitHub.updateGitHubCheck).toHaveBeenCalled();
|
||||||
|
expect(core.setOutput).toHaveBeenCalledWith('build-result', 'failed');
|
||||||
|
expect(core.setFailed).toHaveBeenCalledWith('unity build failed');
|
||||||
|
expect(core.error).toHaveBeenCalledWith('BUILD FAILED!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates error messages with "error " pattern', () => {
|
||||||
|
FollowLogStreamService.handleIteration('error CS0001: Something went wrong', true, false, '');
|
||||||
|
expect(FollowLogStreamService.errors).toContain('error CS0001: Something went wrong');
|
||||||
|
expect(core.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates error messages with "error: " pattern', () => {
|
||||||
|
FollowLogStreamService.handleIteration('Fatal Error: Out of memory', true, false, '');
|
||||||
|
expect(FollowLogStreamService.errors).toContain('Fatal Error: Out of memory');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates "command failed: " messages', () => {
|
||||||
|
FollowLogStreamService.handleIteration('command failed: git pull', true, false, '');
|
||||||
|
expect(FollowLogStreamService.errors).toContain('command failed: git pull');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates "invalid " messages', () => {
|
||||||
|
FollowLogStreamService.handleIteration('invalid configuration value', true, false, '');
|
||||||
|
expect(FollowLogStreamService.errors).toContain('invalid configuration value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates "cannot be found" messages', () => {
|
||||||
|
FollowLogStreamService.handleIteration('Assembly cannot be found', true, false, '');
|
||||||
|
expect(FollowLogStreamService.errors).toContain('Assembly cannot be found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends message to output', () => {
|
||||||
|
const result = FollowLogStreamService.handleIteration('Some normal log line', true, false, 'previous output\n');
|
||||||
|
expect(result.output).toContain('Some normal log line');
|
||||||
|
expect(result.output).toContain('previous output');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves shouldCleanup value', () => {
|
||||||
|
const result = FollowLogStreamService.handleIteration('normal message', true, true, '');
|
||||||
|
expect(result.shouldCleanup).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not change shouldReadLogs for normal messages', () => {
|
||||||
|
const result = FollowLogStreamService.handleIteration('Just a regular build log', true, false, '');
|
||||||
|
expect(result.shouldReadLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes accumulated errors in Build fail GitHub check message', () => {
|
||||||
|
FollowLogStreamService.errors = '\nprevious error';
|
||||||
|
FollowLogStreamService.handleIteration('Build fail', true, false, '');
|
||||||
|
const updateCall = (GitHub.updateGitHubCheck as jest.Mock).mock.calls[0];
|
||||||
|
expect(updateCall[0]).toContain('previous error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { RunnerAvailabilityService } from './runner-availability-service';
|
||||||
|
|
||||||
|
// Mock @octokit/core
|
||||||
|
jest.mock('@octokit/core', () => ({
|
||||||
|
Octokit: jest.fn().mockImplementation(() => ({
|
||||||
|
request: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./orchestrator-logger', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
log: jest.fn(),
|
||||||
|
logWarning: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Octokit } from '@octokit/core';
|
||||||
|
|
||||||
|
const MockedOctokit = Octokit as jest.MockedClass<typeof Octokit>;
|
||||||
|
|
||||||
|
function createMockRunners(runners: Array<{ name: string; status: string; busy: boolean; labels: string[] }>) {
|
||||||
|
return runners.map((r, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: r.name,
|
||||||
|
status: r.status,
|
||||||
|
busy: r.busy,
|
||||||
|
labels: r.labels.map((l) => ({ name: l })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RunnerAvailabilityService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkAvailability', () => {
|
||||||
|
it('should skip check and not fallback when no token is provided', async () => {
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', '', [], 1);
|
||||||
|
expect(result.shouldFallback).toBe(false);
|
||||||
|
expect(result.reason).toContain('No GitHub token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback when no runners are registered', async () => {
|
||||||
|
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners: [] } });
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
||||||
|
expect(result.shouldFallback).toBe(true);
|
||||||
|
expect(result.reason).toContain('No runners registered');
|
||||||
|
expect(result.totalRunners).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not fallback when enough idle runners are available', async () => {
|
||||||
|
const runners = createMockRunners([
|
||||||
|
{ name: 'runner-1', status: 'online', busy: false, labels: ['self-hosted', 'linux'] },
|
||||||
|
{ name: 'runner-2', status: 'online', busy: false, labels: ['self-hosted', 'linux'] },
|
||||||
|
]);
|
||||||
|
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
||||||
|
expect(result.shouldFallback).toBe(false);
|
||||||
|
expect(result.idleRunners).toBe(2);
|
||||||
|
expect(result.totalRunners).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback when all runners are busy', async () => {
|
||||||
|
const runners = createMockRunners([
|
||||||
|
{ name: 'runner-1', status: 'online', busy: true, labels: ['self-hosted'] },
|
||||||
|
{ name: 'runner-2', status: 'online', busy: true, labels: ['self-hosted'] },
|
||||||
|
]);
|
||||||
|
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
||||||
|
expect(result.shouldFallback).toBe(true);
|
||||||
|
expect(result.idleRunners).toBe(0);
|
||||||
|
expect(result.matchingRunners).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback when all runners are offline', async () => {
|
||||||
|
const runners = createMockRunners([
|
||||||
|
{ name: 'runner-1', status: 'offline', busy: false, labels: ['self-hosted'] },
|
||||||
|
]);
|
||||||
|
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
||||||
|
expect(result.shouldFallback).toBe(true);
|
||||||
|
expect(result.idleRunners).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter runners by required labels', async () => {
|
||||||
|
const runners = createMockRunners([
|
||||||
|
{ name: 'linux-runner', status: 'online', busy: false, labels: ['self-hosted', 'linux'] },
|
||||||
|
{ name: 'windows-runner', status: 'online', busy: false, labels: ['self-hosted', 'windows'] },
|
||||||
|
]);
|
||||||
|
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability(
|
||||||
|
'owner',
|
||||||
|
'repo',
|
||||||
|
'token',
|
||||||
|
['self-hosted', 'linux'],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.shouldFallback).toBe(false);
|
||||||
|
expect(result.matchingRunners).toBe(1);
|
||||||
|
expect(result.idleRunners).toBe(1);
|
||||||
|
expect(result.totalRunners).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback when no runners match required labels', async () => {
|
||||||
|
const runners = createMockRunners([
|
||||||
|
{ name: 'windows-runner', status: 'online', busy: false, labels: ['self-hosted', 'windows'] },
|
||||||
|
]);
|
||||||
|
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability(
|
||||||
|
'owner',
|
||||||
|
'repo',
|
||||||
|
'token',
|
||||||
|
['self-hosted', 'linux'],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.shouldFallback).toBe(true);
|
||||||
|
expect(result.matchingRunners).toBe(0);
|
||||||
|
expect(result.idleRunners).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect minAvailable threshold', async () => {
|
||||||
|
const runners = createMockRunners([{ name: 'runner-1', status: 'online', busy: false, labels: ['self-hosted'] }]);
|
||||||
|
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
// Need 2, have 1 — should fallback
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 2);
|
||||||
|
expect(result.shouldFallback).toBe(true);
|
||||||
|
expect(result.idleRunners).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-insensitive for label matching', async () => {
|
||||||
|
const runners = createMockRunners([
|
||||||
|
{ name: 'runner-1', status: 'online', busy: false, labels: ['Self-Hosted', 'Linux'] },
|
||||||
|
]);
|
||||||
|
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability(
|
||||||
|
'owner',
|
||||||
|
'repo',
|
||||||
|
'token',
|
||||||
|
['self-hosted', 'linux'],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(result.shouldFallback).toBe(false);
|
||||||
|
expect(result.matchingRunners).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not fallback on API error (fail-open)', async () => {
|
||||||
|
const mockRequest = jest.fn().mockRejectedValue(new Error('403 Forbidden'));
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
||||||
|
expect(result.shouldFallback).toBe(false);
|
||||||
|
expect(result.reason).toContain('Runner check failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should count only online+idle runners', async () => {
|
||||||
|
const runners = createMockRunners([
|
||||||
|
{ name: 'idle', status: 'online', busy: false, labels: ['self-hosted'] },
|
||||||
|
{ name: 'busy', status: 'online', busy: true, labels: ['self-hosted'] },
|
||||||
|
{ name: 'offline', status: 'offline', busy: false, labels: ['self-hosted'] },
|
||||||
|
]);
|
||||||
|
const mockRequest = jest.fn().mockResolvedValue({ status: 200, data: { runners } });
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
||||||
|
expect(result.shouldFallback).toBe(false);
|
||||||
|
expect(result.totalRunners).toBe(3);
|
||||||
|
expect(result.matchingRunners).toBe(3);
|
||||||
|
expect(result.idleRunners).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pagination limits', () => {
|
||||||
|
it('should stop paginating after reaching the page limit', async () => {
|
||||||
|
// Return full pages (100 runners each) to force continued pagination
|
||||||
|
let callCount = 0;
|
||||||
|
const mockRequest = jest.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
const runners = createMockRunners(
|
||||||
|
Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
name: `runner-${callCount}-${i}`,
|
||||||
|
status: 'online' as const,
|
||||||
|
busy: false,
|
||||||
|
labels: ['self-hosted'],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.resolve({ status: 200, data: { runners } });
|
||||||
|
});
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
||||||
|
|
||||||
|
// Should have called at most 100 pages (the MAX_PAGINATION_PAGES limit)
|
||||||
|
expect(mockRequest).toHaveBeenCalledTimes(100);
|
||||||
|
// Should still have runners from the pages it did fetch
|
||||||
|
expect(result.totalRunners).toBe(10000);
|
||||||
|
expect(result.shouldFallback).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop paginating on rate limit (HTTP 403)', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const mockRequest = jest.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 2) {
|
||||||
|
// Octokit throws for non-2xx responses
|
||||||
|
const error: any = new Error('API rate limit exceeded');
|
||||||
|
error.status = 403;
|
||||||
|
error.response = {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 3600) },
|
||||||
|
};
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
const runners = createMockRunners(
|
||||||
|
Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
name: `runner-${i}`,
|
||||||
|
status: 'online' as const,
|
||||||
|
busy: false,
|
||||||
|
labels: ['self-hosted'],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.resolve({ status: 200, data: { runners } });
|
||||||
|
});
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
||||||
|
|
||||||
|
// Should have stopped at page 2 (rate limited)
|
||||||
|
expect(mockRequest).toHaveBeenCalledTimes(2);
|
||||||
|
// Should use the 100 runners from the first page
|
||||||
|
expect(result.totalRunners).toBe(100);
|
||||||
|
expect(result.shouldFallback).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop paginating on rate limit (HTTP 429)', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const mockRequest = jest.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
// Octokit throws for non-2xx responses
|
||||||
|
const error: any = new Error('Too Many Requests');
|
||||||
|
error.status = 429;
|
||||||
|
error.response = { status: 429, headers: {} };
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({ status: 200, data: { runners: [] } });
|
||||||
|
});
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
||||||
|
|
||||||
|
// Should have stopped at first page (rate limited immediately)
|
||||||
|
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||||
|
// No runners found — should fallback
|
||||||
|
expect(result.totalRunners).toBe(0);
|
||||||
|
expect(result.shouldFallback).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle pagination timeout gracefully', async () => {
|
||||||
|
// Mock Date.now to simulate timeout
|
||||||
|
const originalDateNow = Date.now;
|
||||||
|
let callCount = 0;
|
||||||
|
|
||||||
|
const mockRequest = jest.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
// After first call, advance time past the timeout
|
||||||
|
if (callCount >= 2) {
|
||||||
|
Date.now = jest.fn(() => originalDateNow() + 31_000);
|
||||||
|
}
|
||||||
|
const runners = createMockRunners(
|
||||||
|
Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
name: `runner-${callCount}-${i}`,
|
||||||
|
status: 'online' as const,
|
||||||
|
busy: false,
|
||||||
|
labels: ['self-hosted'],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.resolve({ status: 200, data: { runners } });
|
||||||
|
});
|
||||||
|
MockedOctokit.mockImplementation(() => ({ request: mockRequest } as any));
|
||||||
|
|
||||||
|
const result = await RunnerAvailabilityService.checkAvailability('owner', 'repo', 'token', [], 1);
|
||||||
|
|
||||||
|
// Should have stopped after timeout was detected (2 pages: first succeeds, second triggers timeout check)
|
||||||
|
expect(mockRequest.mock.calls.length).toBeLessThanOrEqual(3);
|
||||||
|
// Should have runners from pages fetched before timeout
|
||||||
|
expect(result.totalRunners).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
Date.now = originalDateNow;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { Octokit } from '@octokit/core';
|
||||||
|
import OrchestratorLogger from './orchestrator-logger';
|
||||||
|
|
||||||
|
interface GitHubRunner {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: 'online' | 'offline';
|
||||||
|
busy: boolean;
|
||||||
|
labels: Array<{ name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunnerCheckResult {
|
||||||
|
shouldFallback: boolean;
|
||||||
|
reason: string;
|
||||||
|
totalRunners: number;
|
||||||
|
matchingRunners: number;
|
||||||
|
idleRunners: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of pages to fetch when paginating through GitHub API results.
|
||||||
|
* 100 pages * 100 per page = 10,000 runners maximum.
|
||||||
|
*/
|
||||||
|
const MAX_PAGINATION_PAGES = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total timeout in milliseconds for the pagination loop.
|
||||||
|
* Prevents indefinite API calls if GitHub is slow or pagination is unexpectedly deep.
|
||||||
|
*/
|
||||||
|
const PAGINATION_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks GitHub Actions runner availability to support automatic provider fallback.
|
||||||
|
*
|
||||||
|
* When a user configures `runnerCheckEnabled: true` with a `fallbackProviderStrategy`,
|
||||||
|
* this service queries the GitHub API for runner status before the build starts.
|
||||||
|
* If insufficient runners are available, the orchestrator routes to the fallback provider.
|
||||||
|
*/
|
||||||
|
export class RunnerAvailabilityService {
|
||||||
|
/**
|
||||||
|
* Check if enough runners are available to handle the build.
|
||||||
|
*
|
||||||
|
* @param owner - GitHub repository owner
|
||||||
|
* @param repo - GitHub repository name
|
||||||
|
* @param token - GitHub token with repo/actions scope
|
||||||
|
* @param requiredLabels - Labels runners must have (empty = any runner)
|
||||||
|
* @param minAvailable - Minimum idle runners required
|
||||||
|
* @returns RunnerCheckResult with decision and diagnostics
|
||||||
|
*/
|
||||||
|
static async checkAvailability(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
token: string,
|
||||||
|
requiredLabels: string[],
|
||||||
|
minAvailable: number,
|
||||||
|
): Promise<RunnerCheckResult> {
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
shouldFallback: false,
|
||||||
|
reason: 'No GitHub token available — skipping runner check',
|
||||||
|
totalRunners: 0,
|
||||||
|
matchingRunners: 0,
|
||||||
|
idleRunners: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const octokit = new Octokit({ auth: token });
|
||||||
|
|
||||||
|
// Fetch all runners for the repository
|
||||||
|
const runners = await RunnerAvailabilityService.fetchRunners(octokit, owner, repo);
|
||||||
|
|
||||||
|
if (runners.length === 0) {
|
||||||
|
return {
|
||||||
|
shouldFallback: true,
|
||||||
|
reason: 'No runners registered for this repository',
|
||||||
|
totalRunners: 0,
|
||||||
|
matchingRunners: 0,
|
||||||
|
idleRunners: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by required labels
|
||||||
|
const matching = RunnerAvailabilityService.filterByLabels(runners, requiredLabels);
|
||||||
|
|
||||||
|
// Count idle (online + not busy)
|
||||||
|
const idle = matching.filter((r) => r.status === 'online' && !r.busy);
|
||||||
|
|
||||||
|
const result: RunnerCheckResult = {
|
||||||
|
shouldFallback: idle.length < minAvailable,
|
||||||
|
reason:
|
||||||
|
idle.length >= minAvailable
|
||||||
|
? `${idle.length} idle runner(s) available (need ${minAvailable})`
|
||||||
|
: `Only ${idle.length} idle runner(s) available, need ${minAvailable}`,
|
||||||
|
totalRunners: runners.length,
|
||||||
|
matchingRunners: matching.length,
|
||||||
|
idleRunners: idle.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
// If the API call fails (permissions, rate limit, etc.), don't block the build
|
||||||
|
OrchestratorLogger.log(`Runner availability check failed: ${error.message}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldFallback: false,
|
||||||
|
reason: `Runner check failed (${error.message}) — proceeding with primary provider`,
|
||||||
|
totalRunners: 0,
|
||||||
|
matchingRunners: 0,
|
||||||
|
idleRunners: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all runners for a repository, handling pagination.
|
||||||
|
*
|
||||||
|
* Includes defensive limits:
|
||||||
|
* - Maximum page count (MAX_PAGINATION_PAGES) to prevent infinite loops
|
||||||
|
* - Total timeout (PAGINATION_TIMEOUT_MS) to prevent indefinite API calls
|
||||||
|
* - Rate-limit detection (HTTP 403/429 with X-RateLimit-Remaining header)
|
||||||
|
*/
|
||||||
|
private static async fetchRunners(octokit: Octokit, owner: string, repo: string): Promise<GitHubRunner[]> {
|
||||||
|
const allRunners: GitHubRunner[] = [];
|
||||||
|
let page = 1;
|
||||||
|
const perPage = 100;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (page <= MAX_PAGINATION_PAGES) {
|
||||||
|
// Check total timeout
|
||||||
|
if (Date.now() - startTime > PAGINATION_TIMEOUT_MS) {
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
`[RunnerAvailability] Pagination timeout reached after ${page - 1} pages and ${Date.now() - startTime}ms. ` +
|
||||||
|
`Using ${allRunners.length} runners found so far.`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: any;
|
||||||
|
try {
|
||||||
|
response = await octokit.request('GET /repos/{owner}/{repo}/actions/runners', {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
per_page: perPage,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
} catch (requestError: any) {
|
||||||
|
// Octokit throws for non-2xx responses. Check if this is a rate limit error.
|
||||||
|
const status = requestError.status ?? requestError.response?.status;
|
||||||
|
if (status === 403 || status === 429) {
|
||||||
|
const resetTime =
|
||||||
|
requestError.response?.headers?.['x-ratelimit-reset'] ?? requestError.headers?.['x-ratelimit-reset'];
|
||||||
|
const resetMessage = resetTime
|
||||||
|
? ` Resets at ${new Date(Number.parseInt(String(resetTime), 10) * 1000).toISOString()}`
|
||||||
|
: '';
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
`[RunnerAvailability] GitHub API rate limit reached (HTTP ${status}).${resetMessage} ` +
|
||||||
|
`Using ${allRunners.length} runners found so far.`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Re-throw non-rate-limit errors to be handled by the outer catch
|
||||||
|
throw requestError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runners = (response.data.runners || []) as GitHubRunner[];
|
||||||
|
allRunners.push(...runners);
|
||||||
|
|
||||||
|
if (runners.length < perPage) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page > MAX_PAGINATION_PAGES) {
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
`[RunnerAvailability] Maximum pagination limit reached (${MAX_PAGINATION_PAGES} pages). ` +
|
||||||
|
`Using ${allRunners.length} runners found so far.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allRunners.length === 0) {
|
||||||
|
OrchestratorLogger.log(
|
||||||
|
'[RunnerAvailability] No runners found. Possible causes: ' +
|
||||||
|
'wrong token permissions (needs repo or actions scope), ' +
|
||||||
|
'no self-hosted runners registered, ' +
|
||||||
|
'or runners are registered at the organization level instead of the repository.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allRunners;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter runners by required labels. A runner matches if it has ALL required labels.
|
||||||
|
* If requiredLabels is empty, all runners match.
|
||||||
|
*/
|
||||||
|
private static filterByLabels(runners: GitHubRunner[], requiredLabels: string[]): GitHubRunner[] {
|
||||||
|
if (requiredLabels.length === 0) return runners;
|
||||||
|
|
||||||
|
return runners.filter((runner) => {
|
||||||
|
const runnerLabelNames = runner.labels.map((l) => l.name.toLowerCase());
|
||||||
|
|
||||||
|
return requiredLabels.every((required) => runnerLabelNames.includes(required.toLowerCase()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { TaskParameterSerializer } from './task-parameter-serializer';
|
||||||
|
|
||||||
|
// Mock dependencies that TaskParameterSerializer uses internally
|
||||||
|
jest.mock('@actions/core', () => ({
|
||||||
|
getInput: jest.fn().mockReturnValue(''),
|
||||||
|
setOutput: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warning: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../options/orchestrator-options', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
getInput: jest.fn().mockReturnValue(undefined),
|
||||||
|
ToEnvVarFormat: (input: string) => {
|
||||||
|
if (input.toUpperCase() === input) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
.replace(/([A-Z])/g, ' $1')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/ /g, '_');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../options/orchestrator-options-reader', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
GetProperties: jest.fn().mockReturnValue([]),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../options/orchestrator-query-override', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
queryOverrides: undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../hooks/command-hook-service', () => ({
|
||||||
|
CommandHookService: {
|
||||||
|
getHooks: jest.fn().mockReturnValue([]),
|
||||||
|
getSecrets: jest.fn().mockReturnValue([]),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../input', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../github', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
githubInputEnabled: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TaskParameterSerializer', () => {
|
||||||
|
describe('ToEnvVarFormat', () => {
|
||||||
|
it('converts camelCase to UPPER_SNAKE_CASE', () => {
|
||||||
|
expect(TaskParameterSerializer.ToEnvVarFormat('targetPlatform')).toBe('TARGET_PLATFORM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts single word to uppercase', () => {
|
||||||
|
expect(TaskParameterSerializer.ToEnvVarFormat('version')).toBe('VERSION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves already-uppercase strings', () => {
|
||||||
|
expect(TaskParameterSerializer.ToEnvVarFormat('AWS_REGION')).toBe('AWS_REGION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multi-word camelCase', () => {
|
||||||
|
expect(TaskParameterSerializer.ToEnvVarFormat('buildPlatformTarget')).toBe('BUILD_PLATFORM_TARGET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles string starting with uppercase', () => {
|
||||||
|
expect(TaskParameterSerializer.ToEnvVarFormat('BuildGuid')).toBe('BUILD_GUID');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UndoEnvVarFormat', () => {
|
||||||
|
it('converts UPPER_SNAKE_CASE back to camelCase', () => {
|
||||||
|
expect(TaskParameterSerializer.UndoEnvVarFormat('TARGET_PLATFORM')).toBe('targetPlatform');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single word', () => {
|
||||||
|
expect(TaskParameterSerializer.UndoEnvVarFormat('VERSION')).toBe('version');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple underscores', () => {
|
||||||
|
expect(TaskParameterSerializer.UndoEnvVarFormat('BUILD_PLATFORM_TARGET')).toBe('buildPlatformTarget');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('round-trip conversion', () => {
|
||||||
|
it('ToEnvVarFormat -> UndoEnvVarFormat returns original for simple camelCase', () => {
|
||||||
|
const original = 'targetPlatform';
|
||||||
|
const envVar = TaskParameterSerializer.ToEnvVarFormat(original);
|
||||||
|
const roundTrip = TaskParameterSerializer.UndoEnvVarFormat(envVar);
|
||||||
|
expect(roundTrip).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips multi-word keys', () => {
|
||||||
|
const original = 'cacheKey';
|
||||||
|
const envVar = TaskParameterSerializer.ToEnvVarFormat(original);
|
||||||
|
const roundTrip = TaskParameterSerializer.UndoEnvVarFormat(envVar);
|
||||||
|
expect(roundTrip).toBe(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uniqBy', () => {
|
||||||
|
it('removes duplicates by key function', () => {
|
||||||
|
const items = [
|
||||||
|
{ name: 'A', value: '1' },
|
||||||
|
{ name: 'B', value: '2' },
|
||||||
|
{ name: 'A', value: '3' },
|
||||||
|
];
|
||||||
|
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].value).toBe('1');
|
||||||
|
expect(result[1].value).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all items when no duplicates', () => {
|
||||||
|
const items = [
|
||||||
|
{ name: 'A', value: '1' },
|
||||||
|
{ name: 'B', value: '2' },
|
||||||
|
{ name: 'C', value: '3' },
|
||||||
|
];
|
||||||
|
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty array', () => {
|
||||||
|
const result = TaskParameterSerializer.uniqBy([], (x) => x.name);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps first occurrence when duplicates exist', () => {
|
||||||
|
const items = [
|
||||||
|
{ name: 'KEY', value: 'first' },
|
||||||
|
{ name: 'KEY', value: 'second' },
|
||||||
|
{ name: 'KEY', value: 'third' },
|
||||||
|
];
|
||||||
|
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].value).toBe('first');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blockedParameterNames', () => {
|
||||||
|
it('contains expected blocked names', () => {
|
||||||
|
expect(TaskParameterSerializer.blockedParameterNames.has('0')).toBe(true);
|
||||||
|
expect(TaskParameterSerializer.blockedParameterNames.has('length')).toBe(true);
|
||||||
|
expect(TaskParameterSerializer.blockedParameterNames.has('prototype')).toBe(true);
|
||||||
|
expect(TaskParameterSerializer.blockedParameterNames.has('')).toBe(true);
|
||||||
|
expect(TaskParameterSerializer.blockedParameterNames.has('unityVersion')).toBe(true);
|
||||||
|
expect(TaskParameterSerializer.blockedParameterNames.has('CUSTOM_JOB')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block valid parameter names', () => {
|
||||||
|
expect(TaskParameterSerializer.blockedParameterNames.has('targetPlatform')).toBe(false);
|
||||||
|
expect(TaskParameterSerializer.blockedParameterNames.has('buildGuid')).toBe(false);
|
||||||
|
expect(TaskParameterSerializer.blockedParameterNames.has('cacheKey')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readDefaultSecrets', () => {
|
||||||
|
it('returns an array', () => {
|
||||||
|
const secrets = TaskParameterSerializer.readDefaultSecrets();
|
||||||
|
expect(Array.isArray(secrets)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes secrets from environment when present', () => {
|
||||||
|
const originalSerial = process.env.UNITY_SERIAL;
|
||||||
|
process.env.UNITY_SERIAL = 'test-serial';
|
||||||
|
|
||||||
|
const secrets = TaskParameterSerializer.readDefaultSecrets();
|
||||||
|
const serialSecret = secrets.find((s) => s.ParameterKey === 'UNITY_SERIAL');
|
||||||
|
expect(serialSecret).toBeDefined();
|
||||||
|
expect(serialSecret?.ParameterValue).toBe('test-serial');
|
||||||
|
|
||||||
|
if (originalSerial !== undefined) {
|
||||||
|
process.env.UNITY_SERIAL = originalSerial;
|
||||||
|
} else {
|
||||||
|
delete process.env.UNITY_SERIAL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes secrets not in environment', () => {
|
||||||
|
const originalSerial = process.env.UNITY_SERIAL;
|
||||||
|
delete process.env.UNITY_SERIAL;
|
||||||
|
|
||||||
|
const secrets = TaskParameterSerializer.readDefaultSecrets();
|
||||||
|
const serialSecret = secrets.find((s) => s.ParameterKey === 'UNITY_SERIAL');
|
||||||
|
expect(serialSecret).toBeUndefined();
|
||||||
|
|
||||||
|
if (originalSerial !== undefined) {
|
||||||
|
process.env.UNITY_SERIAL = originalSerial;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
import { MiddlewareService } from './middleware-service';
|
|
||||||
import { Middleware } from './middleware';
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
jest.mock('../../orchestrator', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: {
|
|
||||||
buildParameters: {
|
|
||||||
providerStrategy: 'aws',
|
|
||||||
targetPlatform: 'StandaloneLinux64',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../../options/orchestrator-options', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: {
|
|
||||||
providerStrategy: 'aws',
|
|
||||||
middlewareFiles: ['code-signing', 'cache-optimizer'],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../core/orchestrator-logger', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: {
|
|
||||||
log: jest.fn(),
|
|
||||||
logWarning: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('MiddlewareService', () => {
|
|
||||||
const originalEnv = process.env;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = originalEnv;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseMiddleware', () => {
|
|
||||||
it('should parse a single middleware definition', () => {
|
|
||||||
const yaml = `
|
|
||||||
name: test-middleware
|
|
||||||
type: command
|
|
||||||
priority: 50
|
|
||||||
trigger:
|
|
||||||
phase: [build]
|
|
||||||
before:
|
|
||||||
commands: echo "before"
|
|
||||||
after:
|
|
||||||
commands: echo "after"
|
|
||||||
`;
|
|
||||||
const result = MiddlewareService.parseMiddleware(yaml);
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].name).toBe('test-middleware');
|
|
||||||
expect(result[0].type).toBe('command');
|
|
||||||
expect(result[0].priority).toBe(50);
|
|
||||||
expect(result[0].trigger.phase).toEqual(['build']);
|
|
||||||
expect(result[0].before?.commands).toBe('echo "before"');
|
|
||||||
expect(result[0].after?.commands).toBe('echo "after"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse an array of middleware definitions', () => {
|
|
||||||
const yaml = `
|
|
||||||
- name: first
|
|
||||||
type: command
|
|
||||||
trigger:
|
|
||||||
phase: [setup]
|
|
||||||
before:
|
|
||||||
commands: echo "first"
|
|
||||||
- name: second
|
|
||||||
type: container
|
|
||||||
image: node:20
|
|
||||||
trigger:
|
|
||||||
phase: [build]
|
|
||||||
after:
|
|
||||||
commands: echo "second"
|
|
||||||
`;
|
|
||||||
const result = MiddlewareService.parseMiddleware(yaml);
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0].name).toBe('first');
|
|
||||||
expect(result[0].type).toBe('command');
|
|
||||||
expect(result[1].name).toBe('second');
|
|
||||||
expect(result[1].type).toBe('container');
|
|
||||||
expect(result[1].image).toBe('node:20');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle string shorthand for before/after', () => {
|
|
||||||
const yaml = `
|
|
||||||
name: shorthand
|
|
||||||
type: command
|
|
||||||
trigger:
|
|
||||||
phase: [build]
|
|
||||||
before: echo "shorthand before"
|
|
||||||
after: echo "shorthand after"
|
|
||||||
`;
|
|
||||||
const result = MiddlewareService.parseMiddleware(yaml);
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].before?.commands).toBe('echo "shorthand before"');
|
|
||||||
expect(result[0].after?.commands).toBe('echo "shorthand after"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse secrets', () => {
|
|
||||||
const yaml = `
|
|
||||||
name: with-secrets
|
|
||||||
type: container
|
|
||||||
trigger:
|
|
||||||
phase: [build]
|
|
||||||
secrets:
|
|
||||||
- name: MY_SECRET
|
|
||||||
value: secret-value
|
|
||||||
- name: ANOTHER_SECRET
|
|
||||||
after:
|
|
||||||
commands: echo "done"
|
|
||||||
`;
|
|
||||||
const result = MiddlewareService.parseMiddleware(yaml);
|
|
||||||
expect(result[0].secrets).toHaveLength(2);
|
|
||||||
expect(result[0].secrets[0].ParameterKey).toBe('MY_SECRET');
|
|
||||||
expect(result[0].secrets[0].ParameterValue).toBe('secret-value');
|
|
||||||
expect(result[0].secrets[1].ParameterKey).toBe('ANOTHER_SECRET');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default priority to 100', () => {
|
|
||||||
const yaml = `
|
|
||||||
name: no-priority
|
|
||||||
type: command
|
|
||||||
trigger:
|
|
||||||
phase: [build]
|
|
||||||
before: echo "test"
|
|
||||||
`;
|
|
||||||
const result = MiddlewareService.parseMiddleware(yaml);
|
|
||||||
expect(result[0].priority).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default type to command', () => {
|
|
||||||
const yaml = `
|
|
||||||
name: no-type
|
|
||||||
trigger:
|
|
||||||
phase: [build]
|
|
||||||
before: echo "test"
|
|
||||||
`;
|
|
||||||
const result = MiddlewareService.parseMiddleware(yaml);
|
|
||||||
expect(result[0].type).toBe('command');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array for empty input', () => {
|
|
||||||
expect(MiddlewareService.parseMiddleware('')).toEqual([]);
|
|
||||||
expect(MiddlewareService.parseMiddleware(' ')).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should normalize scalar trigger values to arrays', () => {
|
|
||||||
const yaml = `
|
|
||||||
name: scalar-triggers
|
|
||||||
type: command
|
|
||||||
trigger:
|
|
||||||
phase: build
|
|
||||||
provider: aws
|
|
||||||
platform: StandaloneLinux64
|
|
||||||
before: echo "test"
|
|
||||||
`;
|
|
||||||
const result = MiddlewareService.parseMiddleware(yaml);
|
|
||||||
expect(result[0].trigger.phase).toEqual(['build']);
|
|
||||||
expect(result[0].trigger.provider).toEqual(['aws']);
|
|
||||||
expect(result[0].trigger.platform).toEqual(['StandaloneLinux64']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle per-phase image override for container type', () => {
|
|
||||||
const yaml = `
|
|
||||||
name: multi-image
|
|
||||||
type: container
|
|
||||||
image: ubuntu
|
|
||||||
trigger:
|
|
||||||
phase: [build]
|
|
||||||
before:
|
|
||||||
image: node:20
|
|
||||||
commands: npm install
|
|
||||||
after:
|
|
||||||
image: python:3
|
|
||||||
commands: python verify.py
|
|
||||||
`;
|
|
||||||
const result = MiddlewareService.parseMiddleware(yaml);
|
|
||||||
expect(result[0].image).toBe('ubuntu');
|
|
||||||
expect(result[0].before?.image).toBe('node:20');
|
|
||||||
expect(result[0].after?.image).toBe('python:3');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('evaluateExpression', () => {
|
|
||||||
it('should evaluate equality expressions', () => {
|
|
||||||
process.env.TEST_VAR = 'hello';
|
|
||||||
expect(MiddlewareService.evaluateExpression("env.TEST_VAR == 'hello'")).toBe(true);
|
|
||||||
expect(MiddlewareService.evaluateExpression("env.TEST_VAR == 'world'")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should evaluate inequality expressions', () => {
|
|
||||||
process.env.TEST_VAR = 'hello';
|
|
||||||
expect(MiddlewareService.evaluateExpression("env.TEST_VAR != 'world'")).toBe(true);
|
|
||||||
expect(MiddlewareService.evaluateExpression("env.TEST_VAR != 'hello'")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should evaluate truthy expressions', () => {
|
|
||||||
process.env.DEFINED_VAR = 'yes';
|
|
||||||
expect(MiddlewareService.evaluateExpression('env.DEFINED_VAR')).toBe(true);
|
|
||||||
|
|
||||||
process.env.FALSE_VAR = 'false';
|
|
||||||
expect(MiddlewareService.evaluateExpression('env.FALSE_VAR')).toBe(false);
|
|
||||||
|
|
||||||
process.env.EMPTY_VAR = '';
|
|
||||||
expect(MiddlewareService.evaluateExpression('env.EMPTY_VAR')).toBe(false);
|
|
||||||
|
|
||||||
delete process.env.MISSING_VAR;
|
|
||||||
expect(MiddlewareService.evaluateExpression('env.MISSING_VAR')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should evaluate falsy expressions', () => {
|
|
||||||
delete process.env.MISSING_VAR;
|
|
||||||
expect(MiddlewareService.evaluateExpression('!env.MISSING_VAR')).toBe(true);
|
|
||||||
|
|
||||||
process.env.DEFINED_VAR = 'yes';
|
|
||||||
expect(MiddlewareService.evaluateExpression('!env.DEFINED_VAR')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default to true for unknown expressions', () => {
|
|
||||||
expect(MiddlewareService.evaluateExpression('some unknown expression')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle double-quoted values', () => {
|
|
||||||
process.env.TEST_VAR = 'hello';
|
|
||||||
expect(MiddlewareService.evaluateExpression('env.TEST_VAR == "hello"')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('evaluateTrigger', () => {
|
|
||||||
it('should match when phase matches', () => {
|
|
||||||
const trigger = { phase: ['build'] };
|
|
||||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(true);
|
|
||||||
expect(MiddlewareService.evaluateTrigger(trigger, 'setup')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should match multiple phases', () => {
|
|
||||||
const trigger = { phase: ['setup', 'build'] };
|
|
||||||
expect(MiddlewareService.evaluateTrigger(trigger, 'setup')).toBe(true);
|
|
||||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(true);
|
|
||||||
expect(MiddlewareService.evaluateTrigger(trigger, 'pre-build')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter by provider', () => {
|
|
||||||
const trigger = { phase: ['build'], provider: ['k8s'] };
|
|
||||||
// Mock provider is 'aws', so this should not match
|
|
||||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
|
|
||||||
|
|
||||||
const matchingTrigger = { phase: ['build'], provider: ['aws', 'k8s'] };
|
|
||||||
expect(MiddlewareService.evaluateTrigger(matchingTrigger, 'build')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter by platform', () => {
|
|
||||||
const trigger = { phase: ['build'], platform: ['StandaloneWindows64'] };
|
|
||||||
// Mock platform is 'StandaloneLinux64', so this should not match
|
|
||||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
|
|
||||||
|
|
||||||
const matchingTrigger = { phase: ['build'], platform: ['StandaloneLinux64'] };
|
|
||||||
expect(MiddlewareService.evaluateTrigger(matchingTrigger, 'build')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should evaluate when expressions', () => {
|
|
||||||
process.env.FEATURE_FLAG = 'true';
|
|
||||||
const trigger = { phase: ['build'], when: "env.FEATURE_FLAG == 'true'" };
|
|
||||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(true);
|
|
||||||
|
|
||||||
process.env.FEATURE_FLAG = 'false';
|
|
||||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should require all conditions to pass (AND logic)', () => {
|
|
||||||
process.env.FEATURE_FLAG = 'true';
|
|
||||||
const trigger = {
|
|
||||||
phase: ['build'],
|
|
||||||
provider: ['k8s'], // won't match (mock provider is 'aws')
|
|
||||||
when: "env.FEATURE_FLAG == 'true'", // would match
|
|
||||||
};
|
|
||||||
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveCommandHooks', () => {
|
|
||||||
const middleware: Middleware[] = [
|
|
||||||
{
|
|
||||||
name: 'low-priority',
|
|
||||||
type: 'command',
|
|
||||||
priority: 10,
|
|
||||||
trigger: { phase: ['build'] },
|
|
||||||
image: 'ubuntu',
|
|
||||||
before: { commands: 'echo "low-before"' },
|
|
||||||
after: { commands: 'echo "low-after"' },
|
|
||||||
secrets: [],
|
|
||||||
allowFailure: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'high-priority',
|
|
||||||
type: 'command',
|
|
||||||
priority: 90,
|
|
||||||
trigger: { phase: ['build'] },
|
|
||||||
image: 'ubuntu',
|
|
||||||
before: { commands: 'echo "high-before"' },
|
|
||||||
after: { commands: 'echo "high-after"' },
|
|
||||||
secrets: [],
|
|
||||||
allowFailure: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'container-type',
|
|
||||||
type: 'container',
|
|
||||||
priority: 50,
|
|
||||||
trigger: { phase: ['build'] },
|
|
||||||
image: 'node:20',
|
|
||||||
before: { commands: 'echo "container"' },
|
|
||||||
secrets: [],
|
|
||||||
allowFailure: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it('should only return command type middleware', () => {
|
|
||||||
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
|
|
||||||
expect(hooks).toHaveLength(2);
|
|
||||||
expect(hooks.every((h) => h.name.startsWith('middleware:'))).toBe(true);
|
|
||||||
expect(hooks.find((h) => h.name.includes('container-type'))).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should order before hooks by ascending priority', () => {
|
|
||||||
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
|
|
||||||
expect(hooks[0].name).toBe('middleware:low-priority:before');
|
|
||||||
expect(hooks[1].name).toBe('middleware:high-priority:before');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should order after hooks by descending priority (wrapping)', () => {
|
|
||||||
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'build', 'after');
|
|
||||||
expect(hooks[0].name).toBe('middleware:high-priority:after');
|
|
||||||
expect(hooks[1].name).toBe('middleware:low-priority:after');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter by phase', () => {
|
|
||||||
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'setup', 'before');
|
|
||||||
expect(hooks).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveContainerHooks', () => {
|
|
||||||
const middleware: Middleware[] = [
|
|
||||||
{
|
|
||||||
name: 'container-a',
|
|
||||||
type: 'container',
|
|
||||||
priority: 20,
|
|
||||||
trigger: { phase: ['pre-build'] },
|
|
||||||
image: 'ubuntu',
|
|
||||||
before: { commands: 'echo "a-before"' },
|
|
||||||
after: { commands: 'echo "a-after"' },
|
|
||||||
secrets: [],
|
|
||||||
allowFailure: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'container-b',
|
|
||||||
type: 'container',
|
|
||||||
priority: 80,
|
|
||||||
trigger: { phase: ['pre-build'] },
|
|
||||||
image: 'node:20',
|
|
||||||
before: { commands: 'echo "b-before"', image: 'python:3' },
|
|
||||||
secrets: [],
|
|
||||||
allowFailure: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it('should resolve container hooks with correct images', () => {
|
|
||||||
const hooks = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
|
|
||||||
expect(hooks).toHaveLength(2);
|
|
||||||
expect(hooks[0].image).toBe('ubuntu'); // default image from middleware
|
|
||||||
expect(hooks[1].image).toBe('python:3'); // per-phase override
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should propagate allowFailure', () => {
|
|
||||||
const hooks = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
|
|
||||||
expect(hooks[0].allowFailure).toBe(false);
|
|
||||||
expect(hooks[1].allowFailure).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should only include phases that have definitions', () => {
|
|
||||||
// container-b has no after phase
|
|
||||||
const hooks = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'after');
|
|
||||||
expect(hooks).toHaveLength(1);
|
|
||||||
expect(hooks[0].name).toBe('middleware:container-a:after');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getMiddleware', () => {
|
|
||||||
it('should sort by priority ascending', () => {
|
|
||||||
const yaml = `
|
|
||||||
- name: high
|
|
||||||
type: command
|
|
||||||
priority: 200
|
|
||||||
trigger:
|
|
||||||
phase: [build]
|
|
||||||
before: echo "high"
|
|
||||||
- name: low
|
|
||||||
type: command
|
|
||||||
priority: 5
|
|
||||||
trigger:
|
|
||||||
phase: [build]
|
|
||||||
before: echo "low"
|
|
||||||
- name: medium
|
|
||||||
type: command
|
|
||||||
priority: 50
|
|
||||||
trigger:
|
|
||||||
phase: [build]
|
|
||||||
before: echo "medium"
|
|
||||||
`;
|
|
||||||
const result = MiddlewareService.getMiddleware(yaml);
|
|
||||||
expect(result[0].name).toBe('low');
|
|
||||||
expect(result[1].name).toBe('medium');
|
|
||||||
expect(result[2].name).toBe('high');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
import YAML from 'yaml';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { Middleware, MiddlewareTrigger } from './middleware';
|
|
||||||
import { ContainerHook } from './container-hook';
|
|
||||||
import { CommandHook } from './command-hook';
|
|
||||||
import OrchestratorOptions from '../../options/orchestrator-options';
|
|
||||||
import Orchestrator from '../../orchestrator';
|
|
||||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
|
||||||
import Input from '../../../input';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for loading, evaluating, and resolving middleware into hooks.
|
|
||||||
*
|
|
||||||
* Middleware is a higher-level composable abstraction over the existing
|
|
||||||
* command hook and container hook systems. Each middleware:
|
|
||||||
* - Wraps around pipeline phases with before/after semantics
|
|
||||||
* - Has trigger conditions (phase, provider, platform, expression)
|
|
||||||
* - Resolves to either CommandHooks or ContainerHooks at execution time
|
|
||||||
* - Executes in priority order (before: ascending, after: descending)
|
|
||||||
*/
|
|
||||||
export class MiddlewareService {
|
|
||||||
/**
|
|
||||||
* Load all active middleware from inline YAML + file-based definitions.
|
|
||||||
* Returns them sorted by priority (ascending).
|
|
||||||
*/
|
|
||||||
static getMiddleware(inlineYaml: string): Middleware[] {
|
|
||||||
const middleware: Middleware[] = [];
|
|
||||||
|
|
||||||
// Parse inline YAML definitions
|
|
||||||
if (inlineYaml && inlineYaml !== '') {
|
|
||||||
middleware.push(...MiddlewareService.parseMiddleware(inlineYaml));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load file-based definitions from game-ci/middleware/
|
|
||||||
middleware.push(...MiddlewareService.getMiddlewareFromFiles());
|
|
||||||
|
|
||||||
// Sort by priority (lower = earlier)
|
|
||||||
middleware.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Middleware: loaded ${middleware.length} definition(s)`);
|
|
||||||
|
|
||||||
return middleware;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve middleware to CommandHooks for a given phase and timing.
|
|
||||||
* Filters by trigger conditions and converts to hooks.
|
|
||||||
*
|
|
||||||
* Before hooks: ascending priority (lowest priority runs first, closest to core phase).
|
|
||||||
* After hooks: descending priority (highest priority runs first, closest to core phase).
|
|
||||||
* This produces the wrapping pattern: outermost middleware's before runs first and after runs last.
|
|
||||||
*/
|
|
||||||
static resolveCommandHooks(middleware: Middleware[], phase: string, timing: 'before' | 'after'): CommandHook[] {
|
|
||||||
const applicable = middleware
|
|
||||||
.filter((m) => m.type === 'command')
|
|
||||||
.filter((m) => MiddlewareService.evaluateTrigger(m.trigger, phase));
|
|
||||||
|
|
||||||
// before: ascending priority; after: descending (wrapping order)
|
|
||||||
if (timing === 'after') {
|
|
||||||
applicable.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const hooks = applicable
|
|
||||||
.filter((m) => (timing === 'before' ? m.before : m.after))
|
|
||||||
.map((m) => {
|
|
||||||
const mPhase = timing === 'before' ? m.before! : m.after!;
|
|
||||||
const hook = new CommandHook();
|
|
||||||
hook.name = `middleware:${m.name}:${timing}`;
|
|
||||||
hook.commands = typeof mPhase.commands === 'string' ? [mPhase.commands] : [mPhase.commands];
|
|
||||||
hook.hook = [timing];
|
|
||||||
hook.step = [phase];
|
|
||||||
hook.secrets = m.secrets || [];
|
|
||||||
|
|
||||||
return hook;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hooks.length > 0) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Middleware: resolved ${hooks.length} command hook(s) for ${phase}:${timing} — ${hooks
|
|
||||||
.map((h) => h.name)
|
|
||||||
.join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return hooks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve middleware to ContainerHooks for a given phase and timing.
|
|
||||||
* Same ordering logic as resolveCommandHooks.
|
|
||||||
*/
|
|
||||||
static resolveContainerHooks(middleware: Middleware[], phase: string, timing: 'before' | 'after'): ContainerHook[] {
|
|
||||||
const applicable = middleware
|
|
||||||
.filter((m) => m.type === 'container')
|
|
||||||
.filter((m) => MiddlewareService.evaluateTrigger(m.trigger, phase));
|
|
||||||
|
|
||||||
// before: ascending priority; after: descending (wrapping order)
|
|
||||||
if (timing === 'after') {
|
|
||||||
applicable.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const hooks = applicable
|
|
||||||
.filter((m) => (timing === 'before' ? m.before : m.after))
|
|
||||||
.map((m) => {
|
|
||||||
const mPhase = timing === 'before' ? m.before! : m.after!;
|
|
||||||
const hook = new ContainerHook();
|
|
||||||
hook.name = `middleware:${m.name}:${timing}`;
|
|
||||||
hook.commands = typeof mPhase.commands === 'string' ? mPhase.commands : mPhase.commands;
|
|
||||||
hook.image = mPhase.image || m.image || 'ubuntu';
|
|
||||||
hook.hook = timing === 'before' ? 'before' : 'after';
|
|
||||||
hook.secrets = m.secrets || [];
|
|
||||||
hook.allowFailure = m.allowFailure ?? false;
|
|
||||||
|
|
||||||
return hook;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hooks.length > 0) {
|
|
||||||
OrchestratorLogger.log(
|
|
||||||
`Middleware: resolved ${hooks.length} container hook(s) for ${phase}:${timing} — ${hooks
|
|
||||||
.map((h) => h.name)
|
|
||||||
.join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return hooks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate whether a middleware's trigger conditions are met.
|
|
||||||
* All specified conditions must pass (AND logic).
|
|
||||||
*/
|
|
||||||
static evaluateTrigger(trigger: MiddlewareTrigger, currentPhase: string): boolean {
|
|
||||||
// Phase must match
|
|
||||||
if (!trigger.phase || !trigger.phase.includes(currentPhase)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider filter (if specified)
|
|
||||||
if (trigger.provider && trigger.provider.length > 0) {
|
|
||||||
const currentProvider = Orchestrator.buildParameters?.providerStrategy || OrchestratorOptions.providerStrategy;
|
|
||||||
if (!trigger.provider.includes(currentProvider)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platform filter (if specified)
|
|
||||||
if (trigger.platform && trigger.platform.length > 0) {
|
|
||||||
const currentPlatform = Orchestrator.buildParameters?.targetPlatform || '';
|
|
||||||
if (!trigger.platform.includes(currentPlatform)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expression-based condition
|
|
||||||
if (trigger.when) {
|
|
||||||
if (!MiddlewareService.evaluateExpression(trigger.when)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate a simple expression string against environment variables.
|
|
||||||
*
|
|
||||||
* Supported formats:
|
|
||||||
* - env.VAR_NAME == 'value' — equality check
|
|
||||||
* - env.VAR_NAME != 'value' — inequality check
|
|
||||||
* - env.VAR_NAME — truthy check (defined, non-empty, not 'false')
|
|
||||||
* - !env.VAR_NAME — falsy check
|
|
||||||
*/
|
|
||||||
static evaluateExpression(expression: string): boolean {
|
|
||||||
const trimmed = expression.trim();
|
|
||||||
|
|
||||||
// Match: env.VAR == 'value' or env.VAR != 'value'
|
|
||||||
const comparisonMatch = trimmed.match(/^env\.(\w+)\s*(==|!=)\s*['"](.*)['"]$/);
|
|
||||||
if (comparisonMatch) {
|
|
||||||
const [, varName, operator, value] = comparisonMatch;
|
|
||||||
const envValue = process.env[varName] || '';
|
|
||||||
|
|
||||||
return operator === '==' ? envValue === value : envValue !== value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match: !env.VAR (falsy check)
|
|
||||||
const falsyMatch = trimmed.match(/^!env\.(\w+)$/);
|
|
||||||
if (falsyMatch) {
|
|
||||||
const [, varName] = falsyMatch;
|
|
||||||
const envValue = process.env[varName];
|
|
||||||
|
|
||||||
return envValue === undefined || envValue === '' || envValue === 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match: env.VAR (truthy check)
|
|
||||||
const truthyMatch = trimmed.match(/^env\.(\w+)$/);
|
|
||||||
if (truthyMatch) {
|
|
||||||
const [, varName] = truthyMatch;
|
|
||||||
const envValue = process.env[varName];
|
|
||||||
|
|
||||||
return envValue !== undefined && envValue !== '' && envValue !== 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown expression format — log warning, default to true
|
|
||||||
OrchestratorLogger.logWarning(`Middleware: unknown expression format "${expression}", defaulting to true`);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse middleware definitions from a YAML string.
|
|
||||||
* Accepts both single-object and array format.
|
|
||||||
*/
|
|
||||||
static parseMiddleware(yamlString: string): Middleware[] {
|
|
||||||
if (!yamlString || yamlString.trim() === '') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const isArray = yamlString.replace(/\s/g, '')[0] === '-';
|
|
||||||
const parsed = isArray ? YAML.parse(yamlString) : [YAML.parse(yamlString)];
|
|
||||||
|
|
||||||
if (!parsed || !Array.isArray(parsed)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed.map((m: any) => MiddlewareService.hydrateMiddleware(m));
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.logWarning(`Middleware: failed to parse YAML — ${error.message}`);
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hydrate a raw parsed YAML object into a Middleware instance.
|
|
||||||
*/
|
|
||||||
private static hydrateMiddleware(m: any): Middleware {
|
|
||||||
const middleware = new Middleware();
|
|
||||||
middleware.name = m.name || 'unnamed';
|
|
||||||
middleware.description = m.description;
|
|
||||||
middleware.type = m.type || 'command';
|
|
||||||
middleware.priority = m.priority ?? 100;
|
|
||||||
middleware.image = m.image || 'ubuntu';
|
|
||||||
middleware.allowFailure = m.allowFailure ?? false;
|
|
||||||
middleware.outputs = m.outputs;
|
|
||||||
|
|
||||||
// Parse trigger — normalize scalar values to arrays
|
|
||||||
middleware.trigger = {
|
|
||||||
phase: MiddlewareService.toStringArray(m.trigger?.phase),
|
|
||||||
provider: m.trigger?.provider ? MiddlewareService.toStringArray(m.trigger.provider) : undefined,
|
|
||||||
platform: m.trigger?.platform ? MiddlewareService.toStringArray(m.trigger.platform) : undefined,
|
|
||||||
when: m.trigger?.when,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse before/after phases — accept string shorthand or object format
|
|
||||||
if (m.before) {
|
|
||||||
middleware.before = {
|
|
||||||
commands: typeof m.before === 'string' ? m.before : m.before.commands || '',
|
|
||||||
image: typeof m.before === 'string' ? undefined : m.before.image,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (m.after) {
|
|
||||||
middleware.after = {
|
|
||||||
commands: typeof m.after === 'string' ? m.after : m.after.commands || '',
|
|
||||||
image: typeof m.after === 'string' ? undefined : m.after.image,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse secrets
|
|
||||||
if (m.secrets && Array.isArray(m.secrets)) {
|
|
||||||
middleware.secrets = m.secrets.map((s: any) => ({
|
|
||||||
ParameterKey: s.name,
|
|
||||||
EnvironmentVariable: Input.ToEnvVarFormat(s.name),
|
|
||||||
ParameterValue: s.value ?? process.env[s.name] ?? process.env[Input.ToEnvVarFormat(s.name)] ?? '',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return middleware;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load middleware definitions from game-ci/middleware/ directory files.
|
|
||||||
* Only files whose base name appears in the middlewareFiles allowlist are loaded.
|
|
||||||
*/
|
|
||||||
static getMiddlewareFromFiles(): Middleware[] {
|
|
||||||
const results: Middleware[] = [];
|
|
||||||
const allowedFiles = OrchestratorOptions.middlewareFiles;
|
|
||||||
if (!allowedFiles || allowedFiles.length === 0) {
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const middlewarePath = path.join(process.cwd(), 'game-ci', 'middleware');
|
|
||||||
if (!fs.existsSync(middlewarePath)) {
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = fs.readdirSync(middlewarePath);
|
|
||||||
for (const file of files) {
|
|
||||||
const baseName = file.replace(/\.ya?ml$/, '');
|
|
||||||
if (!allowedFiles.includes(baseName)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const contents = fs.readFileSync(path.join(middlewarePath, file), 'utf8');
|
|
||||||
results.push(...MiddlewareService.parseMiddleware(contents));
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.logWarning(`Middleware: failed to parse file ${file} — ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Directory doesn't exist or can't be read — not an error
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a value to a string array. Accepts string, string[], or undefined.
|
|
||||||
*/
|
|
||||||
private static toStringArray(value: any): string[] {
|
|
||||||
if (!value) return [];
|
|
||||||
if (Array.isArray(value)) return value;
|
|
||||||
|
|
||||||
return [value];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger conditions that determine when a middleware activates.
|
|
||||||
* All specified conditions must be true (AND logic).
|
|
||||||
*/
|
|
||||||
export interface MiddlewareTrigger {
|
|
||||||
/** Pipeline phases this middleware applies to: 'setup', 'build', 'pre-build', 'post-build' */
|
|
||||||
phase: string[];
|
|
||||||
/** Restrict to specific providers. If omitted, applies to all providers. */
|
|
||||||
provider?: string[];
|
|
||||||
/** Restrict to specific build target platforms. If omitted, applies to all platforms. */
|
|
||||||
platform?: string[];
|
|
||||||
/** Expression-based condition. Supports: env.VAR == 'value', env.VAR != 'value', env.VAR (truthy) */
|
|
||||||
when?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single phase (before or after) of a middleware definition.
|
|
||||||
*/
|
|
||||||
export interface MiddlewarePhase {
|
|
||||||
/** Shell commands to execute */
|
|
||||||
commands: string;
|
|
||||||
/** Override image for this phase (container type only) */
|
|
||||||
image?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware — a composable, trigger-aware pipeline unit built on hooks.
|
|
||||||
*
|
|
||||||
* Middleware wraps around pipeline phases with before/after semantics.
|
|
||||||
* Each middleware resolves to either CommandHooks (inline in build container)
|
|
||||||
* or ContainerHooks (separate Docker containers) at execution time.
|
|
||||||
*
|
|
||||||
* Execution order: before phases run in ascending priority order,
|
|
||||||
* after phases run in descending priority order (wrapping pattern).
|
|
||||||
*
|
|
||||||
* Example YAML:
|
|
||||||
* ```yaml
|
|
||||||
* name: code-signing
|
|
||||||
* description: Signs build artifacts after successful build
|
|
||||||
* type: container
|
|
||||||
* priority: 50
|
|
||||||
* image: ubuntu:22.04
|
|
||||||
* trigger:
|
|
||||||
* phase: [build]
|
|
||||||
* provider: [aws, k8s]
|
|
||||||
* platform: [StandaloneWindows64]
|
|
||||||
* when: "env.SIGN_BUILDS == 'true'"
|
|
||||||
* before:
|
|
||||||
* commands: |
|
|
||||||
* echo "Preparing signing environment..."
|
|
||||||
* after:
|
|
||||||
* commands: |
|
|
||||||
* echo "Signing build artifacts..."
|
|
||||||
* secrets:
|
|
||||||
* - name: SIGNING_KEY
|
|
||||||
* - name: SIGNING_CERT
|
|
||||||
* allowFailure: false
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class Middleware {
|
|
||||||
/** Unique name identifying this middleware */
|
|
||||||
public name!: string;
|
|
||||||
/** Human-readable description */
|
|
||||||
public description?: string;
|
|
||||||
/** Hook type: 'command' (inline in build container) or 'container' (separate Docker container) */
|
|
||||||
public type!: 'command' | 'container';
|
|
||||||
/** Execution priority. Lower values run first for before, last for after (wrapping order). Default: 100 */
|
|
||||||
public priority: number = 100;
|
|
||||||
/** Conditions that determine when this middleware activates */
|
|
||||||
public trigger!: MiddlewareTrigger;
|
|
||||||
/** Default Docker image for container type middleware */
|
|
||||||
public image: string = 'ubuntu';
|
|
||||||
/** Commands to run before the target phase */
|
|
||||||
public before?: MiddlewarePhase;
|
|
||||||
/** Commands to run after the target phase */
|
|
||||||
public after?: MiddlewarePhase;
|
|
||||||
/** Secrets injected as environment variables */
|
|
||||||
public secrets: OrchestratorSecret[] = [];
|
|
||||||
/** If true, failures warn but don't stop the build. Default: false */
|
|
||||||
public allowFailure: boolean = false;
|
|
||||||
/** Environment variable names this middleware exports to downstream middleware */
|
|
||||||
public outputs?: string[];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
import { execSync, execFileSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { BuildReliabilityService } from './build-reliability-service';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('node:child_process');
|
||||||
|
jest.mock('node:fs');
|
||||||
|
jest.mock('@actions/core', () => ({
|
||||||
|
info: jest.fn(),
|
||||||
|
warning: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockExecSync = execSync as jest.MockedFunction<typeof execSync>;
|
||||||
|
const mockExecFileSync = execFileSync as jest.MockedFunction<typeof execFileSync>;
|
||||||
|
const mockFs = fs as jest.Mocked<typeof fs>;
|
||||||
|
|
||||||
|
describe('BuildReliabilityService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// checkGitIntegrity
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('checkGitIntegrity', () => {
|
||||||
|
it('should return true when fsck succeeds with clean output', () => {
|
||||||
|
mockExecSync.mockReturnValue('');
|
||||||
|
const result = BuildReliabilityService.checkGitIntegrity('/repo');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockExecSync).toHaveBeenCalledWith(
|
||||||
|
'git -C "/repo" fsck --no-dangling',
|
||||||
|
expect.objectContaining({ encoding: 'utf8' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when fsck output contains corruption indicators', () => {
|
||||||
|
mockExecSync.mockReturnValue('broken link from tree abc123');
|
||||||
|
const result = BuildReliabilityService.checkGitIntegrity('/repo');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when fsck output contains missing objects', () => {
|
||||||
|
mockExecSync.mockReturnValue('missing blob abc123');
|
||||||
|
const result = BuildReliabilityService.checkGitIntegrity('/repo');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when execSync throws (non-zero exit code)', () => {
|
||||||
|
mockExecSync.mockImplementation(() => {
|
||||||
|
const error: any = new Error('fsck failed');
|
||||||
|
error.stderr = Buffer.from('error: bad object HEAD');
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const result = BuildReliabilityService.checkGitIntegrity('/repo');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use current directory when no repoPath provided', () => {
|
||||||
|
mockExecSync.mockReturnValue('');
|
||||||
|
BuildReliabilityService.checkGitIntegrity();
|
||||||
|
expect(mockExecSync).toHaveBeenCalledWith('git -C "." fsck --no-dangling', expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// cleanStaleLockFiles
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('cleanStaleLockFiles', () => {
|
||||||
|
it('should return 0 when .git directory does not exist', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
const result = BuildReliabilityService.cleanStaleLockFiles('/repo');
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove lock files older than 10 minutes', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const oldTime = now - 15 * 60 * 1000; // 15 minutes ago
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.statSync.mockImplementation((filePath: any) => {
|
||||||
|
if (filePath === path.join('/repo', '.git')) {
|
||||||
|
return { isDirectory: () => true } as fs.Stats;
|
||||||
|
}
|
||||||
|
return { mtimeMs: oldTime } as fs.Stats;
|
||||||
|
});
|
||||||
|
mockFs.readdirSync.mockImplementation((dir: any) => {
|
||||||
|
if (dir === path.join('/repo', '.git')) {
|
||||||
|
return [
|
||||||
|
{ name: 'index.lock', isDirectory: () => false },
|
||||||
|
{ name: 'HEAD.lock', isDirectory: () => false },
|
||||||
|
] as any;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
mockFs.unlinkSync.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.cleanStaleLockFiles('/repo');
|
||||||
|
expect(result).toBe(2);
|
||||||
|
expect(mockFs.unlinkSync).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT remove lock files younger than 10 minutes', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const recentTime = now - 2 * 60 * 1000; // 2 minutes ago
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.statSync.mockImplementation((filePath: any) => {
|
||||||
|
if (filePath === path.join('/repo', '.git')) {
|
||||||
|
return { isDirectory: () => true } as fs.Stats;
|
||||||
|
}
|
||||||
|
return { mtimeMs: recentTime } as fs.Stats;
|
||||||
|
});
|
||||||
|
mockFs.readdirSync.mockImplementation((dir: any) => {
|
||||||
|
if (dir === path.join('/repo', '.git')) {
|
||||||
|
return [{ name: 'index.lock', isDirectory: () => false }] as any;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.cleanStaleLockFiles('/repo');
|
||||||
|
expect(result).toBe(0);
|
||||||
|
expect(mockFs.unlinkSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should recursively scan refs directory for lock files', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const oldTime = now - 15 * 60 * 1000;
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.statSync.mockImplementation((filePath: any) => {
|
||||||
|
if (filePath === path.join('/repo', '.git')) {
|
||||||
|
return { isDirectory: () => true } as fs.Stats;
|
||||||
|
}
|
||||||
|
return { mtimeMs: oldTime } as fs.Stats;
|
||||||
|
});
|
||||||
|
mockFs.readdirSync.mockImplementation((dir: any) => {
|
||||||
|
const gitDir = path.join('/repo', '.git');
|
||||||
|
if (dir === gitDir) {
|
||||||
|
return [{ name: 'refs', isDirectory: () => true }] as any;
|
||||||
|
}
|
||||||
|
if (dir === path.join(gitDir, 'refs')) {
|
||||||
|
return [{ name: 'heads', isDirectory: () => true }] as any;
|
||||||
|
}
|
||||||
|
if (dir === path.join(gitDir, 'refs', 'heads')) {
|
||||||
|
return [{ name: 'main.lock', isDirectory: () => false }] as any;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
mockFs.unlinkSync.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.cleanStaleLockFiles('/repo');
|
||||||
|
expect(result).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// validateSubmoduleBackingStores
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('validateSubmoduleBackingStores', () => {
|
||||||
|
it('should return empty array when .gitmodules does not exist', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
const result = BuildReliabilityService.validateSubmoduleBackingStores('/repo');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect broken backing store for submodule', () => {
|
||||||
|
mockFs.existsSync.mockImplementation((p: any) => {
|
||||||
|
if (p === path.join('/repo', '.gitmodules')) return true;
|
||||||
|
if (p === path.join('/repo', 'lib/sub', '.git')) return true;
|
||||||
|
// Backing store does not exist
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
mockFs.readFileSync.mockImplementation((p: any) => {
|
||||||
|
if (p === path.join('/repo', '.gitmodules')) {
|
||||||
|
return '[submodule "sub"]\n\tpath = lib/sub\n\turl = https://example.com/sub.git';
|
||||||
|
}
|
||||||
|
if (p === path.join('/repo', 'lib/sub', '.git')) {
|
||||||
|
return 'gitdir: ../../.git/modules/lib/sub';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
mockFs.statSync.mockReturnValue({ isFile: () => true } as fs.Stats);
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.validateSubmoduleBackingStores('/repo');
|
||||||
|
expect(result).toContain('lib/sub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when all submodule backing stores are valid', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readFileSync.mockImplementation((p: any) => {
|
||||||
|
if (p === path.join('/repo', '.gitmodules')) {
|
||||||
|
return '[submodule "sub"]\n\tpath = lib/sub\n\turl = https://example.com/sub.git';
|
||||||
|
}
|
||||||
|
if (p === path.join('/repo', 'lib/sub', '.git')) {
|
||||||
|
return 'gitdir: ../../.git/modules/lib/sub';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
mockFs.statSync.mockReturnValue({ isFile: () => true } as fs.Stats);
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.validateSubmoduleBackingStores('/repo');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// recoverCorruptedRepo
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('recoverCorruptedRepo', () => {
|
||||||
|
it('should orchestrate fsck cleanup and re-fetch, returning true on success', () => {
|
||||||
|
// cleanStaleLockFiles: no .git dir
|
||||||
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
||||||
|
|
||||||
|
// fetch succeeds, then fsck succeeds
|
||||||
|
mockExecSync.mockReturnValue('');
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.recoverCorruptedRepo('/repo');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
// Should have called fetch
|
||||||
|
expect(mockExecSync).toHaveBeenCalledWith('git -C "/repo" fetch --all', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when recovery fails to restore integrity', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
||||||
|
|
||||||
|
// fetch succeeds, but fsck fails
|
||||||
|
mockExecSync.mockImplementation((cmd: any) => {
|
||||||
|
if (typeof cmd === 'string' && cmd.includes('fetch')) return '';
|
||||||
|
if (typeof cmd === 'string' && cmd.includes('fsck')) {
|
||||||
|
return 'missing blob abc123';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.recoverCorruptedRepo('/repo');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should continue recovery even when fetch fails', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
|
mockExecSync.mockImplementation((cmd: any) => {
|
||||||
|
callCount++;
|
||||||
|
if (typeof cmd === 'string' && cmd.includes('fetch')) {
|
||||||
|
throw new Error('network error');
|
||||||
|
}
|
||||||
|
// fsck call
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.recoverCorruptedRepo('/repo');
|
||||||
|
// Should still attempt fsck after failed fetch
|
||||||
|
expect(callCount).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// cleanReservedFilenames
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('cleanReservedFilenames', () => {
|
||||||
|
it('should return empty array when Assets directory does not exist', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
const result = BuildReliabilityService.cleanReservedFilenames('/project');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove files with reserved names (con, prn, aux, nul)', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readdirSync.mockImplementation((dir: any) => {
|
||||||
|
if (dir === path.join('/project', 'Assets')) {
|
||||||
|
return [
|
||||||
|
{ name: 'con.txt', isDirectory: () => false },
|
||||||
|
{ name: 'PRN.meta', isDirectory: () => false },
|
||||||
|
{ name: 'aux.shader', isDirectory: () => false },
|
||||||
|
{ name: 'nul.png', isDirectory: () => false },
|
||||||
|
{ name: 'valid-file.cs', isDirectory: () => false },
|
||||||
|
] as any;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
mockFs.unlinkSync.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.cleanReservedFilenames('/project');
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(mockFs.unlinkSync).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove directories with reserved names', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readdirSync.mockImplementation((dir: any) => {
|
||||||
|
if (dir === path.join('/project', 'Assets')) {
|
||||||
|
return [{ name: 'com1', isDirectory: () => true }] as any;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
mockFs.rmSync.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.cleanReservedFilenames('/project');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(mockFs.rmSync).toHaveBeenCalledWith(path.join('/project', 'Assets', 'com1'), {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect COM1 through COM9 and LPT1 through LPT9', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readdirSync.mockImplementation((dir: any) => {
|
||||||
|
if (dir === path.join('/project', 'Assets')) {
|
||||||
|
return [
|
||||||
|
{ name: 'com1.txt', isDirectory: () => false },
|
||||||
|
{ name: 'COM9.meta', isDirectory: () => false },
|
||||||
|
{ name: 'lpt1.dat', isDirectory: () => false },
|
||||||
|
{ name: 'LPT9.log', isDirectory: () => false },
|
||||||
|
] as any;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
mockFs.unlinkSync.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.cleanReservedFilenames('/project');
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not remove files that merely contain reserved names as substrings', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readdirSync.mockImplementation((dir: any) => {
|
||||||
|
if (dir === path.join('/project', 'Assets')) {
|
||||||
|
return [
|
||||||
|
{ name: 'controller.cs', isDirectory: () => false },
|
||||||
|
{ name: 'printer-utils.cs', isDirectory: () => false },
|
||||||
|
{ name: 'auxiliary.shader', isDirectory: () => false },
|
||||||
|
] as any;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.cleanReservedFilenames('/project');
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// getAvailableSpaceMB
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('getAvailableSpaceMB', () => {
|
||||||
|
it('should return -1 when the check fails', () => {
|
||||||
|
mockExecFileSync.mockImplementation(() => {
|
||||||
|
throw new Error('Command failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.getAvailableSpaceMB('/some/path');
|
||||||
|
expect(result).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse wmic output on Windows', () => {
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
|
|
||||||
|
// 10 GB in bytes
|
||||||
|
mockExecFileSync.mockReturnValue('\r\nFreeSpace=10737418240\r\n' as any);
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.getAvailableSpaceMB('C:\\builds');
|
||||||
|
// 10737418240 / (1024 * 1024) = 10240 MB
|
||||||
|
expect(result).toBeCloseTo(10240, 0);
|
||||||
|
|
||||||
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse df output on Unix', () => {
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||||
|
|
||||||
|
mockExecFileSync.mockReturnValue(' Avail\n 5120M\n' as any);
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.getAvailableSpaceMB('/builds');
|
||||||
|
expect(result).toBe(5120);
|
||||||
|
|
||||||
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// getDirectorySizeMB
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('getDirectorySizeMB', () => {
|
||||||
|
it('should return file size for a single file', () => {
|
||||||
|
// 5 MB in bytes
|
||||||
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, size: 5 * 1024 * 1024 } as any);
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.getDirectorySizeMB('/path/to/file.zip');
|
||||||
|
expect(result).toBeCloseTo(5, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return total size for a directory tree', () => {
|
||||||
|
const subDir = path.join('/build', 'sub');
|
||||||
|
|
||||||
|
mockFs.statSync.mockImplementation((p: any) => {
|
||||||
|
const pathStr = typeof p === 'string' ? p : p.toString();
|
||||||
|
if (pathStr === '/build' || pathStr === subDir) {
|
||||||
|
return { isDirectory: () => true, size: 0 } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isDirectory: () => false, size: 1024 * 1024 } as any; // 1 MB each
|
||||||
|
});
|
||||||
|
|
||||||
|
mockFs.readdirSync.mockImplementation((dirPath: any, _options?: any) => {
|
||||||
|
const dirStr = typeof dirPath === 'string' ? dirPath : dirPath.toString();
|
||||||
|
if (dirStr === '/build') {
|
||||||
|
return [
|
||||||
|
{ name: 'file1.bin', isDirectory: () => false },
|
||||||
|
{ name: 'sub', isDirectory: () => true },
|
||||||
|
] as any;
|
||||||
|
}
|
||||||
|
if (dirStr === subDir) {
|
||||||
|
return [{ name: 'file2.bin', isDirectory: () => false }] as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [] as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.getDirectorySizeMB('/build');
|
||||||
|
expect(result).toBeCloseTo(2, 0); // 2 files * 1 MB each
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return -1 when calculation fails', () => {
|
||||||
|
mockFs.statSync.mockImplementation(() => {
|
||||||
|
throw new Error('Access denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.getDirectorySizeMB('/inaccessible');
|
||||||
|
expect(result).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// archiveBuildOutput
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('archiveBuildOutput', () => {
|
||||||
|
it('should skip archiving when source path does not exist', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
BuildReliabilityService.archiveBuildOutput('/builds/output', '/archives');
|
||||||
|
expect(mockExecSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create archive directory and tar.gz output', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.mkdirSync.mockReturnValue(undefined as any);
|
||||||
|
mockExecSync.mockReturnValue('');
|
||||||
|
// Make disk space check return unknown so we proceed
|
||||||
|
mockExecFileSync.mockImplementation(() => {
|
||||||
|
throw new Error('Command not found');
|
||||||
|
});
|
||||||
|
mockFs.statSync.mockImplementation(() => {
|
||||||
|
throw new Error('Not mocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
BuildReliabilityService.archiveBuildOutput('/builds/output', '/archives');
|
||||||
|
|
||||||
|
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/archives', { recursive: true });
|
||||||
|
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining('tar -czf'), expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip archival when insufficient disk space', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.mkdirSync.mockReturnValue(undefined as any);
|
||||||
|
|
||||||
|
// Source is 1000 MB
|
||||||
|
mockFs.statSync.mockImplementation((p: any) => {
|
||||||
|
const pathStr = typeof p === 'string' ? p : p.toString();
|
||||||
|
if (pathStr.endsWith('big-file.bin')) {
|
||||||
|
return { isDirectory: () => false, size: 1000 * 1024 * 1024 } as any;
|
||||||
|
}
|
||||||
|
return { isDirectory: () => true, size: 0 } as any;
|
||||||
|
});
|
||||||
|
mockFs.readdirSync.mockImplementation(() => {
|
||||||
|
return [{ name: 'big-file.bin', isDirectory: () => false }] as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only 500 MB available
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||||
|
mockExecFileSync.mockReturnValue(' Avail\n 500M\n' as any);
|
||||||
|
|
||||||
|
BuildReliabilityService.archiveBuildOutput('/builds/output', '/archives');
|
||||||
|
|
||||||
|
// Should NOT have attempted the tar command
|
||||||
|
expect(mockExecSync).not.toHaveBeenCalledWith(expect.stringContaining('tar'), expect.anything());
|
||||||
|
|
||||||
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up partial archive on tar failure', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.mkdirSync.mockReturnValue(undefined as any);
|
||||||
|
mockFs.unlinkSync.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
// Make disk space check return unknown so we proceed
|
||||||
|
mockExecFileSync.mockImplementation(() => {
|
||||||
|
throw new Error('Command not found');
|
||||||
|
});
|
||||||
|
mockFs.statSync.mockImplementation(() => {
|
||||||
|
throw new Error('Not mocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
// tar command fails
|
||||||
|
mockExecSync.mockImplementation(() => {
|
||||||
|
const error: any = new Error('tar failed');
|
||||||
|
error.stderr = Buffer.from('No space left on device');
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
BuildReliabilityService.archiveBuildOutput('/builds/output', '/archives');
|
||||||
|
|
||||||
|
// Should have attempted to clean up the partial archive
|
||||||
|
// (existsSync returns true for the partial file)
|
||||||
|
expect(mockFs.unlinkSync).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should proceed with warning when disk space check fails', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.mkdirSync.mockReturnValue(undefined as any);
|
||||||
|
mockExecSync.mockReturnValue('');
|
||||||
|
|
||||||
|
// Disk space check fails
|
||||||
|
mockExecFileSync.mockImplementation(() => {
|
||||||
|
throw new Error('Command not found');
|
||||||
|
});
|
||||||
|
// Directory size check also fails
|
||||||
|
mockFs.statSync.mockImplementation(() => {
|
||||||
|
throw new Error('Not mocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
BuildReliabilityService.archiveBuildOutput('/builds/output', '/archives');
|
||||||
|
|
||||||
|
// Should still proceed with tar
|
||||||
|
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining('tar -czf'), expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// enforceRetention
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('enforceRetention', () => {
|
||||||
|
it('should return 0 when archive path does not exist', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
const result = BuildReliabilityService.enforceRetention('/archive', 30);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove archives older than retention period', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const oldTime = now - 45 * 24 * 60 * 60 * 1000; // 45 days ago
|
||||||
|
const recentTime = now - 5 * 24 * 60 * 60 * 1000; // 5 days ago
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readdirSync.mockReturnValue([
|
||||||
|
{ name: 'build-old.tar.gz', isDirectory: () => false },
|
||||||
|
{ name: 'build-recent.tar.gz', isDirectory: () => false },
|
||||||
|
] as any);
|
||||||
|
mockFs.statSync.mockImplementation((p: any) => {
|
||||||
|
if ((p as string).includes('old')) {
|
||||||
|
return { mtimeMs: oldTime } as fs.Stats;
|
||||||
|
}
|
||||||
|
return { mtimeMs: recentTime } as fs.Stats;
|
||||||
|
});
|
||||||
|
mockFs.unlinkSync.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.enforceRetention('/archive', 30);
|
||||||
|
expect(result).toBe(1);
|
||||||
|
expect(mockFs.unlinkSync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep all archives within retention period', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const recentTime = now - 5 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readdirSync.mockReturnValue([
|
||||||
|
{ name: 'build-1.tar.gz', isDirectory: () => false },
|
||||||
|
{ name: 'build-2.tar.gz', isDirectory: () => false },
|
||||||
|
] as any);
|
||||||
|
mockFs.statSync.mockReturnValue({ mtimeMs: recentTime } as fs.Stats);
|
||||||
|
|
||||||
|
const result = BuildReliabilityService.enforceRetention('/archive', 30);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// configureGitEnvironment
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('configureGitEnvironment', () => {
|
||||||
|
it('should set GIT_TERMINAL_PROMPT=0 in process.env', () => {
|
||||||
|
mockExecSync.mockReturnValue('');
|
||||||
|
BuildReliabilityService.configureGitEnvironment();
|
||||||
|
expect(process.env.GIT_TERMINAL_PROMPT).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should configure http.postBuffer via git config', () => {
|
||||||
|
mockExecSync.mockReturnValue('');
|
||||||
|
BuildReliabilityService.configureGitEnvironment();
|
||||||
|
expect(mockExecSync).toHaveBeenCalledWith('git config --global http.postBuffer 524288000', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should configure core.longpaths via git config', () => {
|
||||||
|
mockExecSync.mockReturnValue('');
|
||||||
|
BuildReliabilityService.configureGitEnvironment();
|
||||||
|
expect(mockExecSync).toHaveBeenCalledWith('git config --global core.longpaths true', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn but not throw when git config commands fail', () => {
|
||||||
|
const core = require('@actions/core');
|
||||||
|
mockExecSync.mockImplementation(() => {
|
||||||
|
throw new Error('git config failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => BuildReliabilityService.configureGitEnvironment()).not.toThrow();
|
||||||
|
expect(core.warning).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
import { execSync, execFileSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import * as core from '@actions/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build reliability features for hardening CI pipelines.
|
||||||
|
* Provides git integrity checks, stale lock cleanup, submodule validation,
|
||||||
|
* reserved filename removal, build archival, and git environment configuration.
|
||||||
|
* All features are opt-in and fail gracefully (warnings only).
|
||||||
|
*/
|
||||||
|
export class BuildReliabilityService {
|
||||||
|
// Windows reserved device names that cause Unity asset importer infinite loops
|
||||||
|
private static readonly RESERVED_NAMES = new Set([
|
||||||
|
'con',
|
||||||
|
'prn',
|
||||||
|
'aux',
|
||||||
|
'nul',
|
||||||
|
'com1',
|
||||||
|
'com2',
|
||||||
|
'com3',
|
||||||
|
'com4',
|
||||||
|
'com5',
|
||||||
|
'com6',
|
||||||
|
'com7',
|
||||||
|
'com8',
|
||||||
|
'com9',
|
||||||
|
'lpt1',
|
||||||
|
'lpt2',
|
||||||
|
'lpt3',
|
||||||
|
'lpt4',
|
||||||
|
'lpt5',
|
||||||
|
'lpt6',
|
||||||
|
'lpt7',
|
||||||
|
'lpt8',
|
||||||
|
'lpt9',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Lock files to look for in the .git directory
|
||||||
|
private static readonly LOCK_FILE_NAMES = new Set(['index.lock', 'shallow.lock', 'config.lock', 'HEAD.lock']);
|
||||||
|
|
||||||
|
// Maximum age in milliseconds before a lock file is considered stale (10 minutes)
|
||||||
|
private static readonly LOCK_FILE_MAX_AGE_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run git fsck to check repository integrity.
|
||||||
|
* Returns true if the repo is healthy, false if corruption detected.
|
||||||
|
*/
|
||||||
|
static checkGitIntegrity(repoPath: string = '.'): boolean {
|
||||||
|
core.info(`[Reliability] Checking git integrity in ${repoPath}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = execSync(`git -C "${repoPath}" fsck --no-dangling`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 120_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse output for corruption indicators
|
||||||
|
const corruptionPatterns = [
|
||||||
|
/broken link/i,
|
||||||
|
/missing (blob|tree|commit|tag)/i,
|
||||||
|
/dangling/i,
|
||||||
|
/corrupt/i,
|
||||||
|
/error in /i,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of corruptionPatterns) {
|
||||||
|
if (pattern.test(output)) {
|
||||||
|
core.warning(`[Reliability] Git integrity check found issues: ${output.trim()}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info('[Reliability] Git integrity check passed');
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
// execSync throws on non-zero exit code
|
||||||
|
const stderr = error.stderr?.toString() ?? error.message;
|
||||||
|
core.warning(`[Reliability] Git integrity check failed: ${stderr}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove stale .lock files from the .git directory.
|
||||||
|
* Only removes lock files older than 10 minutes to avoid interfering with active operations.
|
||||||
|
* Returns the number of lock files removed.
|
||||||
|
*/
|
||||||
|
static cleanStaleLockFiles(repoPath: string = '.'): number {
|
||||||
|
const gitDir = path.join(repoPath, '.git');
|
||||||
|
if (!fs.existsSync(gitDir) || !fs.statSync(gitDir).isDirectory()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info(`[Reliability] Scanning for stale lock files in ${gitDir}`);
|
||||||
|
const now = Date.now();
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
const cleanDirectory = (directory: string): void => {
|
||||||
|
if (!fs.existsSync(directory)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
cleanDirectory(fullPath);
|
||||||
|
} else if (entry.name.endsWith('.lock')) {
|
||||||
|
// Check if it is a known lock file location OR under refs/
|
||||||
|
const relativePath = path.relative(gitDir, fullPath);
|
||||||
|
const isKnownLock = BuildReliabilityService.LOCK_FILE_NAMES.has(entry.name);
|
||||||
|
const isRefsLock = relativePath.startsWith('refs' + path.sep);
|
||||||
|
|
||||||
|
if (isKnownLock || isRefsLock) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
const ageMs = now - stat.mtimeMs;
|
||||||
|
|
||||||
|
if (ageMs > BuildReliabilityService.LOCK_FILE_MAX_AGE_MS) {
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
removed++;
|
||||||
|
core.info(
|
||||||
|
`[Reliability] Removed stale lock file (age: ${Math.round(ageMs / 1000)}s): ${relativePath}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
core.info(
|
||||||
|
`[Reliability] Lock file is recent (age: ${Math.round(ageMs / 1000)}s), skipping: ${relativePath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
core.warning(`[Reliability] Could not remove lock file: ${fullPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory not accessible
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cleanDirectory(gitDir);
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
core.info(`[Reliability] Cleaned ${removed} stale lock file(s)`);
|
||||||
|
} else {
|
||||||
|
core.info('[Reliability] No stale lock files found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that submodule .git files point to existing backing stores
|
||||||
|
* under .git/modules/. Returns list of submodule paths with broken backing stores.
|
||||||
|
*/
|
||||||
|
static validateSubmoduleBackingStores(repoPath: string = '.'): string[] {
|
||||||
|
const broken: string[] = [];
|
||||||
|
const gitmodulesPath = path.join(repoPath, '.gitmodules');
|
||||||
|
|
||||||
|
if (!fs.existsSync(gitmodulesPath)) {
|
||||||
|
core.info('[Reliability] No .gitmodules found, skipping submodule validation');
|
||||||
|
return broken;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info(`[Reliability] Validating submodule backing stores in ${repoPath}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(gitmodulesPath, 'utf8');
|
||||||
|
const pathMatches = content.matchAll(/path\s*=\s*(.+)/g);
|
||||||
|
|
||||||
|
for (const match of pathMatches) {
|
||||||
|
const submodulePath = match[1].trim();
|
||||||
|
const gitFile = path.join(repoPath, submodulePath, '.git');
|
||||||
|
|
||||||
|
if (!fs.existsSync(gitFile)) {
|
||||||
|
// Submodule not initialized -- not necessarily broken
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(gitFile);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
// .git is a file -- should contain "gitdir: <path>"
|
||||||
|
const gitFileContent = fs.readFileSync(gitFile, 'utf8').trim();
|
||||||
|
const gitdirMatch = gitFileContent.match(/^gitdir:\s*(.+)$/);
|
||||||
|
|
||||||
|
if (gitdirMatch) {
|
||||||
|
const backingStore = path.resolve(path.join(repoPath, submodulePath), gitdirMatch[1]);
|
||||||
|
if (!fs.existsSync(backingStore)) {
|
||||||
|
broken.push(submodulePath);
|
||||||
|
core.warning(`[Reliability] Submodule ${submodulePath} has broken backing store: ${backingStore}`);
|
||||||
|
} else {
|
||||||
|
core.info(`[Reliability] Submodule ${submodulePath} backing store OK`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
broken.push(submodulePath);
|
||||||
|
core.warning(`[Reliability] Submodule ${submodulePath} .git file has invalid format`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Can't read .git file
|
||||||
|
core.warning(`[Reliability] Could not read .git file for submodule: ${submodulePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
core.warning(`[Reliability] Could not read .gitmodules: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (broken.length > 0) {
|
||||||
|
core.warning(`[Reliability] ${broken.length} submodule(s) have broken backing stores`);
|
||||||
|
} else {
|
||||||
|
core.info('[Reliability] All submodule backing stores are valid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return broken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrate recovery of a corrupted repository.
|
||||||
|
* Sequence: fsck -> clean locks -> re-fetch -> retry fsck.
|
||||||
|
* Returns true if recovery succeeded.
|
||||||
|
*/
|
||||||
|
static recoverCorruptedRepo(repoPath: string = '.'): boolean {
|
||||||
|
core.warning(`[Reliability] Attempting automatic recovery for ${repoPath}`);
|
||||||
|
|
||||||
|
// Step 1: Clean stale lock files that may be preventing operations
|
||||||
|
const locksRemoved = BuildReliabilityService.cleanStaleLockFiles(repoPath);
|
||||||
|
if (locksRemoved > 0) {
|
||||||
|
core.info(`[Reliability] Recovery: cleaned ${locksRemoved} lock file(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Re-fetch to restore missing objects
|
||||||
|
try {
|
||||||
|
core.info('[Reliability] Recovery: re-fetching from remote');
|
||||||
|
execSync(`git -C "${repoPath}" fetch --all`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 300_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
core.info('[Reliability] Recovery: fetch completed');
|
||||||
|
} catch (error: any) {
|
||||||
|
core.warning(`[Reliability] Recovery: fetch failed: ${error.stderr?.toString() ?? error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Retry fsck
|
||||||
|
const healthy = BuildReliabilityService.checkGitIntegrity(repoPath);
|
||||||
|
if (healthy) {
|
||||||
|
core.info('[Reliability] Recovery succeeded -- repository is healthy');
|
||||||
|
} else {
|
||||||
|
core.warning('[Reliability] Recovery failed -- repository still has integrity issues');
|
||||||
|
}
|
||||||
|
|
||||||
|
return healthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a directory tree for files/directories with Windows reserved names.
|
||||||
|
* These names (con, prn, aux, nul, com1-9, lpt1-9) with any extension
|
||||||
|
* cause Unity asset importer infinite loops on Windows.
|
||||||
|
* Returns list of paths that were removed.
|
||||||
|
*/
|
||||||
|
static cleanReservedFilenames(projectPath: string): string[] {
|
||||||
|
const assetsPath = path.join(projectPath, 'Assets');
|
||||||
|
if (!fs.existsSync(assetsPath)) {
|
||||||
|
core.info(`[Reliability] No Assets directory found at ${assetsPath}, skipping reserved filename scan`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info(`[Reliability] Scanning for reserved filenames in ${assetsPath}`);
|
||||||
|
const cleaned: string[] = [];
|
||||||
|
|
||||||
|
const scanDirectory = (directory: string): void => {
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const nameWithoutExtension = entry.name.split('.')[0].toLowerCase();
|
||||||
|
const fullPath = path.join(directory, entry.name);
|
||||||
|
|
||||||
|
if (BuildReliabilityService.RESERVED_NAMES.has(nameWithoutExtension)) {
|
||||||
|
try {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
}
|
||||||
|
cleaned.push(fullPath);
|
||||||
|
core.warning(`[Reliability] Removed reserved filename: ${fullPath}`);
|
||||||
|
} catch {
|
||||||
|
core.warning(`[Reliability] Could not remove reserved filename: ${fullPath}`);
|
||||||
|
}
|
||||||
|
} else if (entry.isDirectory()) {
|
||||||
|
scanDirectory(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory not accessible
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
scanDirectory(assetsPath);
|
||||||
|
|
||||||
|
if (cleaned.length > 0) {
|
||||||
|
core.warning(`[Reliability] Cleaned ${cleaned.length} reserved filename(s)`);
|
||||||
|
} else {
|
||||||
|
core.info('[Reliability] No reserved filenames found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available disk space in megabytes for a given directory.
|
||||||
|
* Returns -1 if the check fails (unknown space).
|
||||||
|
*
|
||||||
|
* Cross-platform: uses wmic on Windows, df on Unix.
|
||||||
|
*/
|
||||||
|
static getAvailableSpaceMB(directoryPath: string): number {
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const drive = path.parse(directoryPath).root;
|
||||||
|
const driveLetter = drive.replace(/[:\\\/]/g, '');
|
||||||
|
const output = execFileSync(
|
||||||
|
'wmic',
|
||||||
|
['logicaldisk', 'where', `DeviceID='${driveLetter}:'`, 'get', 'FreeSpace', '/value'],
|
||||||
|
{ encoding: 'utf8', timeout: 10_000 },
|
||||||
|
);
|
||||||
|
const match = output.match(/FreeSpace=(\d+)/);
|
||||||
|
|
||||||
|
return match ? Number.parseInt(match[1], 10) / (1024 * 1024) : -1;
|
||||||
|
} else {
|
||||||
|
const output = execFileSync('df', ['-BM', '--output=avail', directoryPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
const lines = output.trim().split('\n');
|
||||||
|
|
||||||
|
return Number.parseInt(lines[lines.length - 1], 10);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return -1; // Unknown, caller should proceed with warning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the total size of a directory in megabytes.
|
||||||
|
* Returns -1 if the calculation fails.
|
||||||
|
*/
|
||||||
|
static getDirectorySizeMB(directoryPath: string): number {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(directoryPath);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return stat.size / (1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalBytes = 0;
|
||||||
|
const walkDirectory = (dir: string): void => {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walkDirectory(fullPath);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
totalBytes += fs.statSync(fullPath).size;
|
||||||
|
} catch {
|
||||||
|
// Skip inaccessible files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walkDirectory(directoryPath);
|
||||||
|
|
||||||
|
return totalBytes / (1024 * 1024);
|
||||||
|
} catch {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a tar.gz archive of build output.
|
||||||
|
*
|
||||||
|
* Validates disk space before archiving. Skips archival with a warning
|
||||||
|
* if insufficient space is detected, preventing partial writes on full disks.
|
||||||
|
*/
|
||||||
|
static archiveBuildOutput(sourcePath: string, archivePath: string): void {
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
core.info(`[Reliability] No build output to archive at ${sourcePath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(archivePath, { recursive: true });
|
||||||
|
|
||||||
|
// Check available disk space before archiving
|
||||||
|
const sourceSizeMB = BuildReliabilityService.getDirectorySizeMB(sourcePath);
|
||||||
|
const availableSpaceMB = BuildReliabilityService.getAvailableSpaceMB(archivePath);
|
||||||
|
|
||||||
|
if (sourceSizeMB >= 0 && availableSpaceMB >= 0) {
|
||||||
|
const neededMB = Math.ceil(sourceSizeMB * 1.1); // 10% safety margin
|
||||||
|
if (availableSpaceMB < neededMB) {
|
||||||
|
core.warning(
|
||||||
|
`[Reliability] Insufficient disk space for archive. ` +
|
||||||
|
`Need ~${neededMB}MB, available: ${Math.floor(availableSpaceMB)}MB. Skipping archive.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
core.info(
|
||||||
|
`[Reliability] Disk space check passed: need ~${neededMB}MB, available: ${Math.floor(availableSpaceMB)}MB`,
|
||||||
|
);
|
||||||
|
} else if (availableSpaceMB < 0) {
|
||||||
|
core.warning('[Reliability] Could not determine available disk space. Proceeding with archive cautiously.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/[.:]/g, '-');
|
||||||
|
const archiveFile = path.join(archivePath, `build-${timestamp}.tar.gz`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`tar -czf "${archiveFile}" -C "${path.dirname(sourcePath)}" "${path.basename(sourcePath)}"`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 600_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
core.info(`[Reliability] Build output archived to ${archiveFile}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
core.warning(`[Reliability] Failed to archive build output: ${error.stderr?.toString() ?? error.message}`);
|
||||||
|
|
||||||
|
// Clean up partial archive if it exists to avoid leaving corrupted files
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(archiveFile)) {
|
||||||
|
fs.unlinkSync(archiveFile);
|
||||||
|
core.info(`[Reliability] Cleaned up partial archive: ${archiveFile}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce retention policy -- delete archives older than the retention period.
|
||||||
|
* Returns the number of old archives removed.
|
||||||
|
*/
|
||||||
|
static enforceRetention(archivePath: string, retentionDays: number): number {
|
||||||
|
if (!fs.existsSync(archivePath)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const retentionMs = retentionDays * 24 * 60 * 60 * 1000;
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(archivePath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(archivePath, entry.name);
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
const ageMs = now - stat.mtimeMs;
|
||||||
|
|
||||||
|
if (ageMs > retentionMs) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
}
|
||||||
|
removed++;
|
||||||
|
core.info(
|
||||||
|
`[Reliability] Removed old archive: ${entry.name} (age: ${Math.round(
|
||||||
|
ageMs / (24 * 60 * 60 * 1000),
|
||||||
|
)} days)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
core.warning(`[Reliability] Could not process archive entry: ${fullPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
core.warning(`[Reliability] Could not read archive directory: ${archivePath}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
core.info(
|
||||||
|
`[Reliability] Retention enforced: removed ${removed} old archive(s), retention: ${retentionDays} days`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure git environment variables for CI reliability.
|
||||||
|
* Sets GIT_TERMINAL_PROMPT=0, increases http.postBuffer, enables core.longpaths.
|
||||||
|
*/
|
||||||
|
static configureGitEnvironment(): void {
|
||||||
|
core.info('[Reliability] Configuring git environment for CI');
|
||||||
|
|
||||||
|
// Prevent git from prompting for credentials (hangs in CI)
|
||||||
|
process.env.GIT_TERMINAL_PROMPT = '0';
|
||||||
|
core.info('[Reliability] Set GIT_TERMINAL_PROMPT=0');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Increase http.postBuffer to 500MB for large pushes
|
||||||
|
execSync('git config --global http.postBuffer 524288000', {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 10_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
core.info('[Reliability] Set http.postBuffer=524288000 (500MB)');
|
||||||
|
} catch (error: any) {
|
||||||
|
core.warning(`[Reliability] Could not set http.postBuffer: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Enable long paths on Windows
|
||||||
|
execSync('git config --global core.longpaths true', {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 10_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
core.info('[Reliability] Set core.longpaths=true');
|
||||||
|
} catch (error: any) {
|
||||||
|
core.warning(`[Reliability] Could not set core.longpaths: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { BuildReliabilityService } from './build-reliability-service';
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import * as core from '@actions/core';
|
||||||
|
import { SecretSourceService, validateSecretKey } from './secret-source-service';
|
||||||
|
|
||||||
|
jest.mock('node:fs');
|
||||||
|
jest.mock('@actions/core', () => ({
|
||||||
|
setSecret: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warning: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('../core/orchestrator-system', () => ({
|
||||||
|
OrchestratorSystem: {
|
||||||
|
Run: jest.fn().mockResolvedValue(''),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock('../core/orchestrator-logger', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
log: jest.fn(),
|
||||||
|
logWarning: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFs = fs as jest.Mocked<typeof fs>;
|
||||||
|
|
||||||
|
describe('SecretSourceService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateSecretKey', () => {
|
||||||
|
it('should accept alphanumeric keys', () => {
|
||||||
|
expect(validateSecretKey('MY_SECRET_KEY')).toBe('MY_SECRET_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept keys with hyphens', () => {
|
||||||
|
expect(validateSecretKey('my-secret-key')).toBe('my-secret-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept keys with dots', () => {
|
||||||
|
expect(validateSecretKey('my.secret.key')).toBe('my.secret.key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept keys with forward slashes', () => {
|
||||||
|
expect(validateSecretKey('path/to/secret')).toBe('path/to/secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept keys with mixed valid characters', () => {
|
||||||
|
expect(validateSecretKey('my-app/prod_db.password')).toBe('my-app/prod_db.password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject keys with semicolons (shell injection)', () => {
|
||||||
|
expect(() => validateSecretKey('; rm -rf /')).toThrow('Invalid secret key name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject keys with backticks (command substitution)', () => {
|
||||||
|
expect(() => validateSecretKey('`whoami`')).toThrow('Invalid secret key name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject keys with dollar signs (variable expansion)', () => {
|
||||||
|
expect(() => validateSecretKey('$HOME')).toThrow('Invalid secret key name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject keys with pipe characters', () => {
|
||||||
|
expect(() => validateSecretKey('key | cat /etc/passwd')).toThrow('Invalid secret key name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject keys with ampersands', () => {
|
||||||
|
expect(() => validateSecretKey('key && echo pwned')).toThrow('Invalid secret key name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject keys with newlines', () => {
|
||||||
|
expect(() => validateSecretKey('key\nmalicious')).toThrow('Invalid secret key name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject keys with quotes', () => {
|
||||||
|
expect(() => validateSecretKey('"key"')).toThrow('Invalid secret key name');
|
||||||
|
expect(() => validateSecretKey("'key'")).toThrow('Invalid secret key name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject keys with parentheses (subshell)', () => {
|
||||||
|
expect(() => validateSecretKey('$(whoami)')).toThrow('Invalid secret key name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject empty keys', () => {
|
||||||
|
expect(() => validateSecretKey('')).toThrow('Invalid secret key name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject keys with spaces', () => {
|
||||||
|
expect(() => validateSecretKey('key with spaces')).toThrow('Invalid secret key name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPremadeSource', () => {
|
||||||
|
it('should return true for aws-secrets-manager', () => {
|
||||||
|
expect(SecretSourceService.isPremadeSource('aws-secrets-manager')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for aws-secret-manager (legacy alias)', () => {
|
||||||
|
expect(SecretSourceService.isPremadeSource('aws-secret-manager')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for aws-parameter-store', () => {
|
||||||
|
expect(SecretSourceService.isPremadeSource('aws-parameter-store')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for gcp-secret-manager', () => {
|
||||||
|
expect(SecretSourceService.isPremadeSource('gcp-secret-manager')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for azure-key-vault', () => {
|
||||||
|
expect(SecretSourceService.isPremadeSource('azure-key-vault')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for hashicorp-vault', () => {
|
||||||
|
expect(SecretSourceService.isPremadeSource('hashicorp-vault')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for hashicorp-vault-kv1', () => {
|
||||||
|
expect(SecretSourceService.isPremadeSource('hashicorp-vault-kv1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for vault (short alias)', () => {
|
||||||
|
expect(SecretSourceService.isPremadeSource('vault')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for unknown source', () => {
|
||||||
|
expect(SecretSourceService.isPremadeSource('unknown-source')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAvailableSources', () => {
|
||||||
|
it('should return all premade source names', () => {
|
||||||
|
const sources = SecretSourceService.getAvailableSources();
|
||||||
|
expect(sources).toContain('aws-secrets-manager');
|
||||||
|
expect(sources).toContain('aws-parameter-store');
|
||||||
|
expect(sources).toContain('gcp-secret-manager');
|
||||||
|
expect(sources).toContain('azure-key-vault');
|
||||||
|
expect(sources).toContain('hashicorp-vault');
|
||||||
|
expect(sources).toContain('hashicorp-vault-kv1');
|
||||||
|
expect(sources).toContain('vault');
|
||||||
|
expect(sources.length).toBeGreaterThanOrEqual(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveSource', () => {
|
||||||
|
it('should resolve premade source by name', () => {
|
||||||
|
const source = SecretSourceService.resolveSource('aws-secrets-manager');
|
||||||
|
expect(source).toBeDefined();
|
||||||
|
expect(source!.name).toBe('aws-secrets-manager');
|
||||||
|
expect(source!.command).toContain('secretsmanager');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve custom command with {0} placeholder', () => {
|
||||||
|
const source = SecretSourceService.resolveSource('vault kv get -field=value secret/{0}');
|
||||||
|
expect(source).toBeDefined();
|
||||||
|
expect(source!.name).toBe('custom-command');
|
||||||
|
expect(source!.command).toContain('{0}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve command with spaces as custom command', () => {
|
||||||
|
const source = SecretSourceService.resolveSource('my-tool get-secret');
|
||||||
|
expect(source).toBeDefined();
|
||||||
|
expect(source!.name).toBe('custom-command');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for unknown single-word source', () => {
|
||||||
|
const source = SecretSourceService.resolveSource('unknown');
|
||||||
|
expect(source).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchSecret', () => {
|
||||||
|
it('should run the command with {0} replaced by key', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
OrchestratorSystem.Run.mockResolvedValue('my-secret-value');
|
||||||
|
|
||||||
|
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
||||||
|
const result = await SecretSourceService.fetchSecret(source, 'MY_SECRET');
|
||||||
|
|
||||||
|
expect(result).toBe('my-secret-value');
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('MY_SECRET'), false, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse JSON output when parseOutput is json-field', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
OrchestratorSystem.Run.mockResolvedValue(JSON.stringify({ value: 'extracted-secret' }));
|
||||||
|
|
||||||
|
const source = {
|
||||||
|
name: 'test-source',
|
||||||
|
command: 'fetch {0}',
|
||||||
|
parseOutput: 'json-field' as const,
|
||||||
|
jsonField: 'value',
|
||||||
|
};
|
||||||
|
const result = await SecretSourceService.fetchSecret(source, 'KEY');
|
||||||
|
|
||||||
|
expect(result).toBe('extracted-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to raw output on invalid JSON with json-field mode', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
OrchestratorSystem.Run.mockResolvedValue('not-json');
|
||||||
|
|
||||||
|
const source = {
|
||||||
|
name: 'test-source',
|
||||||
|
command: 'fetch {0}',
|
||||||
|
parseOutput: 'json-field' as const,
|
||||||
|
jsonField: 'value',
|
||||||
|
};
|
||||||
|
const result = await SecretSourceService.fetchSecret(source, 'KEY');
|
||||||
|
|
||||||
|
expect(result).toBe('not-json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string on command failure', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
OrchestratorSystem.Run.mockRejectedValue(new Error('command not found'));
|
||||||
|
|
||||||
|
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
||||||
|
const result = await SecretSourceService.fetchSecret(source, 'KEY');
|
||||||
|
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject keys with shell injection characters', async () => {
|
||||||
|
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
||||||
|
|
||||||
|
await expect(SecretSourceService.fetchSecret(source, '; rm -rf /')).rejects.toThrow('Invalid secret key name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject keys with command substitution', async () => {
|
||||||
|
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
||||||
|
|
||||||
|
await expect(SecretSourceService.fetchSecret(source, '$(whoami)')).rejects.toThrow('Invalid secret key name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject keys with backtick command substitution', async () => {
|
||||||
|
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
||||||
|
|
||||||
|
await expect(SecretSourceService.fetchSecret(source, '`cat /etc/passwd`')).rejects.toThrow(
|
||||||
|
'Invalid secret key name',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept keys with valid path-like patterns', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
OrchestratorSystem.Run.mockResolvedValue('secret-value');
|
||||||
|
|
||||||
|
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
||||||
|
const result = await SecretSourceService.fetchSecret(source, 'prod/database/password');
|
||||||
|
|
||||||
|
expect(result).toBe('secret-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mask fetched secret values with core.setSecret', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
OrchestratorSystem.Run.mockResolvedValue('super-secret-value');
|
||||||
|
|
||||||
|
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
||||||
|
await SecretSourceService.fetchSecret(source, 'MY_SECRET');
|
||||||
|
|
||||||
|
expect(core.setSecret).toHaveBeenCalledWith('super-secret-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not mask empty secret values', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
OrchestratorSystem.Run.mockResolvedValue('');
|
||||||
|
|
||||||
|
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
||||||
|
await SecretSourceService.fetchSecret(source, 'MY_SECRET');
|
||||||
|
|
||||||
|
expect(core.setSecret).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mask JSON-extracted secret values', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
OrchestratorSystem.Run.mockResolvedValue(JSON.stringify({ value: 'json-secret' }));
|
||||||
|
|
||||||
|
const source = {
|
||||||
|
name: 'test-source',
|
||||||
|
command: 'fetch {0}',
|
||||||
|
parseOutput: 'json-field' as const,
|
||||||
|
jsonField: 'value',
|
||||||
|
};
|
||||||
|
await SecretSourceService.fetchSecret(source, 'KEY');
|
||||||
|
|
||||||
|
expect(core.setSecret).toHaveBeenCalledWith('json-secret');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchFromEnv', () => {
|
||||||
|
it('should return env var value when set', () => {
|
||||||
|
process.env.TEST_SECRET_KEY = 'env-value';
|
||||||
|
const result = SecretSourceService.fetchFromEnv('TEST_SECRET_KEY');
|
||||||
|
expect(result).toBe('env-value');
|
||||||
|
delete process.env.TEST_SECRET_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string when env var is not set', () => {
|
||||||
|
const result = SecretSourceService.fetchFromEnv('NONEXISTENT_KEY_12345');
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mask env var values with core.setSecret', () => {
|
||||||
|
process.env.TEST_MASK_KEY = 'masked-env-value';
|
||||||
|
SecretSourceService.fetchFromEnv('TEST_MASK_KEY');
|
||||||
|
expect(core.setSecret).toHaveBeenCalledWith('masked-env-value');
|
||||||
|
delete process.env.TEST_MASK_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not mask empty env var values', () => {
|
||||||
|
const result = SecretSourceService.fetchFromEnv('NONEXISTENT_KEY_99999');
|
||||||
|
expect(result).toBe('');
|
||||||
|
expect(core.setSecret).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchAll', () => {
|
||||||
|
it('should fetch all keys from env source', async () => {
|
||||||
|
process.env.KEY_A = 'val-a';
|
||||||
|
process.env.KEY_B = 'val-b';
|
||||||
|
|
||||||
|
const results = await SecretSourceService.fetchAll('env', ['KEY_A', 'KEY_B']);
|
||||||
|
|
||||||
|
expect(results.KEY_A).toBe('val-a');
|
||||||
|
expect(results.KEY_B).toBe('val-b');
|
||||||
|
|
||||||
|
delete process.env.KEY_A;
|
||||||
|
delete process.env.KEY_B;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch all keys from premade source', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
OrchestratorSystem.Run.mockResolvedValueOnce('secret-1').mockResolvedValueOnce('secret-2');
|
||||||
|
|
||||||
|
const results = await SecretSourceService.fetchAll('aws-parameter-store', ['param1', 'param2']);
|
||||||
|
|
||||||
|
expect(results.param1).toBe('secret-1');
|
||||||
|
expect(results.param2).toBe('secret-2');
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty results for unknown source', async () => {
|
||||||
|
const results = await SecretSourceService.fetchAll('unknown', ['key1']);
|
||||||
|
expect(results).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadFromYaml', () => {
|
||||||
|
it('should return empty array when file does not exist', () => {
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||||
|
const result = SecretSourceService.loadFromYaml('/nonexistent.yml');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse valid YAML source definitions', () => {
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||||
|
(mockFs.readFileSync as jest.Mock).mockReturnValue(`
|
||||||
|
sources:
|
||||||
|
- name: my-vault
|
||||||
|
command: 'vault kv get -field=value secret/{0}'
|
||||||
|
- name: my-api
|
||||||
|
command: 'curl -s https://api.example.com/{0}'
|
||||||
|
parseOutput: json-field
|
||||||
|
jsonField: secret_value
|
||||||
|
`);
|
||||||
|
|
||||||
|
const result = SecretSourceService.loadFromYaml('/sources.yml');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].name).toBe('my-vault');
|
||||||
|
expect(result[0].command).toBe('vault kv get -field=value secret/{0}');
|
||||||
|
expect(result[1].name).toBe('my-api');
|
||||||
|
expect(result[1].parseOutput).toBe('json-field');
|
||||||
|
expect(result[1].jsonField).toBe('secret_value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle YAML with single source', () => {
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||||
|
(mockFs.readFileSync as jest.Mock).mockReturnValue(`
|
||||||
|
sources:
|
||||||
|
- name: simple
|
||||||
|
command: echo {0}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const result = SecretSourceService.loadFromYaml('/simple.yml');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe('simple');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array on parse error', () => {
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||||
|
(mockFs.readFileSync as jest.Mock).mockImplementation(() => {
|
||||||
|
throw new Error('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = SecretSourceService.loadFromYaml('/error.yml');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('premade source commands', () => {
|
||||||
|
it('aws-secrets-manager uses --query SecretString', () => {
|
||||||
|
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
||||||
|
expect(source.command).toContain('--query SecretString');
|
||||||
|
expect(source.command).toContain('--output text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aws-parameter-store uses --with-decryption', () => {
|
||||||
|
const source = SecretSourceService.resolveSource('aws-parameter-store')!;
|
||||||
|
expect(source.command).toContain('--with-decryption');
|
||||||
|
expect(source.command).toContain('--query Parameter.Value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gcp-secret-manager uses latest version', () => {
|
||||||
|
const source = SecretSourceService.resolveSource('gcp-secret-manager')!;
|
||||||
|
expect(source.command).toContain('latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('azure-key-vault uses AZURE_VAULT_NAME env var', () => {
|
||||||
|
const source = SecretSourceService.resolveSource('azure-key-vault')!;
|
||||||
|
expect(source.command).toContain('$AZURE_VAULT_NAME');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hashicorp-vault uses vault kv get with VAULT_MOUNT', () => {
|
||||||
|
const source = SecretSourceService.resolveSource('hashicorp-vault')!;
|
||||||
|
expect(source.command).toContain('vault kv get');
|
||||||
|
expect(source.command).toContain('VAULT_MOUNT');
|
||||||
|
expect(source.command).toContain('-field=value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hashicorp-vault-kv1 uses vault read for KV v1', () => {
|
||||||
|
const source = SecretSourceService.resolveSource('hashicorp-vault-kv1')!;
|
||||||
|
expect(source.command).toContain('vault read');
|
||||||
|
expect(source.command).toContain('-field=value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vault alias resolves to same command as hashicorp-vault', () => {
|
||||||
|
const vault = SecretSourceService.resolveSource('vault')!;
|
||||||
|
const hashicorpVault = SecretSourceService.resolveSource('hashicorp-vault')!;
|
||||||
|
expect(vault.command).toBe(hashicorpVault.command);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import * as core from '@actions/core';
|
||||||
|
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||||
|
import { OrchestratorSystem } from '../core/orchestrator-system';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secret source definition: how to fetch a secret value by key.
|
||||||
|
*/
|
||||||
|
export interface SecretSourceDefinition {
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
parseOutput?: 'raw' | 'json-field';
|
||||||
|
jsonField?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a secret key name contains only safe characters.
|
||||||
|
* Prevents shell injection when keys are interpolated into commands.
|
||||||
|
*
|
||||||
|
* Allowed characters: alphanumeric, hyphens, underscores, dots, forward slashes.
|
||||||
|
*
|
||||||
|
* @param key - The secret key name to validate
|
||||||
|
* @returns The validated key (unchanged)
|
||||||
|
* @throws Error if the key contains disallowed characters
|
||||||
|
*/
|
||||||
|
export function validateSecretKey(key: string): string {
|
||||||
|
if (!/^[a-zA-Z0-9\-_./]+$/.test(key)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid secret key name: "${key}". Keys may only contain alphanumeric characters, hyphens, underscores, dots, and forward slashes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask a secret value so it does not appear in GitHub Actions logs.
|
||||||
|
* Empty or whitespace-only values are skipped (core.setSecret would be a no-op).
|
||||||
|
*/
|
||||||
|
function maskSecretValue(value: string): void {
|
||||||
|
if (value.trim().length > 0) {
|
||||||
|
core.setSecret(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Premade secret sources and custom YAML-based secret source definitions.
|
||||||
|
*
|
||||||
|
* Premade sources are string shortcuts that expand to shell commands:
|
||||||
|
* - `aws-secrets-manager` -- AWS Secrets Manager
|
||||||
|
* - `aws-parameter-store` -- AWS Systems Manager Parameter Store
|
||||||
|
* - `gcp-secret-manager` -- Google Cloud Secret Manager
|
||||||
|
* - `azure-key-vault` -- Azure Key Vault (requires AZURE_VAULT_NAME env var)
|
||||||
|
* - `hashicorp-vault` -- HashiCorp Vault KV v2 (requires VAULT_ADDR, optionally VAULT_MOUNT)
|
||||||
|
* - `hashicorp-vault-kv1` -- HashiCorp Vault KV v1 (requires VAULT_ADDR, optionally VAULT_MOUNT)
|
||||||
|
* - `env` -- Read from environment variables (no shell command needed)
|
||||||
|
*
|
||||||
|
* Custom YAML format:
|
||||||
|
* sources:
|
||||||
|
* - name: my-vault
|
||||||
|
* command: 'vault kv get -field=value secret/{0}'
|
||||||
|
* - name: my-api
|
||||||
|
* command: 'curl -s https://secrets.example.com/api/{0}'
|
||||||
|
* parseOutput: json-field
|
||||||
|
* jsonField: value
|
||||||
|
*/
|
||||||
|
export class SecretSourceService {
|
||||||
|
private static readonly premadeSources: Record<string, SecretSourceDefinition> = {
|
||||||
|
'aws-secrets-manager': {
|
||||||
|
name: 'aws-secrets-manager',
|
||||||
|
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
|
||||||
|
parseOutput: 'raw',
|
||||||
|
},
|
||||||
|
'aws-secret-manager': {
|
||||||
|
// Alias for backward compatibility (original name in inputPullCommand)
|
||||||
|
name: 'aws-secret-manager',
|
||||||
|
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
|
||||||
|
parseOutput: 'raw',
|
||||||
|
},
|
||||||
|
'aws-parameter-store': {
|
||||||
|
name: 'aws-parameter-store',
|
||||||
|
command: 'aws ssm get-parameter --name {0} --with-decryption --query Parameter.Value --output text',
|
||||||
|
parseOutput: 'raw',
|
||||||
|
},
|
||||||
|
'gcp-secret-manager': {
|
||||||
|
name: 'gcp-secret-manager',
|
||||||
|
command: 'gcloud secrets versions access latest --secret="{0}"',
|
||||||
|
parseOutput: 'raw',
|
||||||
|
},
|
||||||
|
'azure-key-vault': {
|
||||||
|
name: 'azure-key-vault',
|
||||||
|
command: 'az keyvault secret show --vault-name "$AZURE_VAULT_NAME" --name {0} --query value --output tsv',
|
||||||
|
parseOutput: 'raw',
|
||||||
|
},
|
||||||
|
'hashicorp-vault': {
|
||||||
|
// HashiCorp Vault KV v2 (default). Requires VAULT_ADDR env var.
|
||||||
|
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
|
||||||
|
// Authentication is handled by VAULT_TOKEN or other Vault auth env vars.
|
||||||
|
name: 'hashicorp-vault',
|
||||||
|
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
|
||||||
|
parseOutput: 'raw',
|
||||||
|
},
|
||||||
|
'hashicorp-vault-kv1': {
|
||||||
|
// HashiCorp Vault KV v1. Requires VAULT_ADDR env var.
|
||||||
|
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
|
||||||
|
name: 'hashicorp-vault-kv1',
|
||||||
|
command: 'vault read -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
|
||||||
|
parseOutput: 'raw',
|
||||||
|
},
|
||||||
|
vault: {
|
||||||
|
// Short alias for hashicorp-vault (KV v2)
|
||||||
|
name: 'vault',
|
||||||
|
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
|
||||||
|
parseOutput: 'raw',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a source name is a known premade source.
|
||||||
|
*/
|
||||||
|
static isPremadeSource(sourceName: string): boolean {
|
||||||
|
return sourceName in SecretSourceService.premadeSources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of available premade source names.
|
||||||
|
*/
|
||||||
|
static getAvailableSources(): string[] {
|
||||||
|
return Object.keys(SecretSourceService.premadeSources);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a source name to a SecretSourceDefinition.
|
||||||
|
*
|
||||||
|
* - If the name matches a premade source, returns that definition.
|
||||||
|
* - If it looks like a shell command (contains spaces or {0}), wraps it as a custom command.
|
||||||
|
* - Otherwise, returns undefined.
|
||||||
|
*/
|
||||||
|
static resolveSource(sourceName: string): SecretSourceDefinition | undefined {
|
||||||
|
// Check premade sources
|
||||||
|
if (SecretSourceService.isPremadeSource(sourceName)) {
|
||||||
|
return SecretSourceService.premadeSources[sourceName];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it contains a placeholder or spaces, treat it as a raw command
|
||||||
|
if (sourceName.includes('{0}') || sourceName.includes(' ')) {
|
||||||
|
return {
|
||||||
|
name: 'custom-command',
|
||||||
|
command: sourceName,
|
||||||
|
parseOutput: 'raw',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load custom secret source definitions from a YAML file.
|
||||||
|
*
|
||||||
|
* Expected format:
|
||||||
|
* sources:
|
||||||
|
* - name: my-source
|
||||||
|
* command: 'my-tool get-secret {0}'
|
||||||
|
* - name: my-api
|
||||||
|
* command: 'curl -s https://api.example.com/secrets/{0}'
|
||||||
|
* parseOutput: json-field
|
||||||
|
* jsonField: value
|
||||||
|
*/
|
||||||
|
static loadFromYaml(filePath: string): SecretSourceDefinition[] {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
OrchestratorLogger.logWarning(`Secret source YAML not found: ${filePath}`);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const parsed = SecretSourceService.parseSimpleYaml(content);
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch (error: any) {
|
||||||
|
OrchestratorLogger.logWarning(`Failed to parse secret source YAML: ${error.message}`);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a secret value using the given source definition.
|
||||||
|
*
|
||||||
|
* Validates the key against an allowlist pattern before interpolating it
|
||||||
|
* into the command string to prevent shell injection. The fetched secret
|
||||||
|
* value is masked via core.setSecret() so it does not leak in logs.
|
||||||
|
*
|
||||||
|
* @param source - The secret source definition to use
|
||||||
|
* @param key - The secret key to fetch
|
||||||
|
* @returns The secret value, or empty string on failure
|
||||||
|
*/
|
||||||
|
static async fetchSecret(source: SecretSourceDefinition, key: string): Promise<string> {
|
||||||
|
// Validate the key to prevent shell injection
|
||||||
|
validateSecretKey(key);
|
||||||
|
|
||||||
|
const command = source.command.replace(/\{0\}/g, key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await OrchestratorSystem.Run(command, false, true);
|
||||||
|
|
||||||
|
let value: string;
|
||||||
|
|
||||||
|
if (source.parseOutput === 'json-field' && source.jsonField) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
value = parsed[source.jsonField] || '';
|
||||||
|
} catch {
|
||||||
|
OrchestratorLogger.logWarning(`Failed to parse JSON output from ${source.name} for key ${key}`);
|
||||||
|
value = output.trim();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value = output.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mask the secret value so it does not appear in GitHub Actions logs
|
||||||
|
maskSecretValue(value);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
} catch (error: any) {
|
||||||
|
OrchestratorLogger.logWarning(`Failed to fetch secret '${key}' from ${source.name}: ${error.message}`);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a secret from an environment variable. No shell command needed.
|
||||||
|
* The value is masked via core.setSecret() so it does not leak in logs.
|
||||||
|
*/
|
||||||
|
static fetchFromEnv(key: string): string {
|
||||||
|
const value = process.env[key] || '';
|
||||||
|
maskSecretValue(value);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a source name and fetch all secrets from it.
|
||||||
|
*
|
||||||
|
* @param sourceName - Premade source name, shell command, or 'env'
|
||||||
|
* @param keys - List of secret keys to fetch
|
||||||
|
* @returns Map of key -> value
|
||||||
|
*/
|
||||||
|
static async fetchAll(sourceName: string, keys: string[]): Promise<Record<string, string>> {
|
||||||
|
const results: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (sourceName === 'env') {
|
||||||
|
for (const key of keys) {
|
||||||
|
results[key] = SecretSourceService.fetchFromEnv(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = SecretSourceService.resolveSource(sourceName);
|
||||||
|
if (!source) {
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
`Unknown secret source '${sourceName}'. Available sources: ${SecretSourceService.getAvailableSources().join(
|
||||||
|
', ',
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
OrchestratorLogger.log(`Fetching ${keys.length} secret(s) from ${source.name}`);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
results[key] = await SecretSourceService.fetchSecret(source, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple YAML parser for secret source definitions.
|
||||||
|
* Handles the specific structure we expect without requiring a YAML library.
|
||||||
|
*/
|
||||||
|
private static parseSimpleYaml(content: string): SecretSourceDefinition[] {
|
||||||
|
const definitions: SecretSourceDefinition[] = [];
|
||||||
|
const lines = content.split('\n');
|
||||||
|
let current: Partial<SecretSourceDefinition> | null = null;
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.replace(/\r$/, '');
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
||||||
|
|
||||||
|
if (trimmed === '- name:' || trimmed.startsWith('- name:')) {
|
||||||
|
if (current?.name && current?.command) {
|
||||||
|
definitions.push(current as SecretSourceDefinition);
|
||||||
|
}
|
||||||
|
|
||||||
|
current = {
|
||||||
|
name: trimmed
|
||||||
|
.replace('- name:', '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^['"]|['"]$/g, ''),
|
||||||
|
parseOutput: 'raw',
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current && trimmed.startsWith('command:')) {
|
||||||
|
current.command = trimmed
|
||||||
|
.replace('command:', '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^['"]|['"]$/g, '');
|
||||||
|
} else if (current && trimmed.startsWith('parseOutput:')) {
|
||||||
|
const value = trimmed
|
||||||
|
.replace('parseOutput:', '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^['"]|['"]$/g, '');
|
||||||
|
current.parseOutput = value as 'raw' | 'json-field';
|
||||||
|
} else if (current && trimmed.startsWith('jsonField:')) {
|
||||||
|
current.jsonField = trimmed
|
||||||
|
.replace('jsonField:', '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^['"]|['"]$/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current?.name && current?.command) {
|
||||||
|
definitions.push(current as SecretSourceDefinition);
|
||||||
|
}
|
||||||
|
|
||||||
|
return definitions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ printenv
|
|||||||
git config --global advice.detachedHead false
|
git config --global advice.detachedHead false
|
||||||
git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"
|
git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"
|
||||||
git config --global filter.lfs.process "git-lfs filter-process --skip"
|
git config --global filter.lfs.process "git-lfs filter-process --skip"
|
||||||
|
${OrchestratorFolders.gitAuthConfigScript}
|
||||||
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
|
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
|
||||||
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
|
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
|
||||||
if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
|
if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { CommandHookService } from '../services/hooks/command-hook-service';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import Orchestrator from '../orchestrator';
|
import Orchestrator from '../orchestrator';
|
||||||
import { ContainerHookService } from '../services/hooks/container-hook-service';
|
import { ContainerHookService } from '../services/hooks/container-hook-service';
|
||||||
import { MiddlewareService } from '../services/hooks/middleware-service';
|
|
||||||
import { CustomWorkflow } from './custom-workflow';
|
|
||||||
|
|
||||||
export class BuildAutomationWorkflow implements WorkflowInterface {
|
export class BuildAutomationWorkflow implements WorkflowInterface {
|
||||||
async run(orchestratorStepState: OrchestratorStepParameters) {
|
async run(orchestratorStepState: OrchestratorStepParameters) {
|
||||||
@@ -15,41 +13,13 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async standardBuildAutomation(baseImage: string, orchestratorStepState: OrchestratorStepParameters) {
|
private static async standardBuildAutomation(baseImage: string, orchestratorStepState: OrchestratorStepParameters) {
|
||||||
|
// TODO accept post and pre build steps as yaml files in the repo
|
||||||
OrchestratorLogger.log(`Orchestrator is running standard build automation`);
|
OrchestratorLogger.log(`Orchestrator is running standard build automation`);
|
||||||
|
|
||||||
let output = '';
|
let output = '';
|
||||||
|
|
||||||
// Load middleware pipeline
|
|
||||||
const middleware = MiddlewareService.getMiddleware(Orchestrator.buildParameters.middleware);
|
|
||||||
|
|
||||||
// --- Pre-build phase ---
|
|
||||||
// Middleware container hooks: pre-build before
|
|
||||||
const preBuildMiddlewareBefore = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
|
|
||||||
if (preBuildMiddlewareBefore.length > 0) {
|
|
||||||
output += await CustomWorkflow.runContainerJob(
|
|
||||||
preBuildMiddlewareBefore,
|
|
||||||
orchestratorStepState.environment,
|
|
||||||
orchestratorStepState.secrets,
|
|
||||||
);
|
|
||||||
OrchestratorLogger.logWithTime('Middleware pre-build:before container step(s) time');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard pre-build container hooks
|
|
||||||
output += await ContainerHookService.RunPreBuildSteps(orchestratorStepState);
|
output += await ContainerHookService.RunPreBuildSteps(orchestratorStepState);
|
||||||
OrchestratorLogger.logWithTime('Configurable pre build step(s) time');
|
OrchestratorLogger.logWithTime('Configurable pre build step(s) time');
|
||||||
|
|
||||||
// Middleware container hooks: pre-build after
|
|
||||||
const preBuildMiddlewareAfter = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'after');
|
|
||||||
if (preBuildMiddlewareAfter.length > 0) {
|
|
||||||
output += await CustomWorkflow.runContainerJob(
|
|
||||||
preBuildMiddlewareAfter,
|
|
||||||
orchestratorStepState.environment,
|
|
||||||
orchestratorStepState.secrets,
|
|
||||||
);
|
|
||||||
OrchestratorLogger.logWithTime('Middleware pre-build:after container step(s) time');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Build phase ---
|
|
||||||
OrchestratorLogger.log(baseImage);
|
OrchestratorLogger.log(baseImage);
|
||||||
OrchestratorLogger.logLine(` `);
|
OrchestratorLogger.logLine(` `);
|
||||||
OrchestratorLogger.logLine('Starting build automation job');
|
OrchestratorLogger.logLine('Starting build automation job');
|
||||||
@@ -65,33 +35,9 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
|||||||
);
|
);
|
||||||
OrchestratorLogger.logWithTime('Build time');
|
OrchestratorLogger.logWithTime('Build time');
|
||||||
|
|
||||||
// --- Post-build phase ---
|
|
||||||
// Middleware container hooks: post-build before
|
|
||||||
const postBuildMiddlewareBefore = MiddlewareService.resolveContainerHooks(middleware, 'post-build', 'before');
|
|
||||||
if (postBuildMiddlewareBefore.length > 0) {
|
|
||||||
output += await CustomWorkflow.runContainerJob(
|
|
||||||
postBuildMiddlewareBefore,
|
|
||||||
orchestratorStepState.environment,
|
|
||||||
orchestratorStepState.secrets,
|
|
||||||
);
|
|
||||||
OrchestratorLogger.logWithTime('Middleware post-build:before container step(s) time');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard post-build container hooks
|
|
||||||
output += await ContainerHookService.RunPostBuildSteps(orchestratorStepState);
|
output += await ContainerHookService.RunPostBuildSteps(orchestratorStepState);
|
||||||
OrchestratorLogger.logWithTime('Configurable post build step(s) time');
|
OrchestratorLogger.logWithTime('Configurable post build step(s) time');
|
||||||
|
|
||||||
// Middleware container hooks: post-build after
|
|
||||||
const postBuildMiddlewareAfter = MiddlewareService.resolveContainerHooks(middleware, 'post-build', 'after');
|
|
||||||
if (postBuildMiddlewareAfter.length > 0) {
|
|
||||||
output += await CustomWorkflow.runContainerJob(
|
|
||||||
postBuildMiddlewareAfter,
|
|
||||||
orchestratorStepState.environment,
|
|
||||||
orchestratorStepState.secrets,
|
|
||||||
);
|
|
||||||
OrchestratorLogger.logWithTime('Middleware post-build:after container step(s) time');
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Orchestrator finished running standard build automation`);
|
OrchestratorLogger.log(`Orchestrator finished running standard build automation`);
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
@@ -104,13 +50,6 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
|||||||
const buildHooks = CommandHookService.getHooks(Orchestrator.buildParameters.commandHooks).filter((x) =>
|
const buildHooks = CommandHookService.getHooks(Orchestrator.buildParameters.commandHooks).filter((x) =>
|
||||||
x.step?.includes(`build`),
|
x.step?.includes(`build`),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resolve middleware command hooks for setup and build phases
|
|
||||||
const middleware = MiddlewareService.getMiddleware(Orchestrator.buildParameters.middleware);
|
|
||||||
const middlewareSetupBefore = MiddlewareService.resolveCommandHooks(middleware, 'setup', 'before');
|
|
||||||
const middlewareSetupAfter = MiddlewareService.resolveCommandHooks(middleware, 'setup', 'after');
|
|
||||||
const middlewareBuildBefore = MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
|
|
||||||
const middlewareBuildAfter = MiddlewareService.resolveCommandHooks(middleware, 'build', 'after');
|
|
||||||
const isContainerized =
|
const isContainerized =
|
||||||
Orchestrator.buildParameters.providerStrategy === 'aws' ||
|
Orchestrator.buildParameters.providerStrategy === 'aws' ||
|
||||||
Orchestrator.buildParameters.providerStrategy === 'k8s' ||
|
Orchestrator.buildParameters.providerStrategy === 'k8s' ||
|
||||||
@@ -132,7 +71,6 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
|||||||
? 'apt-get install -y curl tar tree npm git-lfs jq git > /dev/null || true\n npm --version || true\n npm i -g n > /dev/null || true\n npm i -g semver > /dev/null || true\n npm install --global yarn > /dev/null || true\n n 20.8.0 || true\n node --version || true'
|
? 'apt-get install -y curl tar tree npm git-lfs jq git > /dev/null || true\n npm --version || true\n npm i -g n > /dev/null || true\n npm i -g semver > /dev/null || true\n npm install --global yarn > /dev/null || true\n n 20.8.0 || true\n node --version || true'
|
||||||
: '# skipping toolchain setup in local-docker or non-container provider'
|
: '# skipping toolchain setup in local-docker or non-container provider'
|
||||||
}
|
}
|
||||||
${middlewareSetupBefore.map((x) => x.commands).join('\n ') || ' '}
|
|
||||||
${setupHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
|
${setupHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
|
||||||
${
|
${
|
||||||
Orchestrator.buildParameters.providerStrategy === 'local-docker'
|
Orchestrator.buildParameters.providerStrategy === 'local-docker'
|
||||||
@@ -144,12 +82,9 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
|||||||
export LOG_FILE=${isContainerized ? '/home/job-log.txt' : '$(pwd)/temp/job-log.txt'}
|
export LOG_FILE=${isContainerized ? '/home/job-log.txt' : '$(pwd)/temp/job-log.txt'}
|
||||||
${BuildAutomationWorkflow.setupCommands(builderPath, isContainerized)}
|
${BuildAutomationWorkflow.setupCommands(builderPath, isContainerized)}
|
||||||
${setupHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
|
${setupHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
|
||||||
${middlewareSetupAfter.map((x) => x.commands).join('\n ') || ' '}
|
|
||||||
${middlewareBuildBefore.map((x) => x.commands).join('\n ') || ' '}
|
|
||||||
${buildHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
|
${buildHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
|
||||||
${BuildAutomationWorkflow.BuildCommands(builderPath, isContainerized)}
|
${BuildAutomationWorkflow.BuildCommands(builderPath, isContainerized)}
|
||||||
${buildHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
|
${buildHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}`;
|
||||||
${middlewareBuildAfter.map((x) => x.commands).join('\n ') || ' '}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static setupCommands(builderPath: string, isContainerized: boolean) {
|
private static setupCommands(builderPath: string, isContainerized: boolean) {
|
||||||
@@ -157,6 +92,7 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
|||||||
const commands = `mkdir -p ${OrchestratorFolders.ToLinuxFolder(
|
const commands = `mkdir -p ${OrchestratorFolders.ToLinuxFolder(
|
||||||
OrchestratorFolders.builderPathAbsolute,
|
OrchestratorFolders.builderPathAbsolute,
|
||||||
)}
|
)}
|
||||||
|
${OrchestratorFolders.gitAuthConfigScript}
|
||||||
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
|
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
|
||||||
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
|
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
|
||||||
DEST="${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.builderPathAbsolute)}"
|
DEST="${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.builderPathAbsolute)}"
|
||||||
|
|||||||
Reference in New Issue
Block a user