Compare commits

...

103 Commits

Author SHA1 Message Date
frostebite
8e5b91558a temp: workflow to push secrets to orchestrator repo
One-shot workflow_dispatch to copy UNITY_EMAIL, UNITY_PASSWORD,
UNITY_SERIAL, and GIT_PRIVATE_TOKEN to game-ci/orchestrator.
Delete this branch after running.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:41:20 +00:00
frostebite
b351f4d738 revert: restore localstack:latest now that SDK is pinned
The S3 deserialization issue was caused by @aws-sdk/client-s3 v3.1005
(schema-based AwsRestXmlProtocol), not LocalStack's version. The SDK
is now pinned to ~3.779.0 in the orchestrator repo, so localstack:latest
works correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:27:04 +00:00
frostebite
b78785eba1 fix(ci): pin LocalStack to v3.8.1 for AWS SDK v3 compatibility
localstack:latest (v4.14+) returns JSON responses for some S3 operations,
but @aws-sdk/client-s3 v3.779+ uses AwsRestXmlProtocol which expects XML.
This breaks all SharedWorkspaceLocking tests (locking, e2e caching,
retaining). Pin to v3.8.1 (last v3 release) where the S3 provider
returns proper XML responses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:11:42 +00:00
frostebite
afcb405e96 ci: wire orchestrator integration tests into integrity check
- Add workflow_call trigger to validate-orchestrator-integration.yml
  so other workflows can invoke the exhaustive test suite
- Add orchestrator-integration job to integrity-check.yml that runs
  on pushes to main (skipped on PRs to avoid 1-2h CI time)
- Daily cron + manual dispatch remain as fallback triggers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 07:11:06 +00:00
frostebite
8e79c867bd ci: add smoke test for orchestrator build wiring
Verifies end-to-end that loadOrchestrator().run() is correctly wired
to Orchestrator.run(), BuildParameters.create() produces valid config,
and plugin services resolve to real implementations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:35:50 +00:00
frostebite
c91f9d16ee fix: only suppress module-not-found errors in plugin loader
Previously both loadOrchestrator() and loadPluginServices() caught all
errors, masking real failures like syntax errors or missing transitive
dependencies. Now only MODULE_NOT_FOUND / ERR_MODULE_NOT_FOUND errors
are suppressed; all other exceptions are rethrown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:34:10 +00:00
frostebite
25f0a9c9f2 ci: remove (Nightly) from integration tests workflow name
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:03:12 +00:00
frostebite
e604053195 fix(ci): update workflow references from loadEnterpriseServices to loadPluginServices
CI workflows still referenced the old function name after the rename.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:30:58 +00:00
frostebite
7cbdefc00a refactor: rename enterprise services to plugin services
The orchestrator is a plugin, not an enterprise feature. Renamed
loadEnterpriseServices -> loadPluginServices and all related variables,
types, log messages, and test descriptions to use "plugin" terminology.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:20:06 +00:00
Frostebite
d6fcc2bb1e Potential fix for code scanning alert no. 78: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-10 08:10:02 +00:00
frostebite
c4896aae2b refactor: move install scripts to orchestrator repo
Install scripts now live at game-ci/orchestrator where the CLI releases
are published. Removed from unity-builder to avoid duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:46:37 +00:00
frostebite
e34f4e46b6 ci: add yarn.lock to validate-orchestrator path filters
Ensure orchestrator validation runs when yarn.lock changes, since
dependency updates can affect plugin compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:32:44 +00:00
frostebite
ee53114d77 refactor(ci): split orchestrator tests into per-PR health checks and nightly exhaustive suite
validate-orchestrator.yml (per-PR, ~5 min):
  - Plugin architecture health: compilation, unit tests, plugin loader
    graceful degradation, installed service validation, type declaration checks

validate-orchestrator-integration.yml (daily 3 AM UTC cron, ~1-2h):
  - 5 parallel jobs mirroring orchestrator-integrity.yml:
    plugin-interface, k8s (5 tests), aws (10 tests),
    local-docker (9 tests), rclone (1 test)
  - Full LocalStack + k3d integration coverage
  - continue-on-error on known flaky end2end tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:19:20 +00:00
frostebite
72fdbd86df fix(ci): add refactor/** branch pattern and workflow_dispatch to orchestrator workflows
The refactor/orchestrator-extraction branch was not matching the
feature/** pattern, preventing the integration workflow from running
after fix commits were pushed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:05:21 +00:00
frostebite
ad1a1e8cb2 fix(ci): add build step to k8s and aws integration jobs
The orchestrator tests need compiled output (dist/index.js) to exist
before running integration tests that spawn containers/k8s jobs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 06:48:27 +00:00
frostebite
0557688d0a feat(ci): add orchestrator integration tests and plugin interface tests
- Add validate-orchestrator-integration.yml with 3 parallel jobs:
  plugin-interface (unit tests + smoke tests), k8s-integration
  (k3d + localstack), and aws-integration (localstack only)
- Add orchestrator-plugin.test.ts with 15 unit tests covering
  loadOrchestrator() and loadEnterpriseServices() for both
  installed and not-installed states
- Disk space management follows proven patterns from orchestrator
  repo (parallel jobs, aggressive cleanup between tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 06:35:05 +00:00
frostebite
02f9cee483 chore: remove temporary delete-me scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 06:23:05 +00:00
frostebite
c69c09579b fix(ci): use --legacy-peer-deps for orchestrator install in validation
The orchestrator package brings eslint dependencies that conflict with
unity-builder's peer deps. Since this install is only for smoke-testing
the plugin loader, --legacy-peer-deps is safe here.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 06:13:19 +00:00
frostebite
7b2bd2638e fix(ci): remove reference to deleted orchestrator-integrity.yml
The orchestrator job in integrity-check.yml called the deleted
orchestrator-integrity.yml workflow, causing CI failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 06:10:37 +00:00
frostebite
52bed195b5 refactor: move CLI to orchestrator, fix validate-orchestrator workflow
- Delete src/cli.ts, src/cli/ (commands, tests, input-mapper) — moved
  to game-ci/orchestrator repo (PR #813 reference)
- Delete .github/workflows/release-cli.yml — moved to orchestrator
- Remove bin, pkg, yargs, @types/yargs, pkg from package.json
- Fix validate-orchestrator.yml:
  - Build TypeScript before running require() smoke tests
  - Remove || echo fallback that swallowed errors
  - Add smoke test that installs orchestrator via npm pack and
    verifies loadOrchestrator() returns defined exports

Legacy src/model/cli/ (Cli class, CliFunctionsRepository) preserved —
used by Input.getInput() and build-parameters.ts on main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 05:48:34 +00:00
frostebite
01c718a405 refactor: extract orchestrator — delete 30k lines, decouple all imports
Remove the entire src/model/orchestrator/ directory (148 files, ~30k lines)
and refactor all dependent code to use the plugin loader pattern.

Key changes:
- build-parameters.ts: replace OrchestratorOptions with Input.getInput()
- input.ts: remove OrchestratorQueryOverride input source
- github.ts: strip to minimal class (only githubInputEnabled remains)
- cli/cli.ts: remove orchestrator CLI commands, simplify to core structure
- input-readers/*: replace OrchestratorSystem.Run with child_process.exec
- orchestrator-plugin.ts: import from @game-ci/orchestrator package
- orchestrate.ts, build.ts: use plugin loader instead of direct imports
- index.ts: inline SyncStrategy type, fix implicit any types
- Add type declarations for @game-ci/orchestrator
- Remove orchestrator-only npm dependencies (AWS SDK, K8s, etc.)
- Remove orchestrator-specific npm scripts and CI workflows
- Update validate-orchestrator.yml for external repo validation

All enterprise features gracefully degrade when @game-ci/orchestrator
is not installed — the plugin loader returns undefined and optional
chaining in index.ts skips all enterprise service calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:32:57 +00:00
frostebite
d34f77c043 refactor: route orchestrator through plugin loader
Replace 8 direct orchestrator service imports with a thin plugin loader.
- loadOrchestrator(): loads remote build orchestration
- loadEnterpriseServices(): loads enterprise features for local builds

All functionality is preserved; only the import mechanism changes.
This is the first step toward making orchestrator an optional dependency.

Includes comprehensive integration tests for enterprise feature wiring
that verify gating logic, call ordering, and provider strategy routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:04:01 +00:00
frostebite
20b01e1325 ci: add orchestrator compatibility validation workflow
Runs on PRs that touch orchestrator source or bridge files.
Validates:
- Orchestrator source files are in sync with standalone repo
- Bridge file exports exist in both repos
- Orchestrator tests pass in both unity-builder and standalone contexts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:56:12 +00:00
frostebite
25dbf37f6b refactor(cli): move cache command under orchestrate subcommand
Cache is an orchestrator feature, so it belongs under `game-ci orchestrate cache`
rather than as a top-level `game-ci cache` command.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:17:12 +00:00
frostebite
39f885dcaf fix: downgrade yargs to ^17.7.2 and revert Node to 18 for CI compatibility
yargs@18 requires Node >=20.19.0 which is incompatible with CI's Node 18.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 08:01:42 +00:00
frostebite
0cb3cb35a1 fix(ci): bump node version to 20 in integrity-check
yargs@18.0.0 requires Node >=20.19.0, so Node 18 is no longer
compatible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 05:47:51 +00:00
frostebite
e2a778dc80 fix(test): add gitAuthMode to orchestrator-folders test mock
The test mock was missing gitAuthMode, causing useHeaderAuth to
default to true and strip the token from repo URLs. Adding
gitAuthMode: 'url' restores the expected URL-mode behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 05:45:37 +00:00
frostebite
222665a9ec revert: restore build-tests-mac.yml to match main
Stop modifying the macOS build workflow — leave it identical to main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:56:15 +00:00
frostebite
b19ced70e4 ci: mark failed macOS builds as neutral instead of failure
Use the Checks API to flip failed macOS build conclusions to neutral
(gray dash) so unstable builds don't show red X marks on PRs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:20:07 +00:00
frostebite
cefebcabc9 Merge remote-tracking branch 'origin/feature/orchestrator-enterprise-support' into release/lts-2.0.0
Merges latest commits from PR #777 (git hooks fix + 129 new tests).
Resolved merge conflicts:
- dist/index.js, dist/index.js.map: rebuilt from merged source
- src/model/input.ts: fixed 5 missing closing braces and return statements from merge
2026-03-06 09:39:17 +00:00
frostebite
488c259481 test: add integration wiring and input parsing tests for enterprise features
Add three test files covering the two highest-priority gaps in PR #777:

1. src/index-enterprise-features.test.ts (21 tests) - Integration wiring
   tests for index.ts that verify conditional gating of all enterprise
   services (GitHooks, LocalCache, ChildWorkspace, SubmoduleProfile,
   LfsAgent). Tests that disabled features (default) are never invoked,
   enabled features call the correct service methods, and the order of
   operations is correct (restore before build, save after build).
   Also tests non-local provider strategy skips all enterprise features.

2. src/model/enterprise-inputs.test.ts (103 tests) - Input/BuildParameters
   wiring tests for all 20 new enterprise properties. Covers defaults,
   explicit values, and boolean string parsing edge cases (the #1 source
   of bugs: 'false' as truthy, 'TRUE' case sensitivity, '1', 'yes').
   Verifies BuildParameters.create() correctly maps all Input getters.

3. src/model/orchestrator/services/submodule/submodule-profile-service.test.ts
   (5 new tests) - Command construction safety tests for execute(),
   documenting how paths, branches, and tokens are passed into git
   commands and verifying the expected command strings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:29:10 +00:00
frostebite
7b8f109334 fix: make git hooks opt-in only — do not modify hooks when disabled
Remove the else branch that actively called GitHooksService.disableHooks()
for every user where gitHooksEnabled was false (the default). This was a
breaking change that silently modified core.hooksPath to point at an empty
directory, disabling any existing git hooks (husky, lefthook, pre-commit, etc.).

When gitHooksEnabled is false (default), the action now does nothing
regarding hooks — exactly matching the behavior on main before the hooks
feature was added. The hooks feature only activates when users explicitly
set gitHooksEnabled: true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:53:15 +00:00
frostebite
db42c76d41 ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:36:45 +00:00
frostebite
d82481b922 chore: add release/lts-infrastructure to update-all script 2026-03-05 23:34:45 +00:00
frostebite
f2e7568d47 ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:34:12 +00:00
frostebite
4979a39d13 chore: add integration branch update scripts for release/lts-2.0.0 2026-03-05 21:12:51 +00:00
frostebite
2eb5bd3423 Merge remote-tracking branch 'origin/feature/cli-support' into release/lts-2.0.0
# Conflicts:
#	dist/index.js.map
2026-03-05 21:12:05 +00:00
frostebite
90d7b26980 Merge remote-tracking branch 'origin/feature/community-plugin-validation' into release/lts-2.0.0
# Conflicts:
#	dist/index.js.map
2026-03-05 21:11:55 +00:00
frostebite
1f5f6b76c3 Merge remote-tracking branch 'origin/feature/incremental-sync-protocol' into release/lts-2.0.0
# Conflicts:
#	action.yml
#	dist/index.js
#	dist/index.js.map
#	src/index.ts
#	src/model/build-parameters.ts
2026-03-05 21:11:43 +00:00
frostebite
c5043df431 Merge remote-tracking branch 'origin/feature/generic-artifact-system' into release/lts-2.0.0
# Conflicts:
#	dist/index.js
#	dist/index.js.map
#	src/index.ts
#	src/model/build-parameters.ts
2026-03-05 21:09:45 +00:00
frostebite
a864249881 Merge remote-tracking branch 'origin/feature/hot-runner-protocol' into release/lts-2.0.0
# Conflicts:
#	dist/index.js
#	dist/index.js.map
#	src/index.ts
#	src/model/build-parameters.ts
#	src/model/input.ts
2026-03-05 21:09:26 +00:00
frostebite
415d9e234c Merge remote-tracking branch 'origin/feature/test-workflow-engine' into release/lts-2.0.0
# Conflicts:
#	action.yml
#	dist/index.js
#	dist/index.js.map
#	src/index.ts
#	src/model/build-parameters.ts
#	src/model/input.ts
2026-03-05 21:09:06 +00:00
frostebite
c7422c0943 Merge remote-tracking branch 'origin/ci/orchestrator-integrity-speedup' into release/lts-2.0.0
# Conflicts:
#	.github/workflows/orchestrator-integrity.yml
#	dist/index.js.map
2026-03-05 21:08:47 +00:00
frostebite
750c10b31b Merge remote-tracking branch 'origin/feature/build-reliability' into release/lts-2.0.0
# Conflicts:
#	action.yml
#	dist/index.js
#	dist/index.js.map
#	src/model/build-parameters.ts
#	src/model/input.ts
2026-03-05 21:08:16 +00:00
frostebite
e34af96271 Merge remote-tracking branch 'origin/feature/ci-platform-providers' into release/lts-2.0.0
# Conflicts:
#	action.yml
#	dist/index.js.map
#	src/model/build-parameters.ts
#	src/model/input.ts
#	src/model/orchestrator/orchestrator.ts
2026-03-05 21:07:52 +00:00
frostebite
ae296a7792 Merge remote-tracking branch 'origin/feature/premade-secret-sources' into release/lts-2.0.0
# Conflicts:
#	action.yml
#	dist/index.js.map
2026-03-05 21:07:26 +00:00
frostebite
d53cd983d6 Merge remote-tracking branch 'origin/fix/secure-git-token-usage' into release/lts-2.0.0
# Conflicts:
#	dist/index.js
#	dist/index.js.map
#	src/model/build-parameters.ts
#	src/model/orchestrator/options/orchestrator-options.ts
2026-03-05 21:06:52 +00:00
frostebite
011e341794 Merge remote-tracking branch 'origin/feature/orchestrator-unit-tests' into release/lts-2.0.0
# Conflicts:
#	.github/workflows/orchestrator-integrity.yml
#	dist/index.js.map
2026-03-05 21:06:03 +00:00
frostebite
a16f663cf2 Merge remote-tracking branch 'origin/feature/provider-load-balancing' into release/lts-2.0.0
# Conflicts:
#	dist/index.js.map
2026-03-05 21:05:34 +00:00
frostebite
4724eea88f Merge remote-tracking branch 'origin/feature/cloud-run-azure-providers' into release/lts-2.0.0
# Conflicts:
#	action.yml
#	dist/index.js
#	dist/index.js.map
#	src/model/input.ts
#	src/model/orchestrator/providers/provider-loader.ts
2026-03-05 21:05:24 +00:00
frostebite
bcf32b407d Merge remote-tracking branch 'origin/feature/orchestrator-enterprise-support' into release/lts-2.0.0 2026-03-05 21:03:20 +00:00
frostebite
b4ffa3e070 ci: split orchestrator integrity into 4 parallel jobs to fix timeout
The monolithic orchestrator-integrity workflow runs 25+ tests sequentially
in a single job, consistently hitting the 60-minute timeout on PR runs.
Split into 4 parallel jobs (k8s, aws-provider, local-docker, rclone) each
on its own runner, cutting wall-clock time from 3+ hours to ~1 hour and
eliminating disk space exhaustion from shared runner contention.

Adopts the parallel architecture from PR #809.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:43:59 +00:00
frostebite
280a10d107 fix(cli): address review findings — exit codes, missing inputs, null safety
- Add process.exit(1) in cli.ts catch block so failures produce non-zero exit codes
- Add 6 missing build inputs: containerRegistryRepository, containerRegistryImageVersion,
  dockerIsolationMode, sshPublicKeysDirectoryPath, cacheUnityInstallationOnMac, unityHubVersionOnMac
- Add 6 missing orchestrate inputs: kubeStorageClass, readInputFromOverrideList,
  readInputOverrideCommand, postBuildSteps, preBuildSteps, customJob
- Fix activate command description to accurately reflect verification behavior
- Add null check before accessing result.BuildResults in orchestrate handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:27:19 +00:00
frostebite
79d12aa588 feat(cli): add release workflow, install scripts, and self-update command
Replace the npm-only publish-cli.yml with a comprehensive release-cli.yml
that builds standalone binaries via pkg for all platforms (Linux/macOS/Windows,
x64/arm64), uploads them as GitHub Release assets with SHA256 checksums,
and retains npm publish as an optional job.

Add curl-pipe-sh installer (install.sh) and PowerShell installer (install.ps1)
for one-liner installation from GitHub Releases. Both scripts auto-detect
platform/architecture, verify checksums, and guide PATH configuration.

Add `game-ci update` command for self-updating standalone binaries: checks
GitHub releases for newer versions, downloads the correct platform binary,
verifies it, and atomically replaces the running executable.

Distribution strategy: GitHub Releases (primary), npm (optional), with
winget/Homebrew/Chocolatey/Scoop as future providers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:12:07 +00:00
frostebite
5bdcf12059 feat(cli): add npm publish workflow and CLI tests
Add .github/workflows/publish-cli.yml for publishing the CLI to npm on
release or via manual workflow_dispatch with dry-run support.

Add comprehensive test coverage for the CLI:
- input-mapper.test.ts: 16 tests covering argument mapping, boolean
  conversion, yargs internal property filtering, and Cli.options population
- commands.test.ts: 26 tests verifying command exports, builder flags,
  default values, and camelCase aliases for all six commands
- cli-integration.test.ts: 8 integration tests spawning the CLI process
  to verify help output, version info, and error handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:02:03 +00:00
frostebite
5a42214cda feat: add official game-ci CLI with build, activate, and orchestrate commands
Introduces a yargs-based CLI entry point (src/cli.ts) distributed as the
`game-ci` command. The CLI reuses existing unity-builder modules — Input,
BuildParameters, Orchestrator, Docker, MacBuilder — so the same build
engine powers both the GitHub Action and the standalone CLI.

Commands: build, activate, orchestrate, cache (list/restore/clear),
status, version.

Closes #812

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:50:25 +00:00
frostebite
81ba9c38af style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:16:01 +00:00
frostebite
79ae55802d style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:14:40 +00:00
frostebite
1e2bb889bf style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:13:02 +00:00
frostebite
e9c247f04f style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:10:24 +00:00
frostebite
3976b7cedd style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:08:56 +00:00
frostebite
9789eb5c3b ci: split orchestrator integrity into parallel jobs for faster validation
Rewrite the monolith orchestrator-integrity.yml (1110 lines, single job,
3+ hour sequential execution) into 4 parallel jobs that run on separate
runners:

- k8s-tests: k3d cluster + LocalStack, 5 tests
- aws-provider-tests: LocalStack only, 10 tests
- local-docker-tests: Docker + LocalStack for S3 tests, 9 tests
- rclone-tests: rclone + LocalStack, 1 test

Key improvements:
- Wall-clock time drops from ~3h to ~1h (longest single job)
- Disk exhaustion eliminated: each job gets its own fresh 14GB runner
- Cleanup logic deduplicated via sourced shell functions instead of
  15 copy-pasted 30-line blocks
- K3d node image cleanup only runs in the k8s job (where it matters)
- Light cleanup (cache + docker prune -f) between tests; heavy cleanup
  (prune -af --volumes) only at job boundaries
- workflow_call interface unchanged; integrity-check.yml needs no changes

Ref: #794

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:40:48 +00:00
frostebite
b3bd405399 fix: prettier formatting for orchestrator-folders-auth test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:16:05 +00:00
frostebite
4d7e8717e9 chore: rebuild dist for provider timeout and credential fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:05:02 +00:00
frostebite
120c3c5b24 fix(providers): add polling timeouts, fix credential parsing, validate dependencies
- GitHub Actions: max 4-hour polling with clear timeout error including run URL
- GitLab CI: max 4-hour polling with clear timeout error including pipeline URL
- Remote PowerShell: fix credential split to preserve passwords with colons
  (split on first colon only instead of all colons)
- Remote PowerShell: throw clear error when credential format is invalid
- Ansible: validate ansible-playbook binary exists in setupWorkflow
  (separate from ansible --version check)
- All timeout errors use core.error() for GitHub Actions annotation visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:04:15 +00:00
frostebite
7c0c4c2072 fix(hot-runner): validate persisted registry state and add dispatcher safeguards
Validate runner entries when loading from hot-runners.json. Discard
corrupted entries with warnings. Add validateAndRepair() method for
runtime recovery. Validate data before persisting to prevent writing
corrupt state. Handle corrupt persistence files (invalid JSON)
gracefully. Rewrite executeWithTimeout using Promise.race to clean up
transport connections on timeout. Fix pre-existing ESLint violations
in dispatcher and test files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:00:47 +00:00
frostebite
40dd436000 fix(reliability): add disk space validation before build archival
Check available disk space (cross-platform: wmic/df) before archive
operations to prevent data loss on full disks. Skip archival with
warning if insufficient space (10% safety margin). Clean up partial
archives on tar failure. Proceed with warning when space check fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:00:20 +00:00
frostebite
cff759721a fix(load-balancing): add pagination limits and rate-limit detection
Cap pagination at 100 pages (10,000 runners max), detect GitHub API
rate limiting (403/429) with reset time reporting, add 30-second total
timeout for pagination loop. Log clear diagnostic when no runners found
suggesting possible causes (token permissions, runner registration).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:00:17 +00:00
frostebite
7615bbd9dd fix(artifacts): validate rclone availability before storage upload
Check for rclone binary before attempting storage-based uploads.
Validate storage destination URI format (remoteName:path).
Provide clear error message with install link when rclone is missing.
Fail gracefully instead of cryptic ENOENT crash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:00:13 +00:00
frostebite
f06f99b3e5 chore: rebuild dist for cli-provider timeout changes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:55:23 +00:00
frostebite
1f3affe097 fix(secrets): prevent shell injection in secret key names and mask values
- Validate secret key names against alphanumeric allowlist before shell interpolation
- Apply validation in both SecretSourceService.fetchSecret() and legacy queryOverride()
- Mask fetched secret values with core.setSecret() to prevent log exposure
- Add 20 new tests for validation and masking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:55:18 +00:00
frostebite
fe63d7b32d fix(cli-provider): add timeout protection for external CLI processes
Prevent builds from hanging indefinitely when CLI provider subprocess
is unresponsive. Default 2h for runTaskInWorkflow, 1h for watchWorkflow.
Graceful SIGTERM with 10s grace before SIGKILL.

- Added RUN_TASK_TIMEOUT_MS (2 hours) and WATCH_WORKFLOW_TIMEOUT_MS (1 hour)
- Added gracefulKill helper: SIGTERM first, SIGKILL after 10s grace period
- runTaskInWorkflow and watchWorkflow now have timeout protection
- Existing execute() method upgraded to use gracefulKill
- core.error() called with clear human-readable timeout message
- Added comprehensive tests: timeout triggers, SIGKILL escalation,
  grace period cancellation on voluntary exit, normal completion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:55:14 +00:00
frostebite
5e54bcd4dd fix(testing): use async exec for parallel test group execution
Replace execSync with promisified exec so Promise.all actually runs
test groups in parallel. Add native timeout support via exec options.
Add 50MB maxBuffer for large Unity output. Fix ESLint violations
(variable naming, padding lines, array push consolidation).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:51:52 +00:00
frostebite
007852a800 feat(cache): add child workspace isolation for multi-product CI builds (#777)
Implement two-level workspace isolation pattern for enterprise-scale CI:
- Atomic O(1) workspace restore via filesystem move (no tar/download/extract)
- Separate Library caching for independent restore
- .git preservation for delta operations
- Stale workspace cleanup with configurable retention policies
- 5 new action inputs: childWorkspacesEnabled, childWorkspaceName,
  childWorkspaceCacheRoot, childWorkspacePreserveGit,
  childWorkspaceSeparateLibrary
- 28 unit tests covering all service methods

This enables enterprise CI where workspaces are 50GB+ and traditional
caching via actions/cache is impractical. On NTFS, workspace restore
is O(1) via atomic rename when source and destination are on the same volume.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:02:03 +00:00
frostebite
4870fb5a5c feat(sync): complete incremental sync protocol with storage-pull, state management, and tests (#799)
- Add storage-pull strategy: rclone-based sync from remote storage with
  overlay and clean modes, URI parsing (storage://remote:bucket/path),
  transfer parallelism, and automatic rclone availability checking
- Add SyncStateManager: persistent state load/save with configurable
  paths, workspace hash calculation via SHA-256 of key project files,
  and drift detection for external modification awareness
- Add action.yml inputs: syncStrategy, syncInputRef, syncStorageRemote,
  syncRevertAfter, syncStatePath with sensible defaults
- Wire sync into Input (5 getters), BuildParameters (5 fields), index.ts
  (local build path), and RemoteClient (orchestrator path) with post-job
  overlay revert when syncRevertAfter is true
- Add 42 unit tests covering all strategies, URI parsing, state
  management, hash calculation, drift detection, error handling, and
  edge cases (missing rclone, invalid URIs, absent state, empty diffs)
- Add root:true to eslintrc to prevent plugin resolution conflicts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:56:43 +00:00
frostebite
118671778f feat(testing): implement test workflow engine with YAML suites, taxonomy filtering, and structured results (#790)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:52:43 +00:00
frostebite
aa2e05d468 feat(artifacts): complete generic artifact system with upload handlers, tests, and action integration (#798)
- Add ArtifactUploadHandler with support for github-artifacts, storage (rclone),
  and local copy upload targets, including large file chunking for GitHub Artifacts
- Add 44 unit tests covering OutputTypeRegistry, OutputService, and
  ArtifactUploadHandler (config parsing, upload coordination, file collection)
- Add 6 new action.yml inputs for artifact configuration
- Add artifactManifestPath action output
- Wire artifact collection and upload into index.ts post-build flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:51:43 +00:00
frostebite
1bb31f3e98 feat(hot-runner): implement hot runner protocol with registry, health monitoring, and job dispatch (#791)
Adds persistent Unity editor instance support to reduce build iteration time
by eliminating cold-start overhead. Includes:

- HotRunnerTypes: interfaces for config, status, job request/result, transport
- HotRunnerRegistry: in-memory runner management with file-based persistence
- HotRunnerHealthMonitor: periodic health checks, idle recycling, job-count recycling
- HotRunnerDispatcher: job routing with wait-for-runner, timeout, and output streaming
- HotRunnerService: high-level API integrating registry, health, and dispatch
- 34 unit tests covering registration, filtering, health, dispatch, timeout, fallback
- action.yml inputs for hot runner configuration (7 new inputs)
- Input/BuildParameters integration for hot runner settings
- index.ts wiring with cold-build fallback when hot runner unavailable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:50:31 +00:00
frostebite
ff56194b30 test(providers): add comprehensive unit tests for GitHub Actions, GitLab CI, PowerShell, and Ansible providers (#806)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:47:14 +00:00
frostebite
47670cf3ce feat(reliability): implement build reliability service with git integrity, reserved filename cleanup, and build archival
Adds BuildReliabilityService with the following capabilities:
- checkGitIntegrity(): runs git fsck --no-dangling and parses output for corruption
- cleanStaleLockFiles(): removes stale .lock files older than 10 minutes
- validateSubmoduleBackingStores(): validates .git files point to valid backing stores
- recoverCorruptedRepo(): orchestrates fsck, lock cleanup, re-fetch, retry fsck
- cleanReservedFilenames(): removes Windows reserved filenames (con, prn, aux, nul, com1-9, lpt1-9)
- archiveBuildOutput(): creates tar.gz archive of build output
- enforceRetention(): deletes archives older than retention period
- configureGitEnvironment(): sets GIT_TERMINAL_PROMPT=0, http.postBuffer, core.longpaths

Wired into action.yml as opt-in inputs, with pre-build integrity checks and
post-build archival in the main entry point.

Includes 29 unit tests covering success and failure cases for all methods.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:44:58 +00:00
frostebite
4f07508484 feat(orchestrator): build reliability features — git integrity, reserved filename cleanup, archival
Add three optional reliability features for hardening CI pipelines:
- Git corruption detection & recovery (fsck, stale lock cleanup,
  submodule backing store validation, auto-recovery)
- Reserved filename cleanup (removes Windows device names that
  cause Unity asset importer infinite loops)
- Build output archival with configurable retention policy

All features are opt-in and fail gracefully with warnings only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:35:57 +00:00
frostebite
7db70a712f style: fix prettier formatting and eslint errors on test files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:23:05 +00:00
frostebite
12f287168d feat(orchestrator): CI platform providers — Remote PowerShell, GitHub Actions, GitLab CI, Ansible
Add four new providers that delegate builds to external CI platforms:
- remote-powershell: Execute on remote machines via WinRM/SSH
- github-actions: Dispatch workflow_dispatch on target repository
- gitlab-ci: Trigger pipeline via GitLab API
- ansible: Run playbooks against managed inventory

Each follows the CI-as-a-provider pattern: trigger remote job,
pass build parameters, stream logs, report status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:16:36 +00:00
frostebite
3033ee0067 feat(orchestrator): incremental sync protocol — git delta, direct input, and storage-backed sync
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:39:53 +00:00
frostebite
b3e1639029 feat(orchestrator): generic artifact system — output types, manifests, and collection service
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:38:55 +00:00
frostebite
49b37f7831 feat(orchestrator): add hot runner protocol placeholder
Initial scaffold for the runner registration and hot editor provider module.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:05:17 +00:00
frostebite
8d81236939 feat(orchestrator): add test workflow engine placeholder
Initial scaffold for the test workflow engine service directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:04:54 +00:00
frostebite
26903e96dd feat(hooks): add Unity Git Hooks integration and runHookGroups
Built-in support for Unity Git Hooks (com.frostebite.unitygithooks):
- Auto-detect UPM package in Packages/manifest.json
- Run init-unity-lefthook.js before hook installation
- Set CI-friendly env vars (disable background project mode)

New gitHooksRunBeforeBuild input runs specific lefthook groups before
the Unity build, allowing CI to trigger pre-commit or pre-push checks
that normally only fire on git events.

35 unit tests covering detection, init, CI env, group execution, and
failure handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:54:07 +00:00
frostebite
cf3478c8ec feat(lfs): add built-in elastic-git-storage support with auto-install
First-class support for elastic-git-storage as a custom LFS transfer
agent. When lfsTransferAgent is set to "elastic-git-storage" (or
"elastic-git-storage@v1.0.0" for a specific version), the service
automatically finds or installs the agent from GitHub releases, then
configures it via git config.

Supports version pinning via @version suffix in the agent value,
eliminating the need for a separate version parameter. Platform and
architecture detection handles linux/darwin/windows on amd64/arm64.

37 unit tests covering detection, PATH lookup, installation, version
parsing, and configuration delegation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:46:22 +00:00
frostebite
7f895304f4 feat(secrets): add HashiCorp Vault as first-class premade secret source
Adds three Vault entries: hashicorp-vault (KV v2), hashicorp-vault-kv1
(KV v1), and vault (short alias). Uses VAULT_ADDR for server address and
VAULT_MOUNT env var for configurable mount path (defaults to 'secret').

Refs #776

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:41:04 +00:00
frostebite
e4c156e7b0 feat(orchestrator): add premade secret sources and YAML definitions
Add SecretSourceService with premade secret source integrations:
- aws-secrets-manager (with --query SecretString for direct value)
- aws-parameter-store (with --with-decryption)
- gcp-secret-manager (latest version)
- azure-key-vault (via $AZURE_VAULT_NAME env var)
- env (environment variables, no shell command needed)
- Custom commands (any string with {0} placeholder)
- YAML file definitions for custom sources

Add secretSource input that takes precedence over inputPullCommand.
Backward compatible — existing inputPullCommand behavior unchanged.

Closes #776

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:35:03 +00:00
frostebite
8a41533779 fix(orchestrator): use http.extraHeader for secure git authentication
Replace token-in-URL pattern with http.extraHeader for git clone and LFS
operations. The token no longer appears in clone URLs, git remote config,
or process command lines.

Add gitAuthMode input (default: 'header', legacy: 'url') so users can
fall back to the old behavior if needed.

Closes #785

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:30:51 +00:00
frostebite
a0c79bd657 test(orchestrator): expand unit tests for enterprise services
Add comprehensive tests for CLI provider (cleanupWorkflow, garbageCollect,
listWorkflow, watchWorkflow, stderr forwarding, timeout handling), local
cache service (saveLfsCache full path and error handling), git hooks service
(husky install, failure logging, edge cases), and LFS agent service (empty
storagePaths, validate logging). 73 tests across 4 test files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:20:31 +00:00
frostebite
f4451060a7 ci(orchestrator): add fast unit test gate to integrity workflow
Adds a fast-fail unit test step at the top of orchestrator-integrity,
right after yarn install and before any infrastructure setup (k3d,
LocalStack). Runs 113 mock-based orchestrator tests in ~5 seconds.

If serialization, path computation, log parsing, or provider loading
is broken, the workflow fails immediately instead of spending 30+
minutes setting up LocalStack and k3d clusters.

Tests included: orchestrator-guid, orchestrator-folders,
task-parameter-serializer, follow-log-stream-service,
runner-availability-service, provider-url-parser, provider-loader,
provider-git-manager, orchestrator-image, orchestrator-hooks,
orchestrator-github-checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:13:49 +00:00
frostebite
17a0ea3776 test(orchestrator): add unit tests for untested core services
Adds 64 new mock-based unit tests covering orchestrator services that
previously had zero test coverage:

- TaskParameterSerializer: env var format conversion, round-trip,
  uniqBy deduplication, blocked params, default secrets
- FollowLogStreamService: build output message parsing — end of
  transmission, build success/failure detection, error accumulation,
  Library rebuild detection
- OrchestratorNamespace (guid): GUID generation format, platform
  name normalization, nanoid uniqueness
- OrchestratorFolders: path computation for all folder getters,
  ToLinuxFolder conversion, repo URL generation, purge flag detection

All tests are pure mock-based and run without any external
infrastructure (no LocalStack, K8s, Docker, or AWS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:08:49 +00:00
frostebite
7e9d0bf53e test(orchestrator): add runner availability service tests
Covers: no token skip, no runners fallback, busy/offline runners,
label filtering (case-insensitive), minAvailable threshold,
fail-open on API error, mixed runner states.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:58:58 +00:00
frostebite
cfac5f138d test(orchestrator): expand local cache service test coverage
Adds tests for cache hit restore (picks latest tar), LFS cache
restore/save, garbage collection age filtering, and edge cases
like permission errors and empty directories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:58:09 +00:00
frostebite
d17b099593 style: format changed files with prettier
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:52:11 +00:00
frostebite
8194790728 feat(orchestrator): add retry-on-fallback and provider init timeout
Adds retryOnFallback (retry failed builds on alternate provider) and
providerInitTimeout (swap provider if init takes too long). Refactors
run() into run()/runWithProvider() to support retry loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:45:54 +00:00
frostebite
786ee3799c feat(orchestrator): automatic provider fallback with runner availability check
Adds built-in load balancing: check GitHub runner availability before
builds start, auto-route to a fallback provider when runners are busy
or offline. Eliminates the need for a separate check-runner job.

New inputs: fallbackProviderStrategy, runnerCheckEnabled,
runnerCheckLabels, runnerCheckMinAvailable.

Outputs providerFallbackUsed and providerFallbackReason for workflow
visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:39:23 +00:00
frostebite
f4bc5d20c4 feat(orchestrator): multi-storage support for GCP and Azure providers
Both providers now support four storage backends via gcpStorageType / azureStorageType:

GCP Cloud Run:
  - gcs-fuse: Mount GCS bucket as POSIX filesystem (unlimited, 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, volatile, up to 32 GiB)

Azure ACI:
  - 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, volatile, limited by container memory)

New inputs: gcpStorageType, gcpFilestoreIp, gcpFilestoreShare, azureStorageType,
azureBlobContainer. Constructor validates storage config and warns on missing
prerequisites (e.g. NFS requires VPC connector/subnet).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:07:20 +00:00
frostebite
d8563369e1 feat(orchestrator): add experimental GCP Cloud Run and Azure ACI providers
Add two new cloud provider implementations for the orchestrator, both marked
as experimental:

- **GCP Cloud Run Jobs** (`providerStrategy: gcp-cloud-run`): Executes Unity
  builds as Cloud Run Jobs with GCS FUSE for large artifact storage. Supports
  configurable machine types, service accounts, and VPC connectors. 7 new inputs
  (gcpProject, gcpRegion, gcpBucket, gcpMachineType, gcpDiskSizeGb,
  gcpServiceAccount, gcpVpcConnector).

- **Azure Container Instances** (`providerStrategy: azure-aci`): Executes Unity
  builds as ACI containers with Azure File Shares (Premium FileStorage) for
  large artifact storage up to 100 TiB. Supports configurable CPU/memory,
  VNet integration, and subscription targeting. 9 new inputs
  (azureResourceGroup, azureLocation, azureStorageAccount, azureFileShareName,
  azureSubscriptionId, azureCpu, azureMemoryGb, azureDiskSizeGb, azureSubnetId).

Both providers use their respective CLIs (gcloud, az) for infrastructure
management and support garbage collection of old build resources. No tests
included as these require real cloud infrastructure to validate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:00:09 +00:00
frostebite
5268630ef0 feat(orchestrator): enterprise feature support — CLI provider, submodule profiles, caching, LFS, hooks
Add generic enterprise-grade features to the orchestrator, enabling Unity projects with
complex CI/CD pipelines to adopt game-ci/unity-builder with built-in support for:

- CLI provider protocol: JSON-over-stdin/stdout bridge enabling providers in any language
  (Go, Python, Rust, shell) via the `providerExecutable` input
- Submodule profiles: YAML-based selective submodule initialization with glob patterns
  and variant overlays (`submoduleProfilePath`, `submoduleVariantPath`)
- Local build caching: Filesystem-based Library and LFS caching for local builds without
  external cache actions (`localCacheEnabled`, `localCacheRoot`)
- Custom LFS transfer agents: Register external transfer agents like elastic-git-storage
  (`lfsTransferAgent`, `lfsTransferAgentArgs`, `lfsStoragePaths`)
- Git hooks support: Detect and install lefthook/husky with configurable skip lists
  (`gitHooksEnabled`, `gitHooksSkipList`)

Also removes all `orchestrator-develop` branch references, replacing with `main`.

13 new action inputs, 13 new files, 14 new CLI provider tests, 17 submodule tests,
plus cache/LFS/hooks unit tests. All 452 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:54:46 +00:00
116 changed files with 7644 additions and 295678 deletions

View File

@@ -2,3 +2,4 @@ dist/
lib/
node_modules/
jest.config.js
src/types/

View File

@@ -1,4 +1,5 @@
{
"root": true,
"plugins": ["jest", "@typescript-eslint", "prettier", "unicorn"],
"extends": ["plugin:unicorn/recommended", "plugin:github/recommended", "plugin:prettier/recommended"],
"parser": "@typescript-eslint/parser",

View File

@@ -32,7 +32,8 @@ jobs:
- run: yarn build || { echo "build command should always succeed" ; exit 61; }
# - run: yarn build --quiet && git diff --quiet dist || { echo "dist should be auto generated" ; git diff dist ; exit 62; }
orchestrator:
name: Orchestrator Integrity
uses: ./.github/workflows/orchestrator-integrity.yml
orchestrator-integration:
name: Orchestrator Integration
if: github.event_name == 'push'
uses: ./.github/workflows/validate-orchestrator-integration.yml
secrets: inherit

View File

@@ -1,61 +0,0 @@
name: Async Checks API
on:
workflow_dispatch:
inputs:
checksObject:
description: ''
required: false
default: ''
permissions:
checks: write
env:
GKE_ZONE: 'us-central1'
GKE_REGION: 'us-central1'
GKE_PROJECT: 'unitykubernetesbuilder'
GKE_CLUSTER: 'game-ci-github-pipelines'
GCP_LOGGING: true
GCP_PROJECT: unitykubernetesbuilder
GCP_LOG_FILE: ${{ github.workspace }}/orchestrator-logs.txt
# Commented out: Using LocalStack tests instead of real AWS
# AWS_REGION: eu-west-2
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_DEFAULT_REGION: eu-west-2
# AWS_STACK_NAME: game-ci-github-pipelines
ORCHESTRATOR_BRANCH: ${{ github.ref }}
ORCHESTRATOR_DEBUG: true
ORCHESTRATOR_DEBUG_TREE: true
DEBUG: true
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
PROJECT_PATH: test-project
UNITY_VERSION: 2019.3.15f1
USE_IL2CPP: false
jobs:
asyncChecks:
name: Async Checks
if: github.event.event_type != 'pull_request_target'
runs-on: ubuntu-latest
steps:
- timeout-minutes: 180
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
PROJECT_PATH: test-project
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_PRIVATE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_PLATFORM: StandaloneWindows64
orchestratorTests: true
versioning: None
ORCHESTRATOR_CLUSTER: local-docker
# Commented out: Using LocalStack tests instead of real AWS
# AWS_STACK_NAME: game-ci-github-pipelines
CHECKS_UPDATE: ${{ github.event.inputs.checksObject }}
run: |
git clone -b orchestrator-develop https://github.com/game-ci/unity-builder
cd unity-builder
yarn
ls
yarn run cli -m checks-update

File diff suppressed because it is too large Load Diff

41
.github/workflows/temp-push-secrets.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: "TEMP: Push secrets to orchestrator repo"
# One-shot workflow — run manually, then delete this file and branch.
on:
workflow_dispatch:
jobs:
push-secrets:
name: Push secrets to game-ci/orchestrator
runs-on: ubuntu-latest
steps:
- name: Push UNITY_EMAIL
run: gh secret set UNITY_EMAIL --repo game-ci/orchestrator --body "$SECRET_VALUE"
env:
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
SECRET_VALUE: ${{ secrets.UNITY_EMAIL }}
- name: Push UNITY_PASSWORD
run: gh secret set UNITY_PASSWORD --repo game-ci/orchestrator --body "$SECRET_VALUE"
env:
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
SECRET_VALUE: ${{ secrets.UNITY_PASSWORD }}
- name: Push UNITY_SERIAL
run: gh secret set UNITY_SERIAL --repo game-ci/orchestrator --body "$SECRET_VALUE"
env:
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
SECRET_VALUE: ${{ secrets.UNITY_SERIAL }}
- name: Push GIT_PRIVATE_TOKEN
run: gh secret set GIT_PRIVATE_TOKEN --repo game-ci/orchestrator --body "$SECRET_VALUE"
env:
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
SECRET_VALUE: ${{ secrets.GIT_PRIVATE_TOKEN }}
- name: Confirm
run: |
echo "Secrets pushed to game-ci/orchestrator:"
gh secret list --repo game-ci/orchestrator
env:
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,288 @@
name: Validate Orchestrator Compatibility
# ==============================================================================
# Essential plugin health checks — runs on every PR and push.
# Fast (~5 min): compilation, unit tests, plugin interface, type declarations.
#
# For exhaustive integration tests (k8s, AWS, local-docker, rclone) see
# validate-orchestrator-integration.yml which runs on a daily cron.
# ==============================================================================
on:
workflow_dispatch:
push:
branches: [main, 'release/**', 'feature/**', 'refactor/**']
paths:
- 'src/model/orchestrator-plugin.ts'
- 'src/model/build-parameters.ts'
- 'src/model/input.ts'
- 'src/model/github.ts'
- 'src/model/cli/cli.ts'
- 'src/model/input-readers/**'
- 'src/index.ts'
- 'src/types/game-ci-orchestrator.d.ts'
- 'action.yml'
- 'package.json'
- 'yarn.lock'
- '.github/workflows/validate-orchestrator.yml'
pull_request:
branches: [main, 'release/**']
paths:
- 'src/model/orchestrator-plugin.ts'
- 'src/model/build-parameters.ts'
- 'src/model/input.ts'
- 'src/model/github.ts'
- 'src/model/cli/cli.ts'
- 'src/model/input-readers/**'
- 'src/index.ts'
- 'src/types/game-ci-orchestrator.d.ts'
- 'action.yml'
- 'package.json'
- 'yarn.lock'
- '.github/workflows/validate-orchestrator.yml'
permissions:
contents: read
packages: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# ============================================================================
# PLUGIN ARCHITECTURE HEALTH CHECK
# ============================================================================
# Validates that:
# 1. unity-builder compiles and its unit tests pass
# 2. Plugin loader degrades gracefully without orchestrator
# 3. Orchestrator compiles and its unit tests pass
# 4. Plugin loader loads all services when orchestrator is installed
# 5. Type declarations match actual exports
# ============================================================================
plugin-health:
name: Plugin Architecture Health
runs-on: ubuntu-latest
steps:
- name: Checkout unity-builder
uses: actions/checkout@v4
- name: Checkout orchestrator
uses: actions/checkout@v4
with:
repository: game-ci/orchestrator
path: orchestrator-standalone
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
# --- unity-builder compilation and tests ---
- name: Install unity-builder dependencies
run: yarn install --frozen-lockfile
- name: Build unity-builder
run: |
echo "Building unity-builder TypeScript..."
npx tsc
echo "✓ unity-builder compiles successfully"
- name: Run orchestrator-plugin unit tests
run: |
echo "Running orchestrator-plugin unit tests..."
npx jest orchestrator-plugin --verbose --detectOpenHandles --forceExit
# --- Plugin loader without orchestrator ---
- name: Verify plugin loader returns undefined without orchestrator
run: |
echo "Checking plugin loader handles missing @game-ci/orchestrator..."
node -e "
const { loadOrchestrator, loadPluginServices } = require('./lib/model/orchestrator-plugin');
(async () => {
const orch = await loadOrchestrator();
if (orch !== undefined) {
console.error('ERROR: loadOrchestrator should return undefined when package not installed');
process.exit(1);
}
console.log('✓ loadOrchestrator() returns undefined when package not installed');
const services = await loadPluginServices();
if (services !== undefined) {
console.error('ERROR: loadPluginServices should return undefined when package not installed');
process.exit(1);
}
console.log('✓ loadPluginServices() returns undefined when package not installed');
})();
"
- name: Verify orchestrator type declarations exist
run: |
if [ -f "src/types/game-ci-orchestrator.d.ts" ]; then
echo "✓ Type declarations for @game-ci/orchestrator exist"
else
echo "::error::Missing type declarations: src/types/game-ci-orchestrator.d.ts"
exit 1
fi
# --- Orchestrator compilation and tests ---
- name: Build and pack orchestrator
working-directory: orchestrator-standalone
run: |
yarn install --frozen-lockfile
echo "Building orchestrator..."
npx tsc
echo "✓ orchestrator compiles successfully"
echo "Packing orchestrator as tarball..."
npm pack
- name: Run orchestrator unit tests
working-directory: orchestrator-standalone
run: |
echo "Running orchestrator unit tests..."
npx jest --no-cache 2>&1 | tail -20
# --- Plugin loader with orchestrator installed ---
- name: Install orchestrator into unity-builder
run: |
echo "Installing orchestrator into unity-builder workspace..."
npm install ./orchestrator-standalone/game-ci-orchestrator-*.tgz --no-save --legacy-peer-deps
- name: Verify plugin loader returns exports with orchestrator installed
run: |
echo "Checking plugin loader returns defined exports..."
node -e "
const { loadOrchestrator, loadPluginServices } = require('./lib/model/orchestrator-plugin');
(async () => {
const orch = await loadOrchestrator();
if (orch === undefined) {
console.error('ERROR: loadOrchestrator should return defined exports when package is installed');
process.exit(1);
}
if (typeof orch.run !== 'function') {
console.error('ERROR: loadOrchestrator().run should be a function');
process.exit(1);
}
console.log('✓ loadOrchestrator() returns defined exports with orchestrator installed');
const services = await loadPluginServices();
if (services === undefined) {
console.error('ERROR: loadPluginServices should return defined exports when package is installed');
process.exit(1);
}
const expectedServices = [
'BuildReliabilityService', 'TestWorkflowService', 'HotRunnerService',
'OutputService', 'OutputTypeRegistry', 'ArtifactUploadHandler',
'IncrementalSyncService',
];
for (const svc of expectedServices) {
if (services[svc] === undefined) {
console.error('ERROR: ' + svc + ' should be defined');
process.exit(1);
}
}
console.log('✓ loadPluginServices() returns all ' + expectedServices.length + ' services');
const lazyLoaders = [
'loadChildWorkspaceService', 'loadLocalCacheService',
'loadSubmoduleProfileService', 'loadLfsAgentService', 'loadGitHooksService',
];
for (const loader of lazyLoaders) {
if (typeof services[loader] !== 'function') {
console.error('ERROR: ' + loader + ' should be a function');
process.exit(1);
}
const loaded = await services[loader]();
if (loaded === undefined) {
console.error('ERROR: ' + loader + '() should return defined service');
process.exit(1);
}
}
console.log('✓ All ' + lazyLoaders.length + ' lazy loaders return defined services');
})();
"
- name: Verify type declarations match orchestrator exports
run: |
echo "Checking type declarations align with orchestrator exports..."
node -e "
const orch = require('@game-ci/orchestrator');
const expectedExports = [
'Orchestrator', 'BuildReliabilityService', 'TestWorkflowService',
'HotRunnerService', 'OutputService', 'OutputTypeRegistry',
'ArtifactUploadHandler', 'IncrementalSyncService',
'ChildWorkspaceService', 'LocalCacheService', 'SubmoduleProfileService',
'LfsAgentService', 'GitHooksService',
];
const missing = expectedExports.filter(e => orch[e] === undefined);
if (missing.length > 0) {
console.error('ERROR: Missing exports from @game-ci/orchestrator:', missing.join(', '));
process.exit(1);
}
console.log('✓ All ' + expectedExports.length + ' declared exports present in orchestrator package');
"
- name: Smoke test orchestrator build wiring
run: |
echo "Verifying orchestrator build wiring end-to-end..."
node -e "
const { loadOrchestrator, loadPluginServices } = require('./lib/model/orchestrator-plugin');
const { Orchestrator, BuildParameters } = require('@game-ci/orchestrator');
(async () => {
// Verify Orchestrator has the expected static API
const requiredMethods = ['setup', 'run'];
for (const m of requiredMethods) {
if (typeof Orchestrator[m] !== 'function') {
console.error('ERROR: Orchestrator.' + m + ' should be a function, got ' + typeof Orchestrator[m]);
process.exit(1);
}
}
console.log('✓ Orchestrator has setup() and run() methods');
// Verify BuildParameters.create() produces a valid config object
const params = await BuildParameters.create();
const requiredFields = [
'targetPlatform', 'projectPath', 'providerStrategy',
'editorVersion', 'buildPath', 'buildName',
];
for (const f of requiredFields) {
if (params[f] === undefined) {
console.error('ERROR: BuildParameters.' + f + ' is undefined');
process.exit(1);
}
}
console.log('✓ BuildParameters.create() produces valid config with ' + requiredFields.length + ' required fields');
// Verify loadOrchestrator().run is wired to Orchestrator.run
const orch = await loadOrchestrator();
if (typeof orch.run !== 'function') {
console.error('ERROR: loadOrchestrator().run is not a function');
process.exit(1);
}
// Call run with a bad baseImage to confirm it reaches Orchestrator.run
// (Orchestrator.run throws 'baseImage is undefined' for images containing 'undefined')
try {
await orch.run(params, 'undefined-image');
console.error('ERROR: Expected Orchestrator.run to throw for undefined baseImage');
process.exit(1);
} catch (e) {
if (e.message.includes('baseImage is undefined')) {
console.log('✓ loadOrchestrator().run() correctly wired to Orchestrator.run()');
} else {
console.error('ERROR: Unexpected error from Orchestrator.run:', e.message);
process.exit(1);
}
}
// Verify plugin services wire through to real service instances
const services = await loadPluginServices();
if (typeof services.BuildReliabilityService.configureGitEnvironment !== 'function') {
console.error('ERROR: BuildReliabilityService.configureGitEnvironment should be a function');
process.exit(1);
}
console.log('✓ Plugin services wire through to real orchestrator implementations');
})();
"

View File

@@ -105,6 +105,12 @@ inputs:
required: false
default: ''
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:
required: false
default: ''
@@ -182,8 +188,8 @@ inputs:
required: false
default: ''
description:
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with the
keys image, secrets (name, value object array), command line string)'
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with
the keys image, secrets (name, value object array), command line string)'
awsStackName:
default: 'game-ci'
required: false
@@ -194,6 +200,51 @@ inputs:
description:
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
be configured.'
fallbackProviderStrategy:
default: ''
required: false
description:
'[Orchestrator] Fallback provider when the primary is unavailable. Used with runnerCheckEnabled for automatic
failover, or as a catch-all if the primary provider fails to initialize.'
runnerCheckEnabled:
default: 'false'
required: false
description:
'[Orchestrator] Check GitHub Actions runner availability before starting a build. When no suitable runners are
available and fallbackProviderStrategy is set, automatically routes to the fallback provider.'
runnerCheckLabels:
default: ''
required: false
description:
'[Orchestrator] Comma-separated runner labels to filter when checking availability (e.g. self-hosted,linux).
When empty, checks all runners in the repository.'
runnerCheckMinAvailable:
default: '1'
required: false
description:
'[Orchestrator] Minimum number of idle runners required for the primary provider. If fewer are available,
routes to fallbackProviderStrategy.'
retryOnFallback:
default: 'false'
required: false
description:
'[Orchestrator] When true and fallbackProviderStrategy is set, automatically retry the build on the fallback
provider if the primary provider fails. Useful for long builds where transient cloud failures are common.'
providerInitTimeout:
default: '0'
required: false
description:
'[Orchestrator] Maximum seconds to wait for the primary provider to initialize (setupWorkflow). If exceeded
and fallbackProviderStrategy is set, switches to the fallback. Set to 0 to disable (default).'
secretSource:
default: ''
required: false
description:
'[Orchestrator] Premade secret source for pulling build secrets. Supported values: aws-secrets-manager,
aws-parameter-store, gcp-secret-manager, azure-key-vault, hashicorp-vault, hashicorp-vault-kv1,
vault (alias for hashicorp-vault), env. Can also be a custom shell command with {0} placeholder
for the key, or a path to a YAML file defining custom sources. Takes precedence over
inputPullCommand when set.'
resourceTracking:
default: 'false'
required: false
@@ -269,6 +320,28 @@ inputs:
default: 'false'
required: false
description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.'
artifactOutputTypes:
description: 'Comma-separated list of output types to collect (build, logs, test-results, coverage, images, metrics, data-export, server-build, custom)'
required: false
default: 'build,logs,test-results'
artifactUploadTarget:
description: 'Where to upload artifacts: github-artifacts, storage, local, none'
required: false
default: 'github-artifacts'
artifactUploadPath:
description: 'Destination path for artifact upload (storage URI or local path)'
required: false
artifactCompression:
description: 'Compression for artifacts: none, gzip, lz4'
required: false
default: 'gzip'
artifactRetentionDays:
description: 'Retention period for uploaded artifacts in days'
required: false
default: '30'
artifactCustomTypes:
description: 'JSON string defining custom output types [{name, defaultPath, description}]'
required: false
cloneDepth:
default: '50'
required: false
@@ -279,6 +352,376 @@ inputs:
description:
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.'
submoduleProfilePath:
required: false
default: ''
description:
'Path to a YAML submodule profile file (relative to repo root). Defines which submodules to initialize (branch:
main) or skip (branch: empty). See docs for format.'
submoduleVariantPath:
required: false
default: ''
description:
'Path to a YAML variant overlay file that modifies the base submodule profile. Used for server or debug build
variants.'
submoduleToken:
required: false
default: ''
description:
'Git token for authenticating submodule clones. Falls back to gitPrivateToken or GITHUB_TOKEN if empty.'
localCacheEnabled:
required: false
default: 'false'
description:
'Enable filesystem-based caching for local builds. Caches the Unity Library folder and optionally LFS objects
between builds without requiring actions/cache.'
localCacheRoot:
required: false
default: ''
description:
'Root directory for local build cache. Defaults to $RUNNER_TEMP/game-ci-cache or .game-ci/cache if RUNNER_TEMP is
not set.'
localCacheLibrary:
required: false
default: 'true'
description: 'Cache the Unity Library folder for local builds. Only effective when localCacheEnabled is true.'
localCacheLfs:
required: false
default: 'false'
description: 'Cache Git LFS objects for local builds. Only effective when localCacheEnabled is true.'
childWorkspacesEnabled:
required: false
default: 'false'
description:
'Enable child workspace isolation for multi-product builds. Uses atomic filesystem moves for O(1) workspace
restore instead of tar/download/extract. Ideal for 50GB+ workspaces on self-hosted runners.'
childWorkspaceName:
required: false
default: ''
description:
'Name for this child workspace (e.g., product name like "TurnOfWar"). Used as the cache key for workspace
isolation. Required when childWorkspacesEnabled is true.'
childWorkspaceCacheRoot:
required: false
default: ''
description:
'Parent directory for cached child workspaces. Should be on the same NTFS volume as the build directory for O(1)
atomic restore via filesystem rename. Defaults to $RUNNER_TEMP/game-ci-workspaces.'
childWorkspacePreserveGit:
required: false
default: 'true'
description:
'Preserve .git directory in cached child workspace. Enables delta operations on restore but increases cache size.
Set to false to save disk space at the cost of full re-clone on restore.'
childWorkspaceSeparateLibrary:
required: false
default: 'true'
description:
'Cache Unity Library folder separately from the child workspace. Allows independent Library restore even when
workspace cache is invalidated. Recommended for large projects.'
lfsTransferAgent:
required: false
default: ''
description:
'Custom Git LFS transfer agent. Set to "elastic-git-storage" for built-in support (auto-installs from GitHub
releases). Append @version for a specific release (e.g. "elastic-git-storage@v1.0.0"). Or provide a path to any
custom transfer agent executable. When set, the agent is registered via git config before LFS operations.'
lfsTransferAgentArgs:
required: false
default: ''
description: 'Additional arguments to pass to the custom LFS transfer agent.'
lfsStoragePaths:
required: false
default: ''
description:
'Semicolon-separated list of storage paths for the custom LFS transfer agent. Interpretation depends on the agent
(e.g. local paths, WebDAV URLs, rclone remotes).'
gitHooksEnabled:
required: false
default: 'false'
description:
'Install and run git hooks (lefthook, husky, or native) during builds. When false (default), hooks are disabled
for build performance.'
gitHooksSkipList:
required: false
default: ''
description:
'Comma-separated list of hook names to skip even when gitHooksEnabled is true. Example: pre-push,post-merge'
gitHooksRunBeforeBuild:
required: false
default: ''
description:
'Comma-separated list of lefthook hook groups to run before the Unity build. Allows CI to trigger checks that
normally only run on git events. Example: pre-commit,pre-push. Requires lefthook. Works with Unity Git Hooks
(com.frostebite.unitygithooks) when installed as a UPM package — the init script runs automatically.'
providerExecutable:
required: false
default: ''
description:
'Path to an external CLI executable that implements the provider protocol. Enables providers written in any
language (Go, Python, Rust, shell). Uses JSON-over-stdin/stdout communication.'
gcpProject:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Google Cloud project ID for Cloud Run Jobs provider. Falls back to
GOOGLE_CLOUD_PROJECT env var.'
gcpRegion:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Google Cloud region for Cloud Run Jobs (e.g. us-central1). Defaults to the region
input if empty.'
gcpStorageType:
required: false
default: 'gcs-fuse'
description:
'[Orchestrator] [Experimental] Storage type for Cloud Run Jobs. Options: gcs-fuse (mount GCS bucket as filesystem,
unlimited size, best for large sequential I/O), gcs-copy (copy artifacts in/out via gsutil, simpler, no FUSE
overhead), nfs (Filestore NFS mount, true POSIX, good random I/O, up to 100 TiB), in-memory (tmpfs, fastest but
volatile, up to 32 GiB).'
gcpBucket:
required: false
default: ''
description:
'[Orchestrator] [Experimental] GCS bucket name for build artifact storage. Used by gcs-fuse and gcs-copy storage
types.'
gcpFilestoreIp:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Filestore instance IP address for NFS storage type. Required when gcpStorageType is
nfs.'
gcpFilestoreShare:
required: false
default: '/share1'
description:
'[Orchestrator] [Experimental] Filestore share name for NFS storage type. Defaults to /share1 (the Filestore
default).'
gcpMachineType:
required: false
default: 'e2-standard-4'
description: '[Orchestrator] [Experimental] Machine type for Cloud Run Jobs (e.g. e2-standard-4, e2-highmem-8).'
gcpDiskSizeGb:
required: false
default: '100'
description:
'[Orchestrator] [Experimental] Disk size in GB for Cloud Run Jobs in-memory volumes. Only applies to in-memory
storage type (max 32).'
gcpServiceAccount:
required: false
default: ''
description: '[Orchestrator] [Experimental] Google Cloud service account email for Cloud Run Jobs execution.'
gcpVpcConnector:
required: false
default: ''
description: '[Orchestrator] [Experimental] VPC connector name for Cloud Run Jobs private networking.'
azureResourceGroup:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure resource group for Container Instances provider. Falls back to
AZURE_RESOURCE_GROUP env var.'
azureLocation:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure region for Container Instances (e.g. eastus, westeurope). Defaults to the
region input if empty.'
azureStorageType:
required: false
default: 'azure-files'
description:
'[Orchestrator] [Experimental] Storage type for Azure Container Instances. Options: azure-files (SMB file share
mount, up to 100 TiB, premium throughput), blob-copy (copy artifacts in/out via az storage blob, no mount
overhead), azure-files-nfs (NFS 4.1 file share mount, true POSIX, no SMB lock overhead), in-memory (emptyDir
tmpfs, fastest but volatile, size limited by container memory).'
azureStorageAccount:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure Storage Account name. Used by azure-files, azure-files-nfs, and blob-copy
storage types.'
azureFileShareName:
required: false
default: 'unity-builds'
description:
'[Orchestrator] [Experimental] Azure File Share name within the storage account. Used by azure-files and
azure-files-nfs storage types. Supports up to 100 TiB per share.'
azureBlobContainer:
required: false
default: 'unity-builds'
description: '[Orchestrator] [Experimental] Azure Blob container name for blob-copy storage type.'
azureSubscriptionId:
required: false
default: ''
description: '[Orchestrator] [Experimental] Azure subscription ID. Falls back to AZURE_SUBSCRIPTION_ID env var.'
azureCpu:
required: false
default: '4'
description: '[Orchestrator] [Experimental] CPU cores for Azure Container Instances (1-16).'
azureMemoryGb:
required: false
default: '16'
description: '[Orchestrator] [Experimental] Memory in GB for Azure Container Instances (1-16).'
azureDiskSizeGb:
required: false
default: '100'
description:
'[Orchestrator] [Experimental] File share quota in GB for Azure Container Instances. Premium shares support up to
102400 GB (100 TiB).'
azureSubnetId:
required: false
default: ''
description: '[Orchestrator] [Experimental] Azure subnet resource ID for VNet-integrated Container Instances.'
remotePowershellHost:
default: ''
required: false
description: '[Orchestrator] Remote PowerShell host (hostname or IP) for the remote-powershell provider'
remotePowershellCredential:
default: ''
required: false
description: '[Orchestrator] Remote PowerShell credential (username:password or certificate path)'
remotePowershellTransport:
default: 'wsman'
required: false
description: '[Orchestrator] Remote PowerShell transport protocol (wsman or ssh)'
githubActionsRepo:
default: ''
required: false
description: '[Orchestrator] Target repository (owner/repo) for the github-actions provider'
githubActionsWorkflow:
default: ''
required: false
description: '[Orchestrator] Workflow filename or ID to dispatch for the github-actions provider'
githubActionsToken:
default: ''
required: false
description: '[Orchestrator] PAT with actions:write scope for the github-actions provider'
githubActionsRef:
default: 'main'
required: false
description: '[Orchestrator] Branch/ref to run the workflow on for the github-actions provider'
gitlabProjectId:
default: ''
required: false
description: '[Orchestrator] GitLab project ID or URL-encoded path for the gitlab-ci provider'
gitlabTriggerToken:
default: ''
required: false
description: '[Orchestrator] Pipeline trigger token for the gitlab-ci provider'
gitlabApiUrl:
default: 'https://gitlab.com'
required: false
description: '[Orchestrator] GitLab API URL (for self-hosted instances) for the gitlab-ci provider'
gitlabRef:
default: 'main'
required: false
description: '[Orchestrator] Branch/ref to trigger the pipeline on for the gitlab-ci provider'
ansibleInventory:
default: ''
required: false
description: '[Orchestrator] Path to Ansible inventory file or dynamic inventory script'
ansiblePlaybook:
default: ''
required: false
description: '[Orchestrator] Path to Ansible playbook for Unity builds'
ansibleExtraVars:
default: ''
required: false
description: '[Orchestrator] Additional Ansible variables as JSON'
ansibleVaultPassword:
default: ''
required: false
description: '[Orchestrator] Path to Ansible vault password file'
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'
testSuitePath:
description: 'Path to YAML test suite definition file'
required: false
testSuiteEvent:
description: 'CI event name for suite selection (pr, push, release)'
required: false
testTaxonomyPath:
description: 'Path to custom taxonomy definition YAML'
required: false
testResultFormat:
description: 'Test result output format: junit, json, or both'
required: false
default: 'junit'
testResultPath:
description: 'Directory for structured test result output'
required: false
default: './test-results'
hotRunnerEnabled:
description: '[HotRunner] Use persistent hot runner for builds (requires pre-registered runners)'
required: false
default: 'false'
hotRunnerTransport:
description: '[HotRunner] Transport protocol for hot runner communication: websocket, grpc, named-pipe'
required: false
default: 'websocket'
hotRunnerHost:
description: '[HotRunner] Hot runner host address'
required: false
default: 'localhost'
hotRunnerPort:
description: '[HotRunner] Hot runner port number'
required: false
default: '9090'
hotRunnerHealthInterval:
description: '[HotRunner] Health check interval in seconds'
required: false
default: '30'
hotRunnerMaxIdle:
description: '[HotRunner] Maximum idle time in seconds before recycling runner'
required: false
default: '3600'
hotRunnerFallbackToCold:
description: '[HotRunner] Fall back to cold build if no hot runner available'
required: false
default: 'true'
syncStrategy:
description: 'Workspace sync strategy: full, git-delta, direct-input, storage-pull'
required: false
default: 'full'
syncInputRef:
description: 'URI for direct-input or storage-pull content (storage://remote/path or file path)'
required: false
syncStorageRemote:
description: 'rclone remote name for storage-backed inputs (defaults to rcloneRemote)'
required: false
syncRevertAfter:
description: 'Revert overlaid changes after job completion'
required: false
default: 'true'
syncStatePath:
description: 'Path to sync state file for delta tracking'
required: false
default: '.game-ci/sync-state.json'
outputs:
volume:
@@ -292,6 +735,8 @@ outputs:
'Returns the exit code from the build scripts. This code is 0 if the build was successful. If there was an error
during activation, the code is from the activation step. If activation is successful, the code is from the project
build step.'
artifactManifestPath:
description: 'Path to the generated artifact manifest JSON file'
branding:
icon: 'box'
color: 'gray-dark'

264894
dist/index.js generated vendored

File diff suppressed because one or more lines are too long

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

18407
dist/licenses.txt generated vendored

File diff suppressed because it is too large Load Diff

View File

@@ -11,19 +11,8 @@
"build": "yarn && tsc && ncc build lib --source-map --license licenses.txt",
"lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts",
"format": "prettier --write \"src/**/*.{js,ts}\"",
"cli": "yarn ts-node src/index.ts -m cli",
"gcp-secrets-tests": "cross-env providerStrategy=aws orchestratorTests=true inputPullCommand=\"gcp-secret-manager\" populateOverride=true pullInputList=UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD yarn test -i -t \"orchestrator\"",
"gcp-secrets-cli": "cross-env orchestratorTests=true USE_IL2CPP=false inputPullCommand=\"gcp-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
"aws-secrets-cli": "cross-env orchestratorTests=true inputPullCommand=\"aws-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
"cli-aws": "cross-env providerStrategy=aws yarn run test-cli",
"cli-k8s": "cross-env providerStrategy=k8s yarn run test-cli",
"test-cli": "cross-env orchestratorTests=true yarn ts-node src/index.ts -m cli --projectPath test-project",
"test": "jest",
"test:ci": "jest --config=jest.ci.config.js --runInBand",
"test-i": "cross-env orchestratorTests=true yarn test -i -t \"orchestrator\"",
"test-i-*": "yarn run test-i-aws && yarn run test-i-k8s",
"test-i-aws": "cross-env orchestratorTests=true providerStrategy=aws yarn test -i -t \"orchestrator\"",
"test-i-k8s": "cross-env orchestratorTests=true providerStrategy=k8s yarn test -i -t \"orchestrator\""
"test:ci": "jest --config=jest.ci.config.js --runInBand"
},
"engines": {
"node": ">=18.x"
@@ -33,35 +22,20 @@
"@actions/core": "^1.11.1",
"@actions/exec": "^1.1.1",
"@actions/github": "^6.0.0",
"@aws-sdk/client-cloudformation": "^3.777.0",
"@aws-sdk/client-cloudwatch-logs": "^3.777.0",
"@aws-sdk/client-ecs": "^3.778.0",
"@aws-sdk/client-kinesis": "^3.777.0",
"@aws-sdk/client-s3": "^3.779.0",
"@kubernetes/client-node": "^0.16.3",
"@octokit/core": "^5.1.0",
"async-wait-until": "^2.0.12",
"aws-sdk": "^2.1081.0",
"base-64": "^1.0.0",
"commander": "^9.0.0",
"commander-ts": "^0.2.0",
"kubernetes-client": "^9.0.0",
"md5": "^2.3.0",
"nanoid": "^3.3.1",
"reflect-metadata": "^0.1.13",
"nanoid": "^3.3.1",
"semver": "^7.5.2",
"shell-quote": "^1.8.3",
"ts-md5": "^1.3.1",
"unity-changeset": "^3.1.0",
"uuid": "^9.0.0",
"yaml": "^2.2.2"
},
"devDependencies": {
"@types/base-64": "^1.0.0",
"@types/jest": "^27.4.1",
"@types/node": "^17.0.23",
"@types/semver": "^7.3.9",
"@types/uuid": "^9.0.0",
"@typescript-eslint/parser": "4.8.1",
"@vercel/ncc": "^0.36.1",
"cross-env": "^7.0.3",

View File

@@ -0,0 +1,648 @@
/**
* Integration wiring tests for plugin features in index.ts
*
* These tests verify the conditional gating logic in runMain():
* - Each plugin feature is only invoked when its gate condition is met
* - Services are NOT called when their feature is disabled (the default)
* - The order of operations is correct (restore before build, save after build)
*/
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
import { BuildParameters } from './model';
// ---------------------------------------------------------------------------
// Service mocks — must be declared before importing index.ts (jest hoists them)
// ---------------------------------------------------------------------------
const mockChildWorkspaceService = {
buildConfig: jest.fn().mockReturnValue({ enabled: true, workspaceName: 'Test' }),
initializeWorkspace: jest.fn().mockReturnValue(false),
getWorkspaceSize: jest.fn().mockReturnValue('0 B'),
saveWorkspace: jest.fn(),
};
const mockSubmoduleProfileService = {
createInitPlan: jest.fn().mockResolvedValue([]),
execute: jest.fn().mockResolvedValue(''),
};
const mockLfsAgentService = {
configure: jest.fn().mockResolvedValue(''),
};
const mockLocalCacheService = {
resolveCacheRoot: jest.fn().mockReturnValue('/cache'),
generateCacheKey: jest.fn().mockReturnValue('key-1'),
restoreLfsCache: jest.fn().mockResolvedValue(true),
restoreLibraryCache: jest.fn().mockResolvedValue(true),
saveLibraryCache: jest.fn().mockResolvedValue(''),
saveLfsCache: jest.fn().mockResolvedValue(''),
};
const mockGitHooksService = {
installHooks: jest.fn().mockResolvedValue(''),
configureSkipList: jest.fn().mockReturnValue({ LEFTHOOK_EXCLUDE: 'pre-commit' }),
};
const mockBuildReliabilityService = {
configureGitEnvironment: jest.fn(),
checkGitIntegrity: jest.fn().mockReturnValue(true),
cleanStaleLockFiles: jest.fn(),
validateSubmoduleBackingStores: jest.fn(),
cleanReservedFilenames: jest.fn(),
recoverCorruptedRepo: jest.fn().mockReturnValue(true),
archiveBuildOutput: jest.fn(),
enforceRetention: jest.fn(),
};
const mockTestWorkflowService = {
executeTestSuite: jest.fn().mockResolvedValue([]),
};
const mockHotRunnerService = jest.fn();
const mockIncrementalSyncService = {
resolveStrategy: jest.fn().mockReturnValue('full'),
syncGitDelta: jest.fn().mockResolvedValue(0),
applyDirectInput: jest.fn().mockResolvedValue([]),
syncStoragePull: jest.fn().mockResolvedValue([]),
revertOverlays: jest.fn().mockImplementation(() => Promise.resolve()),
};
const mockOutputService = {
collectOutputs: jest.fn().mockImplementation(() => Promise.resolve()),
};
const mockOutputTypeRegistry = {
registerType: jest.fn(),
};
const mockArtifactUploadHandler = {
parseConfig: jest.fn().mockImplementation(() => {
/* no config */
}),
uploadArtifacts: jest.fn().mockResolvedValue({ success: true, entries: [] }),
};
const mockOrchestrator = {
run: jest.fn().mockImplementation(() => Promise.resolve()),
};
// Mock the orchestrator-plugin module to directly return our mock services.
// This avoids any issues with dynamic imports inside loadPluginServices().
jest.mock('./model/orchestrator-plugin', () => ({
loadOrchestrator: jest.fn().mockResolvedValue({
run: mockOrchestrator.run,
}),
loadPluginServices: jest.fn().mockResolvedValue({
BuildReliabilityService: mockBuildReliabilityService,
TestWorkflowService: mockTestWorkflowService,
HotRunnerService: mockHotRunnerService,
OutputService: mockOutputService,
OutputTypeRegistry: mockOutputTypeRegistry,
ArtifactUploadHandler: mockArtifactUploadHandler,
IncrementalSyncService: mockIncrementalSyncService,
// Lazy-loaded services (matching the plugin loader API)
loadChildWorkspaceService: jest.fn().mockResolvedValue(mockChildWorkspaceService),
loadLocalCacheService: jest.fn().mockResolvedValue(mockLocalCacheService),
loadSubmoduleProfileService: jest.fn().mockResolvedValue(mockSubmoduleProfileService),
loadLfsAgentService: jest.fn().mockResolvedValue(mockLfsAgentService),
loadGitHooksService: jest.fn().mockResolvedValue(mockGitHooksService),
}),
}));
// Mock all non-plugin dependencies to isolate the wiring logic
jest.mock('@actions/core');
jest.mock('./model', () => ({
Action: {
checkCompatibility: jest.fn(),
workspace: '/workspace',
actionFolder: '/action',
},
BuildParameters: {
create: jest.fn(),
},
Cache: {
verify: jest.fn(),
},
Docker: {
run: jest.fn().mockResolvedValue(0),
},
ImageTag: jest.fn().mockImplementation(() => ({
toString: () => 'mock-image:latest',
})),
Output: {
setBuildVersion: jest.fn().mockResolvedValue(''),
setAndroidVersionCode: jest.fn().mockResolvedValue(''),
setEngineExitCode: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('./model/cli/cli', () => ({
Cli: {
InitCliMode: jest.fn().mockReturnValue(false),
},
}));
jest.mock('./model/mac-builder', () => ({
__esModule: true,
default: {
run: jest.fn().mockResolvedValue(0),
},
}));
jest.mock('./model/platform-setup', () => ({
__esModule: true,
default: {
setup: jest.fn().mockResolvedValue(''),
},
}));
const mockedBuildParametersCreate = BuildParameters.create as jest.Mock;
interface PluginBuildParametersOverrides {
providerStrategy?: string;
childWorkspacesEnabled?: boolean;
childWorkspaceName?: string;
childWorkspaceCacheRoot?: string;
childWorkspacePreserveGit?: boolean;
childWorkspaceSeparateLibrary?: boolean;
submoduleProfilePath?: string;
submoduleVariantPath?: string;
submoduleToken?: string;
gitPrivateToken?: string;
lfsTransferAgent?: string;
lfsTransferAgentArgs?: string;
lfsStoragePaths?: string;
localCacheEnabled?: boolean;
localCacheRoot?: string;
localCacheLibrary?: boolean;
localCacheLfs?: boolean;
gitHooksEnabled?: boolean;
gitHooksSkipList?: string;
gitHooksRunBeforeBuild?: string;
}
function createMockBuildParameters(overrides: PluginBuildParametersOverrides = {}) {
return {
// Required base properties
providerStrategy: 'local',
targetPlatform: 'StandaloneLinux64',
editorVersion: '2021.3.1f1',
buildVersion: '1.0.0',
androidVersionCode: '1',
projectPath: '.',
branch: 'main',
runnerTempPath: '/tmp',
// Plugin features - all disabled by default
childWorkspacesEnabled: false,
childWorkspaceName: '',
childWorkspaceCacheRoot: '',
childWorkspacePreserveGit: true,
childWorkspaceSeparateLibrary: true,
submoduleProfilePath: '',
submoduleVariantPath: '',
submoduleToken: '',
gitPrivateToken: '',
lfsTransferAgent: '',
lfsTransferAgentArgs: '',
lfsStoragePaths: '',
localCacheEnabled: false,
localCacheRoot: '',
localCacheLibrary: true,
localCacheLfs: false,
gitHooksEnabled: false,
gitHooksSkipList: '',
gitHooksRunBeforeBuild: '',
...overrides,
};
}
/**
* The entry point (runMain) is invoked by importing index.ts.
* Since it calls `runMain()` at module scope, we need to re-import it
* for each test. jest.isolateModules() handles this.
*/
async function runIndex(overrides: PluginBuildParametersOverrides = {}): Promise<void> {
mockedBuildParametersCreate.mockResolvedValue(createMockBuildParameters(overrides));
return new Promise<void>((resolve) => {
jest.isolateModules(() => {
require('./index');
// runMain() is async; give it a tick to complete
// We use setImmediate to ensure all microtasks from the dynamic imports resolve
});
// Allow all promises and microtasks to settle
setTimeout(resolve, 100);
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('index.ts plugin feature wiring', () => {
const originalPlatform = process.platform;
const originalEnvironment = { ...process.env };
beforeEach(() => {
jest.clearAllMocks();
process.env.GITHUB_WORKSPACE = '/workspace';
// Force linux platform so Docker.run is used (not MacBuilder)
Object.defineProperty(process, 'platform', { value: 'linux' });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
process.env = { ...originalEnvironment };
});
// -----------------------------------------------------------------------
// GitHooksService gating
// -----------------------------------------------------------------------
describe('GitHooksService gating', () => {
it('should NOT call GitHooksService when gitHooksEnabled is false (default)', async () => {
await runIndex({ gitHooksEnabled: false });
expect(mockGitHooksService.installHooks).not.toHaveBeenCalled();
expect(mockGitHooksService.configureSkipList).not.toHaveBeenCalled();
});
it('should call installHooks when gitHooksEnabled is true', async () => {
await runIndex({ gitHooksEnabled: true });
expect(mockGitHooksService.installHooks).toHaveBeenCalledWith('/workspace');
});
it('should call configureSkipList when gitHooksEnabled and gitHooksSkipList is set', async () => {
await runIndex({
gitHooksEnabled: true,
gitHooksSkipList: 'pre-commit,pre-push',
});
expect(mockGitHooksService.configureSkipList).toHaveBeenCalledWith(['pre-commit', 'pre-push']);
});
it('should NOT call configureSkipList when gitHooksSkipList is empty', async () => {
await runIndex({
gitHooksEnabled: true,
gitHooksSkipList: '',
});
expect(mockGitHooksService.installHooks).toHaveBeenCalled();
expect(mockGitHooksService.configureSkipList).not.toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// LocalCacheService gating
// -----------------------------------------------------------------------
describe('LocalCacheService gating', () => {
it('should NOT call LocalCacheService when localCacheEnabled is false (default)', async () => {
await runIndex({ localCacheEnabled: false });
expect(mockLocalCacheService.resolveCacheRoot).not.toHaveBeenCalled();
expect(mockLocalCacheService.generateCacheKey).not.toHaveBeenCalled();
expect(mockLocalCacheService.restoreLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).not.toHaveBeenCalled();
});
it('should call restore and save operations when localCacheEnabled is true', async () => {
await runIndex({
localCacheEnabled: true,
localCacheLibrary: true,
localCacheLfs: true,
});
expect(mockLocalCacheService.resolveCacheRoot).toHaveBeenCalled();
expect(mockLocalCacheService.generateCacheKey).toHaveBeenCalled();
expect(mockLocalCacheService.restoreLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).toHaveBeenCalled();
});
it('should only cache Library when localCacheLibrary is true and localCacheLfs is false', async () => {
await runIndex({
localCacheEnabled: true,
localCacheLibrary: true,
localCacheLfs: false,
});
expect(mockLocalCacheService.restoreLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).not.toHaveBeenCalled();
});
it('should only cache LFS when localCacheLfs is true and localCacheLibrary is false', async () => {
await runIndex({
localCacheEnabled: true,
localCacheLibrary: false,
localCacheLfs: true,
});
expect(mockLocalCacheService.restoreLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.restoreLfsCache).toHaveBeenCalled();
expect(mockLocalCacheService.saveLibraryCache).not.toHaveBeenCalled();
expect(mockLocalCacheService.saveLfsCache).toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// ChildWorkspaceService gating
// -----------------------------------------------------------------------
describe('ChildWorkspaceService gating', () => {
it('should NOT call ChildWorkspaceService when childWorkspacesEnabled is false (default)', async () => {
await runIndex({ childWorkspacesEnabled: false });
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
expect(mockChildWorkspaceService.initializeWorkspace).not.toHaveBeenCalled();
expect(mockChildWorkspaceService.saveWorkspace).not.toHaveBeenCalled();
});
it('should NOT call ChildWorkspaceService when childWorkspacesEnabled is true but childWorkspaceName is empty', async () => {
await runIndex({
childWorkspacesEnabled: true,
childWorkspaceName: '',
});
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
});
it('should call buildConfig, initializeWorkspace, and saveWorkspace when enabled with a name', async () => {
mockChildWorkspaceService.buildConfig.mockReturnValue({ enabled: true, workspaceName: 'TurnOfWar' });
await runIndex({
childWorkspacesEnabled: true,
childWorkspaceName: 'TurnOfWar',
childWorkspaceCacheRoot: '/cache/workspaces',
});
expect(mockChildWorkspaceService.buildConfig).toHaveBeenCalledWith(
expect.objectContaining({
childWorkspacesEnabled: true,
childWorkspaceName: 'TurnOfWar',
}),
);
expect(mockChildWorkspaceService.initializeWorkspace).toHaveBeenCalled();
expect(mockChildWorkspaceService.getWorkspaceSize).toHaveBeenCalled();
expect(mockChildWorkspaceService.saveWorkspace).toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// SubmoduleProfileService gating
// -----------------------------------------------------------------------
describe('SubmoduleProfileService gating', () => {
it('should NOT call SubmoduleProfileService when submoduleProfilePath is empty (default)', async () => {
await runIndex({ submoduleProfilePath: '' });
expect(mockSubmoduleProfileService.createInitPlan).not.toHaveBeenCalled();
expect(mockSubmoduleProfileService.execute).not.toHaveBeenCalled();
});
it('should call createInitPlan and execute when submoduleProfilePath is set', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleVariantPath: '',
submoduleToken: 'my-token',
});
expect(mockSubmoduleProfileService.createInitPlan).toHaveBeenCalledWith('/path/to/profile.yml', '', '/workspace');
expect(mockSubmoduleProfileService.execute).toHaveBeenCalled();
});
it('should pass variant path when provided', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleVariantPath: '/path/to/variant.yml',
});
expect(mockSubmoduleProfileService.createInitPlan).toHaveBeenCalledWith(
'/path/to/profile.yml',
'/path/to/variant.yml',
'/workspace',
);
});
it('should use submoduleToken for auth, falling back to gitPrivateToken', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleToken: '',
gitPrivateToken: 'fallback-token',
});
expect(mockSubmoduleProfileService.execute).toHaveBeenCalledWith(
expect.anything(),
'/workspace',
'fallback-token',
);
});
it('should prefer submoduleToken over gitPrivateToken', async () => {
await runIndex({
submoduleProfilePath: '/path/to/profile.yml',
submoduleToken: 'specific-token',
gitPrivateToken: 'fallback-token',
});
expect(mockSubmoduleProfileService.execute).toHaveBeenCalledWith(
expect.anything(),
'/workspace',
'specific-token',
);
});
});
// -----------------------------------------------------------------------
// LfsAgentService gating
// -----------------------------------------------------------------------
describe('LfsAgentService gating', () => {
it('should NOT call LfsAgentService when lfsTransferAgent is empty (default)', async () => {
await runIndex({ lfsTransferAgent: '' });
expect(mockLfsAgentService.configure).not.toHaveBeenCalled();
});
it('should call configure when lfsTransferAgent is set', async () => {
await runIndex({
lfsTransferAgent: '/tools/elastic-git-storage',
lfsTransferAgentArgs: '--verbose',
lfsStoragePaths: '/path/a;/path/b',
});
expect(mockLfsAgentService.configure).toHaveBeenCalledWith(
'/tools/elastic-git-storage',
'--verbose',
['/path/a', '/path/b'],
'/workspace',
);
});
it('should pass empty array when lfsStoragePaths is empty', async () => {
await runIndex({
lfsTransferAgent: '/tools/agent',
lfsStoragePaths: '',
});
expect(mockLfsAgentService.configure).toHaveBeenCalledWith('/tools/agent', '', [], '/workspace');
});
});
// -----------------------------------------------------------------------
// Order of operations (restore before build, save after build)
// -----------------------------------------------------------------------
describe('order of operations', () => {
it('should execute restore operations before build and save operations after build', async () => {
const callOrder: string[] = [];
// Track call order for each relevant operation
mockChildWorkspaceService.buildConfig.mockReturnValue({ enabled: true, workspaceName: 'Test' });
mockChildWorkspaceService.initializeWorkspace.mockImplementation(() => {
callOrder.push('child-workspace-restore');
return false;
});
mockChildWorkspaceService.getWorkspaceSize.mockImplementation(() => {
callOrder.push('child-workspace-size');
return '0 B';
});
mockSubmoduleProfileService.createInitPlan.mockImplementation(async () => {
callOrder.push('submodule-profile-plan');
return [];
});
mockSubmoduleProfileService.execute.mockImplementation(async () => {
callOrder.push('submodule-profile-execute');
});
mockLfsAgentService.configure.mockImplementation(async () => {
callOrder.push('lfs-agent-configure');
});
mockLocalCacheService.resolveCacheRoot.mockImplementation(() => {
callOrder.push('local-cache-resolve');
return '/cache';
});
mockLocalCacheService.generateCacheKey.mockImplementation(() => {
callOrder.push('local-cache-keygen');
return 'key-1';
});
mockLocalCacheService.restoreLfsCache.mockImplementation(async () => {
callOrder.push('local-cache-restore-lfs');
return true;
});
mockLocalCacheService.restoreLibraryCache.mockImplementation(async () => {
callOrder.push('local-cache-restore-library');
return true;
});
mockGitHooksService.installHooks.mockImplementation(async () => {
callOrder.push('git-hooks-install');
});
mockLocalCacheService.saveLibraryCache.mockImplementation(async () => {
callOrder.push('local-cache-save-library');
});
mockLocalCacheService.saveLfsCache.mockImplementation(async () => {
callOrder.push('local-cache-save-lfs');
});
mockChildWorkspaceService.saveWorkspace.mockImplementation(() => {
callOrder.push('child-workspace-save');
});
await runIndex({
childWorkspacesEnabled: true,
childWorkspaceName: 'TurnOfWar',
submoduleProfilePath: '/profile.yml',
lfsTransferAgent: '/tools/agent',
localCacheEnabled: true,
localCacheLfs: true,
localCacheLibrary: true,
gitHooksEnabled: true,
});
// Verify restore operations happen before save operations.
// The expected order from index.ts is:
// 1. Child workspace restore
// 2. Submodule profile init
// 3. LFS agent configure
// 4. Local cache restore (LFS then Library)
// 5. Git hooks install
// 6. [BUILD happens here - Docker.run or MacBuilder.run]
// 7. Local cache save (Library then LFS)
// 8. Child workspace save
const restoreOps = [
'child-workspace-restore',
'submodule-profile-plan',
'submodule-profile-execute',
'lfs-agent-configure',
'local-cache-restore-lfs',
'local-cache-restore-library',
'git-hooks-install',
];
const saveOps = ['local-cache-save-library', 'local-cache-save-lfs', 'child-workspace-save'];
// All restore ops should appear before all save ops
for (const restoreOp of restoreOps) {
if (!callOrder.includes(restoreOp)) continue; // Skip if the operation wasn't called
for (const saveOp of saveOps) {
if (!callOrder.includes(saveOp)) continue;
expect(callOrder.indexOf(restoreOp)).toBeLessThan(callOrder.indexOf(saveOp));
}
}
// Child workspace save should be last
if (callOrder.includes('child-workspace-save') && callOrder.includes('local-cache-save-lfs')) {
expect(callOrder.indexOf('local-cache-save-lfs')).toBeLessThan(callOrder.indexOf('child-workspace-save'));
}
});
});
// -----------------------------------------------------------------------
// Non-local provider strategy
// -----------------------------------------------------------------------
describe('non-local provider strategy', () => {
it('should skip all plugin features when providerStrategy is not local', async () => {
await runIndex({
providerStrategy: 'aws',
childWorkspacesEnabled: true,
childWorkspaceName: 'Test',
submoduleProfilePath: '/profile.yml',
lfsTransferAgent: '/tools/agent',
localCacheEnabled: true,
gitHooksEnabled: true,
});
// None of the plugin services should be called because
// they are inside the `if (providerStrategy === 'local')` block
expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled();
expect(mockSubmoduleProfileService.createInitPlan).not.toHaveBeenCalled();
expect(mockLfsAgentService.configure).not.toHaveBeenCalled();
expect(mockLocalCacheService.resolveCacheRoot).not.toHaveBeenCalled();
expect(mockGitHooksService.installHooks).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,8 +1,13 @@
import * as core from '@actions/core';
import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output } from './model';
import path from 'node:path';
import { Action, BuildParameters, Cache, Docker, ImageTag, Output } from './model';
import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup';
import { loadOrchestrator, loadPluginServices } from './model/orchestrator-plugin';
type SyncStrategy = 'full' | 'git-delta' | 'direct-input' | 'storage-pull';
type PluginServices = Exclude<ReturnType<typeof loadPluginServices> extends Promise<infer T> ? T : never, undefined>;
async function runMain() {
try {
@@ -14,15 +19,210 @@ async function runMain() {
Action.checkCompatibility();
Cache.verify();
const plugin = await loadPluginServices();
// Always configure git environment for CI reliability
plugin?.BuildReliabilityService.configureGitEnvironment();
const { workspace, actionFolder } = Action;
const buildParameters = await BuildParameters.create();
// If a test suite path is provided, use the test workflow engine
// instead of the standard build execution path
if (buildParameters.testSuitePath) {
core.info('[TestWorkflow] Test suite path detected, using test workflow engine');
const results = await plugin?.TestWorkflowService.executeTestSuite(
buildParameters.testSuitePath,
buildParameters,
);
let totalFailed = 0;
for (const result of results || []) {
totalFailed += result.failed;
}
if (totalFailed > 0) {
core.setFailed(`Test workflow completed with ${totalFailed} failure(s)`);
} else {
core.info('[TestWorkflow] All test runs passed');
}
return;
}
const baseImage = new ImageTag(buildParameters);
// Pre-build reliability checks
if (buildParameters.gitIntegrityCheck) {
core.info('Running git integrity checks...');
const isHealthy = plugin?.BuildReliabilityService.checkGitIntegrity(workspace);
plugin?.BuildReliabilityService.cleanStaleLockFiles(workspace);
plugin?.BuildReliabilityService.validateSubmoduleBackingStores(workspace);
if (buildParameters.cleanReservedFilenames) {
plugin?.BuildReliabilityService.cleanReservedFilenames(buildParameters.projectPath);
}
if (!isHealthy && buildParameters.gitAutoRecover) {
core.info('Git corruption detected, attempting automatic recovery...');
const recovered = plugin?.BuildReliabilityService.recoverCorruptedRepo(workspace);
if (!recovered) {
core.warning('Automatic recovery failed. Build may encounter issues.');
}
}
} else if (buildParameters.cleanReservedFilenames) {
// cleanReservedFilenames can run independently of gitIntegrityCheck
plugin?.BuildReliabilityService.cleanReservedFilenames(buildParameters.projectPath);
}
let exitCode = -1;
if (buildParameters.providerStrategy === 'local') {
// Hot runner path: attempt to use a persistent Unity editor instance
if (buildParameters.hotRunnerEnabled) {
core.info('[HotRunner] Hot runner mode enabled, attempting hot build...');
const hotRunnerConfig = {
enabled: true,
transport: buildParameters.hotRunnerTransport,
host: buildParameters.hotRunnerHost,
port: buildParameters.hotRunnerPort,
healthCheckInterval: buildParameters.hotRunnerHealthInterval,
maxIdleTime: buildParameters.hotRunnerMaxIdle,
maxJobsBeforeRecycle: 0, // no automatic recycle by job count
};
if (!plugin?.HotRunnerService) {
throw new Error('[HotRunner] Orchestrator plugin required for hot runner mode');
}
const hotRunnerService = new plugin.HotRunnerService();
try {
await hotRunnerService.initialize(hotRunnerConfig);
const result = await hotRunnerService.submitBuild(buildParameters, (output: string) => {
core.info(output);
});
exitCode = result.exitCode;
core.info(`[HotRunner] Build completed with exit code ${exitCode}`);
await hotRunnerService.shutdown();
} catch (hotRunnerError) {
await hotRunnerService.shutdown();
if (buildParameters.hotRunnerFallbackToCold) {
core.warning(
`[HotRunner] Hot runner failed: ${(hotRunnerError as Error).message}. Falling back to cold build.`,
);
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
} else {
throw hotRunnerError;
}
}
} else if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
// Child workspace isolation - restore cached workspace before any other setup
let childWorkspaceConfig: any;
if (buildParameters.childWorkspacesEnabled && buildParameters.childWorkspaceName) {
const ChildWorkspaceService = await plugin?.loadChildWorkspaceService();
const cacheRoot =
buildParameters.childWorkspaceCacheRoot ||
path.join(buildParameters.runnerTempPath || process.env.RUNNER_TEMP || '', 'game-ci-workspaces');
childWorkspaceConfig = ChildWorkspaceService?.buildConfig({
childWorkspacesEnabled: buildParameters.childWorkspacesEnabled,
childWorkspaceName: buildParameters.childWorkspaceName,
childWorkspaceCacheRoot: cacheRoot,
childWorkspacePreserveGit: buildParameters.childWorkspacePreserveGit,
childWorkspaceSeparateLibrary: buildParameters.childWorkspaceSeparateLibrary,
});
const projectFullPath = path.join(workspace, buildParameters.projectPath);
const restored = ChildWorkspaceService?.initializeWorkspace(projectFullPath, childWorkspaceConfig);
core.info(
`Child workspace "${buildParameters.childWorkspaceName}": ${
restored ? 'restored from cache' : 'starting fresh'
}`,
);
// Log workspace size for resource tracking
const size = ChildWorkspaceService?.getWorkspaceSize(projectFullPath);
core.info(`Child workspace size after restore: ${size}`);
}
// Submodule profile initialization
if (buildParameters.submoduleProfilePath) {
core.info('Initializing submodules from profile...');
const SubmoduleProfileService = await plugin?.loadSubmoduleProfileService();
const plan = await SubmoduleProfileService?.createInitPlan(
buildParameters.submoduleProfilePath,
buildParameters.submoduleVariantPath,
workspace,
);
if (plan) {
await SubmoduleProfileService?.execute(
plan,
workspace,
buildParameters.submoduleToken || buildParameters.gitPrivateToken,
);
}
}
// Configure custom LFS transfer agent
if (buildParameters.lfsTransferAgent) {
core.info('Configuring custom LFS transfer agent...');
const LfsAgentService = await plugin?.loadLfsAgentService();
await LfsAgentService?.configure(
buildParameters.lfsTransferAgent,
buildParameters.lfsTransferAgentArgs,
buildParameters.lfsStoragePaths ? buildParameters.lfsStoragePaths.split(';') : [],
workspace,
);
}
// Local build caching - restore
let cacheRoot = '';
let cacheKey = '';
// eslint-disable-next-line no-undef
let LocalCacheService: Awaited<ReturnType<NonNullable<typeof plugin>['loadLocalCacheService']>> | undefined;
if (buildParameters.localCacheEnabled) {
LocalCacheService = await plugin?.loadLocalCacheService();
cacheRoot = LocalCacheService?.resolveCacheRoot(buildParameters) || '';
cacheKey =
LocalCacheService?.generateCacheKey(
buildParameters.targetPlatform,
buildParameters.editorVersion,
buildParameters.branch || '',
) || '';
if (buildParameters.localCacheLfs) {
await LocalCacheService?.restoreLfsCache(workspace, cacheRoot, cacheKey);
}
if (buildParameters.localCacheLibrary) {
const projectFullPath = path.join(workspace, buildParameters.projectPath);
await LocalCacheService?.restoreLibraryCache(projectFullPath, cacheRoot, cacheKey);
}
}
// Git hooks — opt-in only. When disabled (default), do not touch hooks at all.
if (buildParameters.gitHooksEnabled) {
const GitHooksService = await plugin?.loadGitHooksService();
await GitHooksService?.installHooks(workspace);
if (buildParameters.gitHooksSkipList) {
const environment = GitHooksService?.configureSkipList(buildParameters.gitHooksSkipList.split(','));
if (environment) {
Object.assign(process.env, environment);
}
}
}
// Apply incremental sync strategy before build
const syncStrategy = buildParameters.syncStrategy as SyncStrategy;
if (syncStrategy !== 'full') {
core.info(`[Sync] Applying sync strategy: ${syncStrategy}`);
await applySyncStrategy(buildParameters, workspace, plugin);
}
await PlatformSetup.setup(buildParameters, actionFolder);
exitCode =
process.platform === 'darwin'
@@ -32,16 +232,126 @@ async function runMain() {
actionFolder,
...buildParameters,
});
// Local build caching - save
if (buildParameters.localCacheEnabled && LocalCacheService) {
if (buildParameters.localCacheLibrary) {
const projectFullPath = path.join(workspace, buildParameters.projectPath);
await LocalCacheService.saveLibraryCache(projectFullPath, cacheRoot, cacheKey);
}
if (buildParameters.localCacheLfs) {
await LocalCacheService.saveLfsCache(workspace, cacheRoot, cacheKey);
}
}
// Child workspace isolation - save workspace for next run
if (childWorkspaceConfig && childWorkspaceConfig.enabled) {
const ChildWorkspaceService = await plugin?.loadChildWorkspaceService();
const projectFullPath = path.join(workspace, buildParameters.projectPath);
const preSaveSize = ChildWorkspaceService?.getWorkspaceSize(projectFullPath);
core.info(`Child workspace size before save: ${preSaveSize}`);
ChildWorkspaceService?.saveWorkspace(projectFullPath, childWorkspaceConfig);
core.info(`Child workspace "${buildParameters.childWorkspaceName}" saved to cache`);
}
// Revert overlays after job completion if configured
if (buildParameters.syncRevertAfter && syncStrategy !== 'full') {
core.info('[Sync] Reverting overlay changes after job completion');
try {
await plugin?.IncrementalSyncService.revertOverlays(workspace, buildParameters.syncStatePath);
} catch (revertError) {
core.warning(`[Sync] Overlay revert failed: ${(revertError as Error).message}`);
}
}
} else {
await Orchestrator.run(buildParameters, baseImage.toString());
const orchestrator = await loadOrchestrator();
if (!orchestrator) {
throw new Error(
'Orchestrator package not available. Install @game-ci/orchestrator or use providerStrategy=local.',
);
}
await orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0;
}
// Post-build: archive and enforce retention
if (buildParameters.buildArchiveEnabled && exitCode === 0) {
core.info('Archiving build output...');
plugin?.BuildReliabilityService.archiveBuildOutput(buildParameters.buildPath, buildParameters.buildArchivePath);
plugin?.BuildReliabilityService.enforceRetention(
buildParameters.buildArchivePath,
buildParameters.buildArchiveRetention,
);
}
// Set output
await Output.setBuildVersion(buildParameters.buildVersion);
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
await Output.setEngineExitCode(exitCode);
// Artifact collection and upload (runs on both success and failure)
try {
// Register custom output types if provided
if (buildParameters.artifactCustomTypes) {
try {
const customTypes = JSON.parse(buildParameters.artifactCustomTypes);
if (Array.isArray(customTypes)) {
for (const ct of customTypes) {
plugin?.OutputTypeRegistry.registerType({
name: ct.name,
defaultPath: ct.defaultPath || ct.pattern || `./${ct.name}/`,
description: ct.description || `Custom output type: ${ct.name}`,
builtIn: false,
});
}
}
} catch (parseError) {
core.warning(`Failed to parse artifactCustomTypes: ${(parseError as Error).message}`);
}
}
// Collect outputs and generate manifest
const manifestPath = path.join(buildParameters.projectPath, 'output-manifest.json');
const manifest = await plugin?.OutputService.collectOutputs(
buildParameters.projectPath,
buildParameters.buildGuid,
buildParameters.artifactOutputTypes,
manifestPath,
);
core.setOutput('artifactManifestPath', manifestPath);
if (manifest) {
// Upload artifacts
const uploadConfig = plugin?.ArtifactUploadHandler.parseConfig(
buildParameters.artifactUploadTarget,
buildParameters.artifactUploadPath || undefined,
buildParameters.artifactCompression,
buildParameters.artifactRetentionDays,
);
if (uploadConfig) {
const uploadResult = await plugin?.ArtifactUploadHandler.uploadArtifacts(
manifest,
uploadConfig,
buildParameters.projectPath,
);
if (uploadResult && !uploadResult.success) {
core.warning(
`Artifact upload completed with errors: ${uploadResult.entries
.filter((entry: any) => !entry.success)
.map((entry: any) => `${entry.type}: ${entry.error}`)
.join('; ')}`,
);
}
}
}
} catch (artifactError) {
core.warning(`Artifact collection/upload failed: ${(artifactError as Error).message}`);
}
if (exitCode !== 0) {
core.setFailed(`Build failed with exit code ${exitCode}`);
}
@@ -50,4 +360,99 @@ async function runMain() {
}
}
async function runColdBuild(
buildParameters: BuildParameters,
baseImage: ImageTag,
workspace: string,
actionFolder: string,
): Promise<number> {
if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
await PlatformSetup.setup(buildParameters, actionFolder);
return process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace,
actionFolder,
...buildParameters,
});
} else {
const orchestrator = await loadOrchestrator();
if (!orchestrator) {
throw new Error(
'Orchestrator package not available. Install @game-ci/orchestrator or use providerStrategy=local.',
);
}
await orchestrator.run(buildParameters, baseImage.toString());
return 0;
}
}
/**
* Apply the configured sync strategy to the workspace before build.
*/
async function applySyncStrategy(
buildParameters: BuildParameters,
workspace: string,
plugin?: PluginServices | undefined,
): Promise<void> {
if (!plugin?.IncrementalSyncService) {
core.warning('[Sync] Orchestrator plugin not available, skipping sync strategy');
return;
}
const { IncrementalSyncService } = plugin;
const strategy = buildParameters.syncStrategy as SyncStrategy;
const resolvedStrategy = IncrementalSyncService.resolveStrategy(strategy, workspace, buildParameters.syncStatePath);
if (resolvedStrategy === 'full') {
core.info('[Sync] Resolved to full sync (no incremental state available)');
return;
}
switch (resolvedStrategy) {
case 'git-delta': {
const targetReference = buildParameters.gitSha || buildParameters.branch;
const changedFiles = await IncrementalSyncService.syncGitDelta(
workspace,
targetReference,
buildParameters.syncStatePath,
);
core.info(`[Sync] Git delta sync applied: ${changedFiles} file(s) changed`);
break;
}
case 'direct-input': {
if (!buildParameters.syncInputRef) {
throw new Error('[Sync] direct-input strategy requires syncInputRef to be set');
}
const overlays = await IncrementalSyncService.applyDirectInput(
workspace,
buildParameters.syncInputRef,
buildParameters.syncStorageRemote || undefined,
buildParameters.syncStatePath,
);
core.info(`[Sync] Direct input applied: ${overlays.length} overlay(s)`);
break;
}
case 'storage-pull': {
if (!buildParameters.syncInputRef) {
throw new Error('[Sync] storage-pull strategy requires syncInputRef to be set');
}
const pulledFiles = await IncrementalSyncService.syncStoragePull(workspace, buildParameters.syncInputRef, {
rcloneRemote: buildParameters.syncStorageRemote || undefined,
syncRevertAfter: buildParameters.syncRevertAfter,
statePath: buildParameters.syncStatePath,
});
core.info(`[Sync] Storage pull complete: ${pulledFiles.length} file(s)`);
break;
}
default:
core.warning(`[Sync] Unknown sync strategy: ${resolvedStrategy}`);
}
}
runMain();

View File

@@ -1,29 +0,0 @@
// Integration test for exercising real GitHub check creation and updates.
import Orchestrator from '../model/orchestrator/orchestrator';
import UnityVersioning from '../model/unity-versioning';
import GitHub from '../model/github';
import { TIMEOUT_INFINITE, createParameters } from '../test-utils/orchestrator-test-helpers';
const runIntegration = process.env.RUN_GITHUB_INTEGRATION_TESTS === 'true';
const describeOrSkip = runIntegration ? describe : describe.skip;
describeOrSkip('Orchestrator Github Checks Integration', () => {
it(
'creates and updates a real GitHub check',
async () => {
const buildParameter = await createParameters({
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.read('test-project'),
asyncOrchestrator: `true`,
githubChecks: `true`,
});
await Orchestrator.setup(buildParameter);
const checkId = await GitHub.createGitHubCheck(`integration create`);
expect(checkId).not.toEqual('');
await GitHub.updateGitHubCheck(`1 ${new Date().toISOString()}`, `integration`);
await GitHub.updateGitHubCheck(`2 ${new Date().toISOString()}`, `integration`, `success`, `completed`);
},
TIMEOUT_INFINITE,
);
});

View File

@@ -1,7 +1,5 @@
import { customAlphabet } from 'nanoid';
import AndroidVersioning from './android-versioning';
import OrchestratorConstants from './orchestrator/options/orchestrator-constants';
import OrchestratorBuildGuid from './orchestrator/options/orchestrator-guid';
import Input from './input';
import Platform from './platform';
import UnityVersioning from './unity-versioning';
@@ -10,8 +8,6 @@ import { GitRepoReader } from './input-readers/git-repo';
import { GithubCliReader } from './input-readers/github-cli';
import { Cli } from './cli/cli';
import GitHub from './github';
import OrchestratorOptions from './orchestrator/options/orchestrator-options';
import Orchestrator from './orchestrator/orchestrator';
import * as core from '@actions/core';
class BuildParameters {
@@ -54,6 +50,13 @@ class BuildParameters {
public sshAgent!: string;
public sshPublicKeysDirectoryPath!: string;
public providerStrategy!: string;
public fallbackProviderStrategy!: string;
public runnerCheckEnabled!: boolean;
public runnerCheckLabels!: string[];
public runnerCheckMinAvailable!: number;
public retryOnFallback!: boolean;
public providerInitTimeout!: number;
public gitAuthMode!: string;
public gitPrivateToken!: string;
public awsStackName!: string;
public awsEndpoint?: string;
@@ -106,10 +109,103 @@ class BuildParameters {
public cacheUnityInstallationOnMac!: boolean;
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string;
public submoduleProfilePath!: string;
public submoduleVariantPath!: string;
public submoduleToken!: string;
public localCacheEnabled!: boolean;
public localCacheRoot!: string;
public localCacheLibrary!: boolean;
public localCacheLfs!: boolean;
public childWorkspacesEnabled!: boolean;
public childWorkspaceName!: string;
public childWorkspaceCacheRoot!: string;
public childWorkspacePreserveGit!: boolean;
public childWorkspaceSeparateLibrary!: boolean;
public lfsTransferAgent!: string;
public lfsTransferAgentArgs!: string;
public lfsStoragePaths!: string;
public gitHooksEnabled!: boolean;
public gitHooksSkipList!: string;
public gitHooksRunBeforeBuild!: string;
public providerExecutable!: string;
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
}
// 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 gitIntegrityCheck!: boolean;
public gitAutoRecover!: boolean;
public cleanReservedFilenames!: boolean;
public buildArchiveEnabled!: boolean;
public buildArchivePath!: string;
public buildArchiveRetention!: number;
public testSuitePath!: string;
public testSuiteEvent!: string;
public testTaxonomyPath!: string;
public testResultFormat!: string;
public testResultPath!: string;
public hotRunnerEnabled!: boolean;
public hotRunnerTransport!: 'websocket' | 'grpc' | 'named-pipe';
public hotRunnerHost!: string;
public hotRunnerPort!: number;
public hotRunnerHealthInterval!: number;
public hotRunnerMaxIdle!: number;
public hotRunnerFallbackToCold!: boolean;
public artifactOutputTypes!: string;
public artifactUploadTarget!: string;
public artifactUploadPath!: string;
public artifactCompression!: string;
public artifactRetentionDays!: string;
public artifactCustomTypes!: string;
public syncStrategy!: string;
public syncInputRef!: string;
public syncStorageRemote!: string;
public syncRevertAfter!: boolean;
public syncStatePath!: string;
static async create(): Promise<BuildParameters> {
const buildFile = this.parseBuildFile(Input.buildName, Input.targetPlatform, Input.androidExportType);
@@ -193,55 +289,166 @@ class BuildParameters {
dockerIsolationMode: Input.dockerIsolationMode,
containerRegistryRepository: Input.containerRegistryRepository,
containerRegistryImageVersion: Input.containerRegistryImageVersion,
providerStrategy: OrchestratorOptions.providerStrategy,
buildPlatform: OrchestratorOptions.buildPlatform,
kubeConfig: OrchestratorOptions.kubeConfig,
containerMemory: OrchestratorOptions.containerMemory,
containerCpu: OrchestratorOptions.containerCpu,
containerNamespace: OrchestratorOptions.containerNamespace,
kubeVolumeSize: OrchestratorOptions.kubeVolumeSize,
kubeVolume: OrchestratorOptions.kubeVolume,
postBuildContainerHooks: OrchestratorOptions.postBuildContainerHooks,
preBuildContainerHooks: OrchestratorOptions.preBuildContainerHooks,
customJob: OrchestratorOptions.customJob,
providerStrategy: Input.getInput('providerStrategy') || (Cli.isCliMode ? 'aws' : 'local'),
fallbackProviderStrategy: Input.getInput('fallbackProviderStrategy') || '',
runnerCheckEnabled: Input.getInput('runnerCheckEnabled') === 'true',
runnerCheckLabels: (Input.getInput('runnerCheckLabels') || '')
.split(',')
.map((l: string) => l.trim())
.filter(Boolean),
runnerCheckMinAvailable: Number(Input.getInput('runnerCheckMinAvailable')) || 1,
retryOnFallback: Input.getInput('retryOnFallback') === 'true',
providerInitTimeout: Number(Input.getInput('providerInitTimeout')) || 0,
gitAuthMode: Input.getInput('gitAuthMode') || 'header',
buildPlatform:
Input.getInput('buildPlatform') ||
((Input.getInput('providerStrategy') || 'local') !== 'local' ? 'linux' : process.platform),
kubeConfig: Input.getInput('kubeConfig') || '',
containerMemory: Input.getInput('containerMemory') || '3072',
containerCpu: Input.getInput('containerCpu') || '1024',
containerNamespace: Input.getInput('containerNamespace') || 'default',
kubeVolumeSize: Input.getInput('kubeVolumeSize') || '25Gi',
kubeVolume: Input.getInput('kubeVolume') || '',
postBuildContainerHooks: Input.getInput('postBuildContainerHooks') || '',
preBuildContainerHooks: Input.getInput('preBuildContainerHooks') || '',
customJob: Input.getInput('customJob') || '',
runNumber: Input.runNumber,
branch: Input.branch.replace('/head', '') || (await GitRepoReader.GetBranch()),
orchestratorBranch: OrchestratorOptions.orchestratorBranch.split('/').reverse()[0],
orchestratorDebug: OrchestratorOptions.orchestratorDebug,
githubRepo: (Input.githubRepo ?? (await GitRepoReader.GetRemote())) || OrchestratorOptions.orchestratorRepoName,
orchestratorRepoName: OrchestratorOptions.orchestratorRepoName,
cloneDepth: Number.parseInt(OrchestratorOptions.cloneDepth),
orchestratorBranch: (Input.getInput('orchestratorBranch') || 'main').split('/').reverse()[0],
orchestratorDebug:
Input.getInput('orchestratorDebug') === 'true' || Input.getInput('orchestratorTests') === 'true',
githubRepo:
(Input.githubRepo ?? (await GitRepoReader.GetRemote())) ||
Input.getInput('orchestratorRepoName') ||
'game-ci/unity-builder',
orchestratorRepoName: Input.getInput('orchestratorRepoName') || 'game-ci/unity-builder',
cloneDepth: Number.parseInt(Input.getInput('cloneDepth') || '50'),
isCliMode: Cli.isCliMode,
awsStackName: OrchestratorOptions.awsStackName,
awsEndpoint: OrchestratorOptions.awsEndpoint,
awsCloudFormationEndpoint: OrchestratorOptions.awsCloudFormationEndpoint,
awsEcsEndpoint: OrchestratorOptions.awsEcsEndpoint,
awsKinesisEndpoint: OrchestratorOptions.awsKinesisEndpoint,
awsCloudWatchLogsEndpoint: OrchestratorOptions.awsCloudWatchLogsEndpoint,
awsS3Endpoint: OrchestratorOptions.awsS3Endpoint,
storageProvider: OrchestratorOptions.storageProvider,
rcloneRemote: OrchestratorOptions.rcloneRemote,
awsStackName: Input.getInput('awsStackName') || 'game-ci',
awsEndpoint: Input.getInput('awsEndpoint'),
awsCloudFormationEndpoint: Input.getInput('awsCloudFormationEndpoint') || Input.getInput('awsEndpoint'),
awsEcsEndpoint: Input.getInput('awsEcsEndpoint') || Input.getInput('awsEndpoint'),
awsKinesisEndpoint: Input.getInput('awsKinesisEndpoint') || Input.getInput('awsEndpoint'),
awsCloudWatchLogsEndpoint: Input.getInput('awsCloudWatchLogsEndpoint') || Input.getInput('awsEndpoint'),
awsS3Endpoint: Input.getInput('awsS3Endpoint') || Input.getInput('awsEndpoint'),
storageProvider: Input.getInput('storageProvider') || 's3',
rcloneRemote: Input.getInput('rcloneRemote') || '',
gitSha: Input.gitSha,
logId: customAlphabet(OrchestratorConstants.alphabet, 9)(),
buildGuid: OrchestratorBuildGuid.generateGuid(Input.runNumber, Input.targetPlatform),
commandHooks: OrchestratorOptions.commandHooks,
inputPullCommand: OrchestratorOptions.inputPullCommand,
pullInputList: OrchestratorOptions.pullInputList,
kubeStorageClass: OrchestratorOptions.kubeStorageClass,
cacheKey: OrchestratorOptions.cacheKey,
maxRetainedWorkspaces: Number.parseInt(OrchestratorOptions.maxRetainedWorkspaces),
useLargePackages: OrchestratorOptions.useLargePackages,
useCompressionStrategy: OrchestratorOptions.useCompressionStrategy,
garbageMaxAge: OrchestratorOptions.garbageMaxAge,
githubChecks: OrchestratorOptions.githubChecks,
asyncWorkflow: OrchestratorOptions.asyncOrchestrator,
githubCheckId: OrchestratorOptions.githubCheckId,
finalHooks: OrchestratorOptions.finalHooks,
skipLfs: OrchestratorOptions.skipLfs,
skipCache: OrchestratorOptions.skipCache,
logId: customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 9)(),
buildGuid: `${Input.runNumber}-${Input.targetPlatform.toLowerCase().replace('standalone', '')}-${customAlphabet(
'0123456789abcdefghijklmnopqrstuvwxyz',
4,
)()}`,
commandHooks: Input.getInput('commandHooks') || '',
inputPullCommand: Input.getInput('inputPullCommand') || '',
pullInputList: (Input.getInput('pullInputList') || '').split(',').filter(Boolean),
kubeStorageClass: Input.getInput('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: Input.getInput('cacheKey') || Input.branch,
maxRetainedWorkspaces: Number.parseInt(Input.getInput('maxRetainedWorkspaces') || '0'),
useLargePackages: Input.getInput('useLargePackages') === 'true',
useCompressionStrategy: Input.getInput('useCompressionStrategy') === 'true',
garbageMaxAge: Number(Input.getInput('garbageMaxAge')) || 24,
githubChecks: Input.getInput('githubChecks') === 'true',
asyncWorkflow: Input.getInput('asyncOrchestrator') === 'true',
githubCheckId: Input.getInput('githubCheckId') || '',
finalHooks: (Input.getInput('finalHooks') || '').split(',').filter(Boolean),
skipLfs: Input.getInput('skipLfs') === 'true',
skipCache: Input.getInput('skipCache') === 'true',
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
submoduleProfilePath: Input.submoduleProfilePath,
submoduleVariantPath: Input.submoduleVariantPath,
submoduleToken: Input.submoduleToken,
localCacheEnabled: Input.localCacheEnabled,
localCacheRoot: Input.localCacheRoot,
localCacheLibrary: Input.localCacheLibrary,
localCacheLfs: Input.localCacheLfs,
childWorkspacesEnabled: Input.childWorkspacesEnabled,
childWorkspaceName: Input.childWorkspaceName,
childWorkspaceCacheRoot: Input.childWorkspaceCacheRoot,
childWorkspacePreserveGit: Input.childWorkspacePreserveGit,
childWorkspaceSeparateLibrary: Input.childWorkspaceSeparateLibrary,
lfsTransferAgent: Input.lfsTransferAgent,
lfsTransferAgentArgs: Input.lfsTransferAgentArgs,
lfsStoragePaths: Input.lfsStoragePaths,
gitHooksEnabled: Input.gitHooksEnabled,
gitHooksSkipList: Input.gitHooksSkipList,
gitHooksRunBeforeBuild: Input.gitHooksRunBeforeBuild,
providerExecutable: Input.providerExecutable,
// 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,
gitIntegrityCheck: Input.gitIntegrityCheck,
gitAutoRecover: Input.gitAutoRecover,
cleanReservedFilenames: Input.cleanReservedFilenames,
buildArchiveEnabled: Input.buildArchiveEnabled,
buildArchivePath: Input.buildArchivePath,
buildArchiveRetention: Input.buildArchiveRetention,
testSuitePath: Input.testSuitePath,
testSuiteEvent: Input.testSuiteEvent,
testTaxonomyPath: Input.testTaxonomyPath,
testResultFormat: Input.testResultFormat,
testResultPath: Input.testResultPath,
hotRunnerEnabled: Input.hotRunnerEnabled,
hotRunnerTransport: Input.hotRunnerTransport,
hotRunnerHost: Input.hotRunnerHost,
hotRunnerPort: Input.hotRunnerPort,
hotRunnerHealthInterval: Input.hotRunnerHealthInterval,
hotRunnerMaxIdle: Input.hotRunnerMaxIdle,
hotRunnerFallbackToCold: Input.hotRunnerFallbackToCold,
artifactOutputTypes: Input.artifactOutputTypes,
artifactUploadTarget: Input.artifactUploadTarget,
artifactUploadPath: Input.artifactUploadPath,
artifactCompression: Input.artifactCompression,
artifactRetentionDays: Input.artifactRetentionDays,
artifactCustomTypes: Input.artifactCustomTypes,
syncStrategy: Input.syncStrategy,
syncInputRef: Input.syncInputRef,
syncStorageRemote: Input.syncStorageRemote,
syncRevertAfter: Input.syncRevertAfter,
syncStatePath: Input.syncStatePath,
};
}

View File

@@ -1,15 +1,8 @@
import { Command } from 'commander-ts';
import { BuildParameters, Orchestrator, ImageTag, Input } from '..';
import { Input } from '..';
import * as core from '@actions/core';
import { ActionYamlReader } from '../input-readers/action-yaml';
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';
import OrchestratorQueryOverride from '../orchestrator/options/orchestrator-query-override';
import { CliFunction, CliFunctionsRepository } from './cli-functions-repository';
import { Caching } from '../orchestrator/remote-client/caching';
import { LfsHashing } from '../orchestrator/services/utility/lfs-hashing';
import { RemoteClient } from '../orchestrator/remote-client';
import OrchestratorOptionsReader from '../orchestrator/options/orchestrator-options-reader';
import GitHub from '../github';
import { OptionValues } from 'commander';
import { InputKey } from '../input';
@@ -30,14 +23,13 @@ export class Cli {
}
public static InitCliMode() {
CliFunctionsRepository.PushCliFunctionSource(RemoteClient);
CliFunctionsRepository.PushCliFunctionSource(Caching);
CliFunctionsRepository.PushCliFunctionSource(LfsHashing);
const program = new Command();
program.version('0.0.1');
const properties = OrchestratorOptionsReader.GetProperties();
const actionYamlReader: ActionYamlReader = new ActionYamlReader();
const properties = Object.getOwnPropertyNames(Input).filter(
(p) => p !== 'length' && p !== 'prototype' && p !== 'name',
);
for (const element of properties) {
program.option(`--${element} <${element}>`, actionYamlReader.GetActionYamlValue(element));
}
@@ -53,6 +45,11 @@ export class Cli {
program.option('--artifactName <artifactName>', 'caching artifact name');
program.option('--select <select>', 'select a particular resource');
program.option('--logFile <logFile>', 'output to log file (log stream only)');
program.option('--profilePath <profilePath>', 'path to submodule profile YAML');
program.option('--variantPath <variantPath>', 'path to submodule variant YAML');
program.option('--agentPath <agentPath>', 'path to custom LFS transfer agent');
program.option('--agentArgs <agentArgs>', 'arguments for custom LFS transfer agent');
program.option('--storagePaths <storagePaths>', 'semicolon-separated storage paths for LFS agent');
program.parse(process.argv);
Cli.options = program.opts();
@@ -60,26 +57,15 @@ export class Cli {
}
static async RunCli(): Promise<void> {
GitHub.githubInputEnabled = false;
if (Cli.options!['populateOverride'] === `true`) {
await OrchestratorQueryOverride.PopulateQueryOverrideInput();
}
if (Cli.options!['logInput']) {
Cli.logInput();
}
const results = CliFunctionsRepository.GetCliFunctions(Cli.options?.mode);
OrchestratorLogger.log(`Entrypoint: ${results.key}`);
if (!results) {
throw new Error(
`Unknown CLI mode: ${Cli.options?.mode}. Orchestrator CLI features require @game-ci/orchestrator.`,
);
}
core.info(`Entrypoint: ${results.key}`);
Cli.options!.versioning = 'None';
Orchestrator.buildParameters = await BuildParameters.create();
Orchestrator.buildParameters.buildGuid = process.env.BUILD_GUID || ``;
OrchestratorLogger.log(`Build Params:
${JSON.stringify(Orchestrator.buildParameters, undefined, 4)}
`);
Orchestrator.lockedWorkspace = process.env.LOCKED_WORKSPACE || ``;
OrchestratorLogger.log(`Locked Workspace: ${Orchestrator.lockedWorkspace}`);
await Orchestrator.setup(Orchestrator.buildParameters);
return await results.target[results.propertyKey](Cli.options);
}
@@ -87,7 +73,9 @@ export class Cli {
private static logInput() {
core.info(`\n`);
core.info(`INPUT:`);
const properties = OrchestratorOptionsReader.GetProperties();
const properties = Object.getOwnPropertyNames(Input).filter(
(p) => p !== 'length' && p !== 'prototype' && p !== 'name',
);
for (const element of properties) {
if (
element in Input &&
@@ -103,73 +91,4 @@ export class Cli {
}
core.info(`\n`);
}
@CliFunction(`cli-build`, `runs a orchestrator build`)
public static async CLIBuild(): Promise<string> {
const buildParameter = await BuildParameters.create();
const baseImage = new ImageTag(buildParameter);
return (await Orchestrator.run(buildParameter, baseImage.toString())).BuildResults;
}
@CliFunction(`async-workflow`, `runs a orchestrator build`)
public static async asyncronousWorkflow(): Promise<string> {
const buildParameter = await BuildParameters.create();
const baseImage = new ImageTag(buildParameter);
await Orchestrator.setup(buildParameter);
return (await Orchestrator.run(buildParameter, baseImage.toString())).BuildResults;
}
@CliFunction(`checks-update`, `runs a orchestrator build`)
public static async checksUpdate() {
const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter);
const input = JSON.parse(process.env.CHECKS_UPDATE || ``);
core.info(`Checks Update ${process.env.CHECKS_UPDATE}`);
if (input.mode === `create`) {
throw new Error(`Not supported: only use update`);
} else if (input.mode === `update`) {
await GitHub.updateGitHubCheckRequest(input.data);
}
}
@CliFunction(`garbage-collect`, `runs garbage collection`)
public static async GarbageCollect(): Promise<string> {
const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter);
return await Orchestrator.Provider.garbageCollect(``, false, 0, false, false);
}
@CliFunction(`list-resources`, `lists active resources`)
public static async ListResources(): Promise<string[]> {
const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter);
const result = await Orchestrator.Provider.listResources();
OrchestratorLogger.log(JSON.stringify(result, undefined, 4));
return result.map((x) => x.Name);
}
@CliFunction(`list-worfklow`, `lists running workflows`)
public static async ListWorfklow(): Promise<string[]> {
const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter);
return (await Orchestrator.Provider.listWorkflow()).map((x) => x.Name);
}
@CliFunction(`watch`, `follows logs of a running workflow`)
public static async Watch(): Promise<string> {
const buildParameter = await BuildParameters.create();
await Orchestrator.setup(buildParameter);
return await Orchestrator.Provider.watchWorkflow();
}
}

View File

@@ -1,222 +1,5 @@
import OrchestratorLogger from './orchestrator/services/core/orchestrator-logger';
import Orchestrator from './orchestrator/orchestrator';
import OrchestratorOptions from './orchestrator/options/orchestrator-options';
import * as core from '@actions/core';
import { Octokit } from '@octokit/core';
class GitHub {
private static readonly asyncChecksApiWorkflowName = `Async Checks API`;
public static githubInputEnabled: boolean = true;
private static longDescriptionContent: string = ``;
private static startedDate: string;
private static endedDate: string;
static result: string = ``;
static forceAsyncTest: boolean;
private static get octokitDefaultToken() {
return new Octokit({
auth: process.env.GITHUB_TOKEN,
});
}
private static get octokitPAT() {
return new Octokit({
auth: Orchestrator.buildParameters.gitPrivateToken,
});
}
private static get sha() {
return Orchestrator.buildParameters.gitSha;
}
private static get checkName() {
return `Orchestrator (${Orchestrator.buildParameters.buildGuid})`;
}
private static get nameReadable() {
return GitHub.checkName;
}
private static get checkRunId() {
return Orchestrator.buildParameters.githubCheckId;
}
private static get owner() {
return OrchestratorOptions.githubOwner;
}
private static get repo() {
return OrchestratorOptions.githubRepoName;
}
public static async createGitHubCheck(summary: string) {
if (!Orchestrator.buildParameters.githubChecks) {
return ``;
}
GitHub.startedDate = new Date().toISOString();
OrchestratorLogger.log(`Creating github check`);
const data = {
owner: GitHub.owner,
repo: GitHub.repo,
name: GitHub.checkName,
// eslint-disable-next-line camelcase
head_sha: GitHub.sha,
status: 'queued',
// eslint-disable-next-line camelcase
external_id: Orchestrator.buildParameters.buildGuid,
// eslint-disable-next-line camelcase
started_at: GitHub.startedDate,
output: {
title: GitHub.nameReadable,
summary,
text: '',
images: [
{
alt: 'Game-CI',
// eslint-disable-next-line camelcase
image_url: 'https://game.ci/assets/images/game-ci-brand-logo-wordmark.svg',
},
],
},
};
const result = await GitHub.createGitHubCheckRequest(data);
OrchestratorLogger.log(`Creating github check ${result.status}`);
return result.data.id.toString();
}
public static async updateGitHubCheck(
longDescription: string,
summary: string,
result = `neutral`,
status = `in_progress`,
) {
if (`${Orchestrator.buildParameters.githubChecks}` !== `true`) {
return;
}
OrchestratorLogger.log(
`githubChecks: ${Orchestrator.buildParameters.githubChecks} checkRunId: ${GitHub.checkRunId} sha: ${GitHub.sha} async: ${Orchestrator.isOrchestratorAsyncEnvironment}`,
);
GitHub.longDescriptionContent += `\n${longDescription}`;
if (GitHub.result !== `success` && GitHub.result !== `failure`) {
GitHub.result = result;
} else {
result = GitHub.result;
}
const data: any = {
owner: GitHub.owner,
repo: GitHub.repo,
// eslint-disable-next-line camelcase
check_run_id: GitHub.checkRunId,
name: GitHub.checkName,
// eslint-disable-next-line camelcase
head_sha: GitHub.sha,
// eslint-disable-next-line camelcase
started_at: GitHub.startedDate,
status,
output: {
title: GitHub.nameReadable,
summary,
text: GitHub.longDescriptionContent,
annotations: [],
},
};
if (status === `completed`) {
if (GitHub.endedDate !== undefined) {
GitHub.endedDate = new Date().toISOString();
}
// eslint-disable-next-line camelcase
data.completed_at = GitHub.endedDate || GitHub.startedDate;
data.conclusion = result;
}
await (Orchestrator.isOrchestratorAsyncEnvironment || GitHub.forceAsyncTest
? GitHub.runUpdateAsyncChecksWorkflow(data, `update`)
: GitHub.updateGitHubCheckRequest(data));
}
public static async updateGitHubCheckRequest(data: any) {
return await GitHub.octokitDefaultToken.request(`PATCH /repos/{owner}/{repo}/check-runs/{check_run_id}`, data);
}
public static async createGitHubCheckRequest(data: any) {
return await GitHub.octokitDefaultToken.request(`POST /repos/{owner}/{repo}/check-runs`, data);
}
public static async runUpdateAsyncChecksWorkflow(data: any, mode: string) {
if (mode === `create`) {
throw new Error(`Not supported: only use update`);
}
const workflowsResult = await GitHub.octokitPAT.request(`GET /repos/{owner}/{repo}/actions/workflows`, {
owner: GitHub.owner,
repo: GitHub.repo,
});
const workflows = workflowsResult.data.workflows;
OrchestratorLogger.log(`Got ${workflows.length} workflows`);
let selectedId = ``;
for (let index = 0; index < workflowsResult.data.total_count; index++) {
if (workflows[index].name === GitHub.asyncChecksApiWorkflowName) {
selectedId = workflows[index].id.toString();
}
}
if (selectedId === ``) {
core.info(JSON.stringify(workflows));
throw new Error(`no workflow with name "${GitHub.asyncChecksApiWorkflowName}"`);
}
await GitHub.octokitPAT.request(`POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`, {
owner: GitHub.owner,
repo: GitHub.repo,
// eslint-disable-next-line camelcase
workflow_id: selectedId,
ref: OrchestratorOptions.branch,
inputs: {
checksObject: JSON.stringify({ data, mode }),
},
});
}
static async triggerWorkflowOnComplete(triggerWorkflowOnComplete: string[]) {
const isLocalAsync = Orchestrator.buildParameters.asyncWorkflow && !Orchestrator.isOrchestratorAsyncEnvironment;
if (isLocalAsync || triggerWorkflowOnComplete === undefined || triggerWorkflowOnComplete.length === 0) {
return;
}
try {
const workflowsResult = await GitHub.octokitPAT.request(`GET /repos/{owner}/{repo}/actions/workflows`, {
owner: GitHub.owner,
repo: GitHub.repo,
});
const workflows = workflowsResult.data.workflows;
OrchestratorLogger.log(`Got ${workflows.length} workflows`);
for (const element of triggerWorkflowOnComplete) {
let selectedId = ``;
for (let index = 0; index < workflowsResult.data.total_count; index++) {
if (workflows[index].name === element) {
selectedId = workflows[index].id.toString();
}
}
if (selectedId === ``) {
core.info(JSON.stringify(workflows));
throw new Error(`no workflow with name "${GitHub.asyncChecksApiWorkflowName}"`);
}
await GitHub.octokitPAT.request(`POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`, {
owner: GitHub.owner,
repo: GitHub.repo,
// eslint-disable-next-line camelcase
workflow_id: selectedId,
ref: OrchestratorOptions.branch,
inputs: {
buildGuid: Orchestrator.buildParameters.buildGuid,
},
});
}
} catch {
core.info(`github workflow complete hook not found`);
}
}
public static async getCheckStatus() {
return await GitHub.octokitDefaultToken.request(`GET /repos/{owner}/{repo}/check-runs/{check_run_id}`);
}
}
export default GitHub;

View File

@@ -9,22 +9,5 @@ import Platform from './platform';
import Project from './project';
import Unity from './unity';
import Versioning from './versioning';
import Orchestrator from './orchestrator/orchestrator';
import loadProvider, { ProviderLoader } from './orchestrator/providers/provider-loader';
export {
Action,
BuildParameters,
Cache,
Docker,
Input,
ImageTag,
Output,
Platform,
Project,
Unity,
Versioning,
Orchestrator as Orchestrator,
loadProvider,
ProviderLoader,
};
export { Action, BuildParameters, Cache, Docker, Input, ImageTag, Output, Platform, Project, Unity, Versioning };

View File

@@ -1,12 +1,21 @@
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
import { exec } from 'node:child_process';
import Input from '../input';
export class GenericInputReader {
public static async Run(command: string) {
if (OrchestratorOptions.providerStrategy === 'local') {
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
return '';
}
return await OrchestratorSystem.Run(command, false, true);
return new Promise<string>((resolve, reject) => {
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout.toString());
});
});
}
}

View File

@@ -1,6 +1,5 @@
import { GitRepoReader } from './git-repo';
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
import Input from '../input';
describe(`git repo tests`, () => {
it(`Branch value parsed from CLI to not contain illegal characters`, async () => {
@@ -10,15 +9,15 @@ describe(`git repo tests`, () => {
it(`returns valid branch name when using https`, async () => {
const mockValue = 'https://github.com/example/example.git';
await jest.spyOn(OrchestratorSystem, 'Run').mockReturnValue(Promise.resolve(mockValue));
await jest.spyOn(OrchestratorOptions, 'providerStrategy', 'get').mockReturnValue('not-local');
jest.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
jest.spyOn(Input, 'getInput').mockReturnValue('not-local');
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
});
it(`returns valid branch name when using ssh`, async () => {
const mockValue = 'git@github.com:example/example.git';
await jest.spyOn(OrchestratorSystem, 'Run').mockReturnValue(Promise.resolve(mockValue));
await jest.spyOn(OrchestratorOptions, 'providerStrategy', 'get').mockReturnValue('not-local');
jest.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
jest.spyOn(Input, 'getInput').mockReturnValue('not-local');
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
});
});

View File

@@ -1,33 +1,42 @@
import { assert } from 'node:console';
import fs from 'node:fs';
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
import { exec } from 'node:child_process';
import * as core from '@actions/core';
import Input from '../input';
export class GitRepoReader {
private static async runCommand(command: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout.toString());
});
});
}
public static async GetRemote() {
if (OrchestratorOptions.providerStrategy === 'local') {
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
return '';
}
assert(fs.existsSync(`.git`));
const value = (await OrchestratorSystem.Run(`cd ${Input.projectPath} && git remote -v`, false, true)).replace(
/ /g,
``,
);
OrchestratorLogger.log(`value ${value}`);
const value = (await GitRepoReader.runCommand(`cd ${Input.projectPath} && git remote -v`)).replace(/ /g, ``);
core.info(`value ${value}`);
assert(value.includes('github.com'));
return value.split('github.com')[1].split('.git')[0].slice(1);
}
public static async GetBranch() {
if (OrchestratorOptions.providerStrategy === 'local') {
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
return '';
}
assert(fs.existsSync(`.git`));
return (await OrchestratorSystem.Run(`cd ${Input.projectPath} && git branch --show-current`, false, true))
return (await GitRepoReader.runCommand(`cd ${Input.projectPath} && git branch --show-current`))
.split('\n')[0]
.replace(/ /g, ``)
.replace('/head', '');

View File

@@ -1,19 +1,32 @@
import { OrchestratorSystem } from '../orchestrator/services/core/orchestrator-system';
import { exec } from 'node:child_process';
import * as core from '@actions/core';
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
import Input from '../input';
export class GithubCliReader {
private static async runCommand(command: string, suppressError = false): Promise<string> {
return new Promise<string>((resolve, reject) => {
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout, stderr) => {
if (error && !suppressError) {
reject(error);
return;
}
resolve((stdout || '').toString() + (stderr || '').toString());
});
});
}
static async GetGitHubAuthToken() {
if (OrchestratorOptions.providerStrategy === 'local') {
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
return '';
}
try {
const authStatus = await OrchestratorSystem.Run(`gh auth status`, true, true);
const authStatus = await GithubCliReader.runCommand(`gh auth status`, true);
if (authStatus.includes('You are not logged') || authStatus === '') {
return '';
}
return (await OrchestratorSystem.Run(`gh auth status -t`, false, true))
return (await GithubCliReader.runCommand(`gh auth status -t`))
.split(`Token: `)[1]
.replace(/ /g, '')
.replace(/\n/g, '');

View File

@@ -1,10 +1,10 @@
import path from 'node:path';
import fs from 'node:fs';
import YAML from 'yaml';
import OrchestratorOptions from '../orchestrator/options/orchestrator-options';
import Input from '../input';
export function ReadLicense(): string {
if (OrchestratorOptions.providerStrategy === 'local') {
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
return '';
}
const pipelineFile = path.join(__dirname, `.github`, `workflows`, `orchestrator-k8s-pipeline.yml`);

View File

@@ -1,7 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { Cli } from './cli/cli';
import OrchestratorQueryOverride from './orchestrator/options/orchestrator-query-override';
import Platform from './platform';
import GitHub from './github';
import os from 'node:os';
@@ -32,10 +31,6 @@ class Input {
return Cli.query(query, alternativeQuery);
}
if (OrchestratorQueryOverride.query(query, alternativeQuery)) {
return OrchestratorQueryOverride.query(query, alternativeQuery);
}
if (process.env[query] !== undefined) {
return process.env[query]!;
}
@@ -241,6 +236,28 @@ class Input {
return Input.getInput('dockerWorkspacePath') ?? '/github/workspace';
}
static get syncStrategy(): string {
return Input.getInput('syncStrategy') ?? 'full';
}
static get syncInputRef(): string {
return Input.getInput('syncInputRef') ?? '';
}
static get syncStorageRemote(): string {
return Input.getInput('syncStorageRemote') ?? '';
}
static get syncRevertAfter(): boolean {
const input = Input.getInput('syncRevertAfter') ?? 'true';
return input === 'true';
}
static get syncStatePath(): string {
return Input.getInput('syncStatePath') ?? '.game-ci/sync-state.json';
}
static get dockerCpuLimit(): string {
return Input.getInput('dockerCpuLimit') ?? os.cpus().length.toString();
}
@@ -278,10 +295,356 @@ class Input {
return Input.getInput('containerRegistryImageVersion') ?? '3';
}
static get artifactOutputTypes(): string {
return Input.getInput('artifactOutputTypes') ?? 'build,logs,test-results';
}
static get artifactUploadTarget(): string {
return Input.getInput('artifactUploadTarget') ?? 'github-artifacts';
}
static get artifactUploadPath(): string {
return Input.getInput('artifactUploadPath') ?? '';
}
static get artifactCompression(): string {
return Input.getInput('artifactCompression') ?? 'gzip';
}
static get artifactRetentionDays(): string {
return Input.getInput('artifactRetentionDays') ?? '30';
}
static get artifactCustomTypes(): string {
return Input.getInput('artifactCustomTypes') ?? '';
}
static get skipActivation(): string {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
static get submoduleProfilePath(): string {
return Input.getInput('submoduleProfilePath') ?? '';
}
static get submoduleVariantPath(): string {
return Input.getInput('submoduleVariantPath') ?? '';
}
static get submoduleToken(): string {
return Input.getInput('submoduleToken') ?? '';
}
static get localCacheEnabled(): boolean {
return (Input.getInput('localCacheEnabled') ?? 'false') === 'true';
}
static get localCacheRoot(): string {
return Input.getInput('localCacheRoot') ?? '';
}
static get localCacheLibrary(): boolean {
return (Input.getInput('localCacheLibrary') ?? 'true') === 'true';
}
static get localCacheLfs(): boolean {
return (Input.getInput('localCacheLfs') ?? 'false') === 'true';
}
static get childWorkspacesEnabled(): boolean {
return (Input.getInput('childWorkspacesEnabled') ?? 'false') === 'true';
}
static get childWorkspaceName(): string {
return Input.getInput('childWorkspaceName') ?? '';
}
static get childWorkspaceCacheRoot(): string {
return Input.getInput('childWorkspaceCacheRoot') ?? '';
}
static get childWorkspacePreserveGit(): boolean {
return (Input.getInput('childWorkspacePreserveGit') ?? 'true') === 'true';
}
static get childWorkspaceSeparateLibrary(): boolean {
return (Input.getInput('childWorkspaceSeparateLibrary') ?? 'true') === 'true';
}
static get lfsTransferAgent(): string {
return Input.getInput('lfsTransferAgent') ?? '';
}
static get lfsTransferAgentArgs(): string {
return Input.getInput('lfsTransferAgentArgs') ?? '';
}
static get lfsStoragePaths(): string {
return Input.getInput('lfsStoragePaths') ?? '';
}
static get gitHooksEnabled(): boolean {
return (Input.getInput('gitHooksEnabled') ?? 'false') === 'true';
}
static get gitHooksSkipList(): string {
return Input.getInput('gitHooksSkipList') ?? '';
}
static get gitHooksRunBeforeBuild(): string {
return Input.getInput('gitHooksRunBeforeBuild') ?? '';
}
static get providerExecutable(): string {
return Input.getInput('providerExecutable') ?? '';
}
// 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') ?? '';
}
static get gitIntegrityCheck(): boolean {
const input = Input.getInput('gitIntegrityCheck') ?? 'false';
return input === 'true';
}
static get hotRunnerEnabled(): boolean {
const input = Input.getInput('hotRunnerEnabled') ?? false;
return input === 'true';
}
static get gitAutoRecover(): boolean {
const input = Input.getInput('gitAutoRecover') ?? 'false';
return input === 'true';
}
static get hotRunnerTransport(): 'websocket' | 'grpc' | 'named-pipe' {
return (Input.getInput('hotRunnerTransport') ?? 'websocket') as 'websocket' | 'grpc' | 'named-pipe';
}
static get hotRunnerHost(): string {
return Input.getInput('hotRunnerHost') ?? 'localhost';
}
static get hotRunnerPort(): number {
return Number.parseInt(Input.getInput('hotRunnerPort') ?? '9090', 10);
}
static get hotRunnerHealthInterval(): number {
return Number.parseInt(Input.getInput('hotRunnerHealthInterval') ?? '30', 10);
}
static get hotRunnerMaxIdle(): number {
return Number.parseInt(Input.getInput('hotRunnerMaxIdle') ?? '3600', 10);
}
static get hotRunnerFallbackToCold(): boolean {
const input = Input.getInput('hotRunnerFallbackToCold') ?? 'true';
return input === 'true';
}
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);
}
static get testSuitePath(): string {
return Input.getInput('testSuitePath') ?? '';
}
static get testSuiteEvent(): string {
return Input.getInput('testSuiteEvent') ?? '';
}
static get testTaxonomyPath(): string {
return Input.getInput('testTaxonomyPath') ?? '';
}
static get testResultFormat(): string {
return Input.getInput('testResultFormat') ?? 'junit';
}
static get testResultPath(): string {
return Input.getInput('testResultPath') ?? './test-results';
}
public static ToEnvVarFormat(input: string) {
if (input.toUpperCase() === input) {
return input;

View File

@@ -0,0 +1,285 @@
/**
* Tests for the orchestrator plugin interface (orchestrator-plugin.ts).
*
* The plugin acts as a dynamic bridge to @game-ci/orchestrator, which is an
* optional dependency. Two scenarios exist:
*
* 1. Package NOT installed (the natural state in unity-builder) -- both
* loadOrchestrator() and loadPluginServices() must degrade gracefully.
*
* 2. Package IS installed (mocked) -- the returned wrappers must faithfully
* forward calls and map results.
*/
// Mock @actions/core so we can inspect core.warning calls even after
// jest.resetModules() re-imports orchestrator-plugin (which statically
// imports @actions/core at the top level).
const mockWarning = jest.fn();
jest.mock('@actions/core', () => ({
warning: mockWarning,
}));
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
beforeEach(() => {
jest.resetModules();
mockWarning.mockClear();
});
// ---------------------------------------------------------------------------
// Part 1: Package NOT installed (natural state)
// ---------------------------------------------------------------------------
describe('orchestrator-plugin (package not installed)', () => {
it('loadOrchestrator() returns undefined', async () => {
const { loadOrchestrator } = await import('./orchestrator-plugin');
const result = await loadOrchestrator();
expect(result).toBeUndefined();
});
it('loadPluginServices() returns undefined and logs a warning', async () => {
const { loadPluginServices } = await import('./orchestrator-plugin');
const result = await loadPluginServices();
expect(result).toBeUndefined();
expect(mockWarning).toHaveBeenCalledTimes(1);
expect(mockWarning).toHaveBeenCalledWith(expect.stringContaining('Orchestrator plugin not available'));
});
});
// ---------------------------------------------------------------------------
// Part 2: Package IS installed (mocked)
// ---------------------------------------------------------------------------
describe('orchestrator-plugin (package installed)', () => {
// Fake service sentinels -- unique objects so we can assert identity.
const fakeBuildReliabilityService = { _id: 'BuildReliabilityService' };
const fakeTestWorkflowService = { _id: 'TestWorkflowService' };
const fakeHotRunnerService = { _id: 'HotRunnerService' };
const fakeOutputService = { _id: 'OutputService' };
const fakeOutputTypeRegistry = { _id: 'OutputTypeRegistry' };
const fakeArtifactUploadHandler = { _id: 'ArtifactUploadHandler' };
const fakeIncrementalSyncService = { _id: 'IncrementalSyncService' };
const fakeChildWorkspaceService = { _id: 'ChildWorkspaceService' };
const fakeLocalCacheService = { _id: 'LocalCacheService' };
const fakeSubmoduleProfileService = { _id: 'SubmoduleProfileService' };
const fakeLfsAgentService = { _id: 'LfsAgentService' };
const fakeGitHooksService = { _id: 'GitHooksService' };
const mockOrchestratorRun = jest.fn();
/**
* Install the mock BEFORE importing orchestrator-plugin so that the dynamic
* import('@game-ci/orchestrator') inside loadOrchestrator / loadPluginServices
* resolves to our fake module.
*
* The { virtual: true } flag is required because @game-ci/orchestrator is
* not physically installed in unity-builder's node_modules.
*/
function installOrchestratorMock(overrides: Record<string, unknown> = {}) {
jest.doMock(
'@game-ci/orchestrator',
() => ({
Orchestrator: { run: mockOrchestratorRun },
BuildReliabilityService: fakeBuildReliabilityService,
TestWorkflowService: fakeTestWorkflowService,
HotRunnerService: fakeHotRunnerService,
OutputService: fakeOutputService,
OutputTypeRegistry: fakeOutputTypeRegistry,
ArtifactUploadHandler: fakeArtifactUploadHandler,
IncrementalSyncService: fakeIncrementalSyncService,
ChildWorkspaceService: fakeChildWorkspaceService,
LocalCacheService: fakeLocalCacheService,
SubmoduleProfileService: fakeSubmoduleProfileService,
LfsAgentService: fakeLfsAgentService,
GitHooksService: fakeGitHooksService,
...overrides,
}),
{ virtual: true },
);
}
beforeEach(() => {
mockOrchestratorRun.mockReset();
});
// -----------------------------------------------------------------------
// loadOrchestrator()
// -----------------------------------------------------------------------
describe('loadOrchestrator()', () => {
it('returns an object with a run function', async () => {
installOrchestratorMock();
const { loadOrchestrator } = await import('./orchestrator-plugin');
const orchestrator = await loadOrchestrator();
expect(orchestrator).toBeDefined();
expect(typeof orchestrator!.run).toBe('function');
});
it('run() maps BuildSucceeded=true to exitCode=0', async () => {
mockOrchestratorRun.mockResolvedValue({ BuildSucceeded: true, BuildResults: 'ok' });
installOrchestratorMock();
const { loadOrchestrator } = await import('./orchestrator-plugin');
const orchestrator = await loadOrchestrator();
const result = await orchestrator!.run({}, 'ubuntu:latest');
expect(result.exitCode).toBe(0);
expect(result.BuildSucceeded).toBe(true);
});
it('run() maps BuildSucceeded=false to exitCode=1', async () => {
mockOrchestratorRun.mockResolvedValue({ BuildSucceeded: false, BuildResults: 'fail' });
installOrchestratorMock();
const { loadOrchestrator } = await import('./orchestrator-plugin');
const orchestrator = await loadOrchestrator();
const result = await orchestrator!.run({}, 'ubuntu:latest');
expect(result.exitCode).toBe(1);
expect(result.BuildSucceeded).toBe(false);
});
it('run() passes buildParameters and baseImage to Orchestrator.run', async () => {
const buildParameters = { targetPlatform: 'StandaloneLinux64', editorVersion: '2021.3.1f1' };
const baseImage = 'unityci/editor:2021.3.1f1-linux-il2cpp-1';
mockOrchestratorRun.mockResolvedValue({ BuildSucceeded: true, BuildResults: '' });
installOrchestratorMock();
const { loadOrchestrator } = await import('./orchestrator-plugin');
const orchestrator = await loadOrchestrator();
await orchestrator!.run(buildParameters, baseImage);
expect(mockOrchestratorRun).toHaveBeenCalledTimes(1);
expect(mockOrchestratorRun).toHaveBeenCalledWith(buildParameters, baseImage);
});
});
// -----------------------------------------------------------------------
// loadPluginServices()
// -----------------------------------------------------------------------
describe('loadPluginServices()', () => {
it('returns all 7 eager services', async () => {
installOrchestratorMock();
const { loadPluginServices } = await import('./orchestrator-plugin');
const services = await loadPluginServices();
expect(services).toBeDefined();
expect(services!.BuildReliabilityService).toBe(fakeBuildReliabilityService);
expect(services!.TestWorkflowService).toBe(fakeTestWorkflowService);
expect(services!.HotRunnerService).toBe(fakeHotRunnerService);
expect(services!.OutputService).toBe(fakeOutputService);
expect(services!.OutputTypeRegistry).toBe(fakeOutputTypeRegistry);
expect(services!.ArtifactUploadHandler).toBe(fakeArtifactUploadHandler);
expect(services!.IncrementalSyncService).toBe(fakeIncrementalSyncService);
});
it('returns all 5 lazy loader functions', async () => {
installOrchestratorMock();
const { loadPluginServices } = await import('./orchestrator-plugin');
const services = await loadPluginServices();
expect(services).toBeDefined();
expect(typeof services!.loadChildWorkspaceService).toBe('function');
expect(typeof services!.loadLocalCacheService).toBe('function');
expect(typeof services!.loadSubmoduleProfileService).toBe('function');
expect(typeof services!.loadLfsAgentService).toBe('function');
expect(typeof services!.loadGitHooksService).toBe('function');
});
it('loadChildWorkspaceService() returns the correct service', async () => {
installOrchestratorMock();
const { loadPluginServices } = await import('./orchestrator-plugin');
const services = await loadPluginServices();
const service = await services!.loadChildWorkspaceService();
expect(service).toBe(fakeChildWorkspaceService);
});
it('loadLocalCacheService() returns the correct service', async () => {
installOrchestratorMock();
const { loadPluginServices } = await import('./orchestrator-plugin');
const services = await loadPluginServices();
const service = await services!.loadLocalCacheService();
expect(service).toBe(fakeLocalCacheService);
});
it('loadSubmoduleProfileService() returns the correct service', async () => {
installOrchestratorMock();
const { loadPluginServices } = await import('./orchestrator-plugin');
const services = await loadPluginServices();
const service = await services!.loadSubmoduleProfileService();
expect(service).toBe(fakeSubmoduleProfileService);
});
it('loadLfsAgentService() returns the correct service', async () => {
installOrchestratorMock();
const { loadPluginServices } = await import('./orchestrator-plugin');
const services = await loadPluginServices();
const service = await services!.loadLfsAgentService();
expect(service).toBe(fakeLfsAgentService);
});
it('loadGitHooksService() returns the correct service', async () => {
installOrchestratorMock();
const { loadPluginServices } = await import('./orchestrator-plugin');
const services = await loadPluginServices();
const service = await services!.loadGitHooksService();
expect(service).toBe(fakeGitHooksService);
});
});
// -----------------------------------------------------------------------
// Error handling
// -----------------------------------------------------------------------
describe('error handling', () => {
it('propagates errors thrown by Orchestrator.run()', async () => {
const orchestratorError = new Error('Build infrastructure failure');
mockOrchestratorRun.mockRejectedValue(orchestratorError);
installOrchestratorMock();
const { loadOrchestrator } = await import('./orchestrator-plugin');
const orchestrator = await loadOrchestrator();
await expect(orchestrator!.run({}, 'ubuntu:latest')).rejects.toThrow('Build infrastructure failure');
});
it('returns undefined services as-is when a service export is undefined', async () => {
installOrchestratorMock({
BuildReliabilityService: undefined,
ChildWorkspaceService: undefined,
});
const { loadPluginServices } = await import('./orchestrator-plugin');
const services = await loadPluginServices();
expect(services).toBeDefined();
expect(services!.BuildReliabilityService).toBeUndefined();
// The lazy loader still works -- it just returns undefined
const childService = await services!.loadChildWorkspaceService();
expect(childService).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,97 @@
import * as core from '@actions/core';
export interface OrchestratorPluginResult {
exitCode: number;
BuildSucceeded: boolean;
}
/**
* Load the orchestrator for remote builds.
* Returns undefined if orchestrator is not available.
*/
export async function loadOrchestrator(): Promise<
| {
// eslint-disable-next-line no-unused-vars
run: (buildParameters: any, baseImage: string) => Promise<OrchestratorPluginResult>;
}
| undefined
> {
try {
// eslint-disable-next-line import/no-unresolved
const { Orchestrator } = await import('@game-ci/orchestrator');
return {
run: async (buildParameters: any, baseImage: string): Promise<OrchestratorPluginResult> => {
const result = await Orchestrator.run(buildParameters, baseImage);
return {
exitCode: result.BuildSucceeded ? 0 : 1,
BuildSucceeded: result.BuildSucceeded,
};
},
};
} catch (error) {
if (!isModuleNotFoundError(error)) {
throw error;
}
}
}
/**
* Load orchestrator plugin services for local builds.
* These services are part of the orchestrator but also used in local builds
* (child workspaces, local cache, git hooks, LFS agents, etc.).
*/
export async function loadPluginServices() {
try {
// eslint-disable-next-line import/no-unresolved
const orchestrator = await import('@game-ci/orchestrator');
return {
BuildReliabilityService: orchestrator.BuildReliabilityService,
TestWorkflowService: orchestrator.TestWorkflowService,
HotRunnerService: orchestrator.HotRunnerService,
OutputService: orchestrator.OutputService,
OutputTypeRegistry: orchestrator.OutputTypeRegistry,
ArtifactUploadHandler: orchestrator.ArtifactUploadHandler,
IncrementalSyncService: orchestrator.IncrementalSyncService,
// Lazy-loaded services (only imported when needed)
async loadChildWorkspaceService() {
return orchestrator.ChildWorkspaceService;
},
async loadLocalCacheService() {
return orchestrator.LocalCacheService;
},
async loadSubmoduleProfileService() {
return orchestrator.SubmoduleProfileService;
},
async loadLfsAgentService() {
return orchestrator.LfsAgentService;
},
async loadGitHooksService() {
return orchestrator.GitHooksService;
},
};
} catch (error) {
if (!isModuleNotFoundError(error)) {
throw error;
}
core.warning(`Orchestrator plugin not available: ${(error as Error).message}`);
}
}
function isModuleNotFoundError(error: unknown): boolean {
if (error && typeof error === 'object' && 'code' in error) {
const code = (error as { code: string }).code;
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
return true;
}
}
return typeof (error as Error)?.message === 'string' && /cannot find module/i.test((error as Error).message);
}

View File

@@ -1,15 +0,0 @@
import OrchestratorLogger from '../services/core/orchestrator-logger';
import * as core from '@actions/core';
import Orchestrator from '../orchestrator';
import OrchestratorSecret from '../options/orchestrator-secret';
import BuildParameters from '../../build-parameters';
export class OrchestratorError {
public static async handleException(error: unknown, buildParameters: BuildParameters, secrets: OrchestratorSecret[]) {
OrchestratorLogger.error(JSON.stringify(error, undefined, 4));
core.setFailed('Orchestrator failed');
if (Orchestrator.Provider !== undefined) {
await Orchestrator.Provider.cleanupWorkflow(buildParameters, buildParameters.branch, secrets);
}
}
}

View File

@@ -1,4 +0,0 @@
class OrchestratorConstants {
static alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
}
export default OrchestratorConstants;

View File

@@ -1,5 +0,0 @@
class OrchestratorEnvironmentVariable {
public name!: string;
public value!: string;
}
export default OrchestratorEnvironmentVariable;

View File

@@ -1,90 +0,0 @@
import path from 'node:path';
import OrchestratorOptions from './orchestrator-options';
import Orchestrator from '../orchestrator';
import BuildParameters from '../../build-parameters';
export class OrchestratorFolders {
public static readonly repositoryFolder = 'repo';
public static ToLinuxFolder(folder: string) {
return folder.replace(/\\/g, `/`);
}
// Only the following paths that do not start a path.join with another "Full" suffixed property need to start with an absolute /
public static get uniqueOrchestratorJobFolderAbsolute(): string {
return Orchestrator.buildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(Orchestrator.buildParameters)
? path.join(`/`, OrchestratorFolders.buildVolumeFolder, Orchestrator.lockedWorkspace)
: path.join(`/`, OrchestratorFolders.buildVolumeFolder, Orchestrator.buildParameters.buildGuid);
}
public static get cacheFolderForAllFull(): string {
return path.join('/', OrchestratorFolders.buildVolumeFolder, OrchestratorFolders.cacheFolder);
}
public static get cacheFolderForCacheKeyFull(): string {
return path.join(
'/',
OrchestratorFolders.buildVolumeFolder,
OrchestratorFolders.cacheFolder,
Orchestrator.buildParameters.cacheKey,
);
}
public static get builderPathAbsolute(): string {
return path.join(
OrchestratorOptions.useSharedBuilder
? `/${OrchestratorFolders.buildVolumeFolder}`
: OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute,
`builder`,
);
}
public static get repoPathAbsolute(): string {
return path.join(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute, OrchestratorFolders.repositoryFolder);
}
public static get projectPathAbsolute(): string {
return path.join(OrchestratorFolders.repoPathAbsolute, Orchestrator.buildParameters.projectPath);
}
public static get libraryFolderAbsolute(): string {
return path.join(OrchestratorFolders.projectPathAbsolute, `Library`);
}
public static get projectBuildFolderAbsolute(): string {
return path.join(OrchestratorFolders.repoPathAbsolute, Orchestrator.buildParameters.buildPath);
}
public static get lfsFolderAbsolute(): string {
return path.join(OrchestratorFolders.repoPathAbsolute, `.git`, `lfs`);
}
public static get purgeRemoteCaching(): boolean {
return process.env.PURGE_REMOTE_BUILDER_CACHE !== undefined;
}
public static get lfsCacheFolderFull() {
return path.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `lfs`);
}
public static get libraryCacheFolderFull() {
return path.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `Library`);
}
public static get unityBuilderRepoUrl(): string {
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
}
public static get targetBuildRepoUrl(): string {
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.githubRepo}.git`;
}
public static get buildVolumeFolder() {
return 'data';
}
public static get cacheFolder() {
return 'cache';
}
}

View File

@@ -1,11 +0,0 @@
import { customAlphabet } from 'nanoid';
import OrchestratorConstants from './orchestrator-constants';
class OrchestratorNamespace {
static generateGuid(runNumber: string | number, platform: string) {
const nanoid = customAlphabet(OrchestratorConstants.alphabet, 4);
return `${runNumber}-${platform.toLowerCase().replace('standalone', '')}-${nanoid()}`;
}
}
export default OrchestratorNamespace;

View File

@@ -1,10 +0,0 @@
import Input from '../../input';
import OrchestratorOptions from './orchestrator-options';
class OrchestratorOptionsReader {
static GetProperties() {
return [...Object.getOwnPropertyNames(Input), ...Object.getOwnPropertyNames(OrchestratorOptions)];
}
}
export default OrchestratorOptionsReader;

View File

@@ -1,338 +0,0 @@
import { Cli } from '../../cli/cli';
import OrchestratorQueryOverride from './orchestrator-query-override';
import GitHub from '../../github';
import * as core from '@actions/core';
class OrchestratorOptions {
// ### ### ###
// Input Handling
// ### ### ###
public static getInput(query: string): string | undefined {
if (GitHub.githubInputEnabled) {
const coreInput = core.getInput(query);
if (coreInput && coreInput !== '') {
return coreInput;
}
}
const alternativeQuery = OrchestratorOptions.ToEnvVarFormat(query);
// Query input sources
if (Cli.query(query, alternativeQuery)) {
return Cli.query(query, alternativeQuery);
}
if (OrchestratorQueryOverride.query(query, alternativeQuery)) {
return OrchestratorQueryOverride.query(query, alternativeQuery);
}
if (process.env[query] !== undefined) {
return process.env[query];
}
if (alternativeQuery !== query && process.env[alternativeQuery] !== undefined) {
return process.env[alternativeQuery];
}
}
public static ToEnvVarFormat(input: string): string {
if (input.toUpperCase() === input) {
return input;
}
return input
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
.replace(/ /g, '_');
}
// ### ### ###
// Provider parameters
// ### ### ###
static get region(): string {
return OrchestratorOptions.getInput('region') || 'eu-west-2';
}
// ### ### ###
// GitHub parameters
// ### ### ###
static get githubChecks(): boolean {
const value = OrchestratorOptions.getInput('githubChecks');
return value === `true` || false;
}
static get githubCheckId(): string {
return OrchestratorOptions.getInput('githubCheckId') || ``;
}
static get githubOwner(): string {
return OrchestratorOptions.getInput('githubOwner') || OrchestratorOptions.githubRepo?.split(`/`)[0] || '';
}
static get githubRepoName(): string {
return OrchestratorOptions.getInput('githubRepoName') || OrchestratorOptions.githubRepo?.split(`/`)[1] || '';
}
static get orchestratorRepoName(): string {
return OrchestratorOptions.getInput('orchestratorRepoName') || 'game-ci/unity-builder';
}
static get cloneDepth(): string {
return OrchestratorOptions.getInput('cloneDepth') || '50';
}
static get finalHooks(): string[] {
return OrchestratorOptions.getInput('finalHooks')?.split(',') || [];
}
// ### ### ###
// Git syncronization parameters
// ### ### ###
static get githubRepo(): string | undefined {
return (
OrchestratorOptions.getInput('GITHUB_REPOSITORY') || OrchestratorOptions.getInput('GITHUB_REPO') || undefined
);
}
static get branch(): string {
if (OrchestratorOptions.getInput(`GITHUB_REF`)) {
return (
OrchestratorOptions.getInput(`GITHUB_REF`)?.replace('refs/', '').replace(`head/`, '').replace(`heads/`, '') ||
``
);
} else if (OrchestratorOptions.getInput('branch')) {
return OrchestratorOptions.getInput('branch') || ``;
} else {
return '';
}
}
// ### ### ###
// Orchestrator parameters
// ### ### ###
static get buildPlatform(): string {
const input = OrchestratorOptions.getInput('buildPlatform');
if (input && input !== '') {
return input;
}
if (OrchestratorOptions.providerStrategy !== 'local') {
return 'linux';
}
return process.platform;
}
static get orchestratorBranch(): string {
return OrchestratorOptions.getInput('orchestratorBranch') || 'main';
}
static get providerStrategy(): string {
const provider =
OrchestratorOptions.getInput('orchestratorCluster') || OrchestratorOptions.getInput('providerStrategy');
if (Cli.isCliMode) {
return provider || 'aws';
}
return provider || 'local';
}
static get containerCpu(): string {
return OrchestratorOptions.getInput('containerCpu') || `1024`;
}
static get containerMemory(): string {
return OrchestratorOptions.getInput('containerMemory') || `3072`;
}
static get containerNamespace(): string {
return OrchestratorOptions.getInput('containerNamespace') || `default`;
}
static get customJob(): string {
return OrchestratorOptions.getInput('customJob') || '';
}
// ### ### ###
// Custom commands from files parameters
// ### ### ###
static get containerHookFiles(): string[] {
return OrchestratorOptions.getInput('containerHookFiles')?.split(`,`) || [];
}
static get commandHookFiles(): string[] {
return OrchestratorOptions.getInput('commandHookFiles')?.split(`,`) || [];
}
// ### ### ###
// Custom commands from yaml parameters
// ### ### ###
static get commandHooks(): string {
return OrchestratorOptions.getInput('commandHooks') || '';
}
static get postBuildContainerHooks(): string {
return OrchestratorOptions.getInput('postBuildContainerHooks') || '';
}
static get preBuildContainerHooks(): string {
return OrchestratorOptions.getInput('preBuildContainerHooks') || '';
}
// ### ### ###
// Input override handling
// ### ### ###
static get pullInputList(): string[] {
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
}
static get inputPullCommand(): string {
const value = OrchestratorOptions.getInput('inputPullCommand');
if (value === 'gcp-secret-manager') {
return 'gcloud secrets versions access 1 --secret="{0}"';
} else if (value === 'aws-secret-manager') {
return 'aws secretsmanager get-secret-value --secret-id {0}';
}
return value || '';
}
// ### ### ###
// Aws
// ### ### ###
static get awsStackName() {
return OrchestratorOptions.getInput('awsStackName') || 'game-ci';
}
static get awsEndpoint(): string | undefined {
return OrchestratorOptions.getInput('awsEndpoint');
}
static get awsCloudFormationEndpoint(): string | undefined {
return OrchestratorOptions.getInput('awsCloudFormationEndpoint') || OrchestratorOptions.awsEndpoint;
}
static get awsEcsEndpoint(): string | undefined {
return OrchestratorOptions.getInput('awsEcsEndpoint') || OrchestratorOptions.awsEndpoint;
}
static get awsKinesisEndpoint(): string | undefined {
return OrchestratorOptions.getInput('awsKinesisEndpoint') || OrchestratorOptions.awsEndpoint;
}
static get awsCloudWatchLogsEndpoint(): string | undefined {
return OrchestratorOptions.getInput('awsCloudWatchLogsEndpoint') || OrchestratorOptions.awsEndpoint;
}
static get awsS3Endpoint(): string | undefined {
return OrchestratorOptions.getInput('awsS3Endpoint') || OrchestratorOptions.awsEndpoint;
}
// ### ### ###
// Storage
// ### ### ###
static get storageProvider(): string {
return OrchestratorOptions.getInput('storageProvider') || 's3';
}
static get rcloneRemote(): string {
return OrchestratorOptions.getInput('rcloneRemote') || '';
}
// ### ### ###
// K8s
// ### ### ###
static get kubeConfig(): string {
return OrchestratorOptions.getInput('kubeConfig') || '';
}
static get kubeVolume(): string {
return OrchestratorOptions.getInput('kubeVolume') || '';
}
static get kubeVolumeSize(): string {
return OrchestratorOptions.getInput('kubeVolumeSize') || '25Gi';
}
static get kubeStorageClass(): string {
return OrchestratorOptions.getInput('kubeStorageClass') || '';
}
// ### ### ###
// Caching
// ### ### ###
static get cacheKey(): string {
return OrchestratorOptions.getInput('cacheKey') || OrchestratorOptions.branch;
}
// ### ### ###
// Utility Parameters
// ### ### ###
static get orchestratorDebug(): boolean {
return (
OrchestratorOptions.getInput(`orchestratorTests`) === `true` ||
OrchestratorOptions.getInput(`orchestratorDebug`) === `true` ||
OrchestratorOptions.getInput(`orchestratorDebugTree`) === `true` ||
OrchestratorOptions.getInput(`orchestratorDebugEnv`) === `true` ||
false
);
}
static get skipLfs(): boolean {
return OrchestratorOptions.getInput(`skipLfs`) === `true`;
}
static get skipCache(): boolean {
return OrchestratorOptions.getInput(`skipCache`) === `true`;
}
public static get asyncOrchestrator(): boolean {
return OrchestratorOptions.getInput('asyncOrchestrator') === 'true';
}
public static get resourceTracking(): boolean {
return OrchestratorOptions.getInput('resourceTracking') === 'true';
}
public static get useLargePackages(): boolean {
return OrchestratorOptions.getInput(`useLargePackages`) === `true`;
}
public static get useSharedBuilder(): boolean {
return OrchestratorOptions.getInput(`useSharedBuilder`) === `true`;
}
public static get useCompressionStrategy(): boolean {
return OrchestratorOptions.getInput(`useCompressionStrategy`) === `true`;
}
public static get useCleanupCron(): boolean {
return (OrchestratorOptions.getInput(`useCleanupCron`) || 'true') === 'true';
}
// ### ### ###
// Retained Workspace
// ### ### ###
public static get maxRetainedWorkspaces(): string {
return OrchestratorOptions.getInput(`maxRetainedWorkspaces`) || `0`;
}
// ### ### ###
// Garbage Collection
// ### ### ###
static get garbageMaxAge(): number {
return Number(OrchestratorOptions.getInput(`garbageMaxAge`)) || 24;
}
}
export default OrchestratorOptions;

View File

@@ -1,67 +0,0 @@
import Input from '../../input';
import { GenericInputReader } from '../../input-readers/generic-input-reader';
import OrchestratorOptions from './orchestrator-options';
const formatFunction = (value: string, arguments_: any[]) => {
for (const element of arguments_) {
value = value.replace(`{${element.key}}`, element.value);
}
return value;
};
class OrchestratorQueryOverride {
static queryOverrides: { [key: string]: string } | undefined;
// TODO accept premade secret sources or custom secret source definition yamls
public static query(key: string, alternativeKey: string) {
if (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) {
return OrchestratorQueryOverride.queryOverrides[key];
}
if (
OrchestratorQueryOverride.queryOverrides &&
alternativeKey &&
OrchestratorQueryOverride.queryOverrides[alternativeKey] !== undefined
) {
return OrchestratorQueryOverride.queryOverrides[alternativeKey];
}
return;
}
private static shouldUseOverride(query: string) {
if (OrchestratorOptions.inputPullCommand !== '') {
if (OrchestratorOptions.pullInputList.length > 0) {
const doesInclude =
OrchestratorOptions.pullInputList.includes(query) ||
OrchestratorOptions.pullInputList.includes(Input.ToEnvVarFormat(query));
return doesInclude ? true : false;
} else {
return true;
}
}
}
private static async queryOverride(query: string) {
if (!this.shouldUseOverride(query)) {
throw new Error(`Should not be trying to run override query on ${query}`);
}
return await GenericInputReader.Run(
formatFunction(OrchestratorOptions.inputPullCommand, [{ key: 0, value: query }]),
);
}
public static async PopulateQueryOverrideInput() {
const queries = OrchestratorOptions.pullInputList;
OrchestratorQueryOverride.queryOverrides = {};
for (const element of queries) {
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);
}
}
}
}
export default OrchestratorQueryOverride;

View File

@@ -1,6 +0,0 @@
class OrchestratorSecret {
public ParameterKey!: string;
public EnvironmentVariable!: string;
public ParameterValue!: string;
}
export default OrchestratorSecret;

View File

@@ -1,3 +0,0 @@
export class OrchestratorStatics {
public static readonly logPrefix = `Orchestrator`;
}

View File

@@ -1,13 +0,0 @@
import OrchestratorEnvironmentVariable from './orchestrator-environment-variable';
import OrchestratorSecret from './orchestrator-secret';
export class OrchestratorStepParameters {
public image: string;
public environment: OrchestratorEnvironmentVariable[];
public secrets: OrchestratorSecret[];
constructor(image: string, environmentVariables: OrchestratorEnvironmentVariable[], secrets: OrchestratorSecret[]) {
this.image = image;
this.environment = environmentVariables;
this.secrets = secrets;
}
}

View File

@@ -1,345 +0,0 @@
import AwsBuildPlatform from './providers/aws';
import { BuildParameters, Input } from '..';
import Kubernetes from './providers/k8s';
import OrchestratorLogger from './services/core/orchestrator-logger';
import { OrchestratorStepParameters } from './options/orchestrator-step-parameters';
import { WorkflowCompositionRoot } from './workflows/workflow-composition-root';
import { OrchestratorError } from './error/orchestrator-error';
import { TaskParameterSerializer } from './services/core/task-parameter-serializer';
import * as core from '@actions/core';
import OrchestratorSecret from './options/orchestrator-secret';
import { ProviderInterface } from './providers/provider-interface';
import OrchestratorEnvironmentVariable from './options/orchestrator-environment-variable';
import TestOrchestrator from './providers/test';
import LocalOrchestrator from './providers/local';
import LocalDockerOrchestrator from './providers/docker';
import loadProvider from './providers/provider-loader';
import GitHub from '../github';
import SharedWorkspaceLocking from './services/core/shared-workspace-locking';
import { FollowLogStreamService } from './services/core/follow-log-stream-service';
import OrchestratorResult from './services/core/orchestrator-result';
import OrchestratorOptions from './options/orchestrator-options';
import ResourceTracking from './services/core/resource-tracking';
class Orchestrator {
public static Provider: ProviderInterface;
public static buildParameters: BuildParameters;
private static defaultSecrets: OrchestratorSecret[];
private static orchestratorEnvironmentVariables: OrchestratorEnvironmentVariable[];
static lockedWorkspace: string = ``;
public static readonly retainedWorkspacePrefix: string = `retained-workspace`;
// When true, validates AWS CloudFormation templates even when using local-docker execution
// This is set by AWS_FORCE_PROVIDER=aws-local mode
public static validateAwsTemplates: boolean = false;
public static get isOrchestratorEnvironment() {
return process.env[`GITHUB_ACTIONS`] !== `true`;
}
public static get isOrchestratorAsyncEnvironment() {
return process.env[`ASYNC_WORKFLOW`] === `true`;
}
public static async setup(buildParameters: BuildParameters) {
OrchestratorLogger.setup();
OrchestratorLogger.log(`Setting up orchestrator`);
Orchestrator.buildParameters = buildParameters;
ResourceTracking.logAllocationSummary('setup');
await ResourceTracking.logDiskUsageSnapshot('setup');
if (Orchestrator.buildParameters.githubCheckId === ``) {
Orchestrator.buildParameters.githubCheckId = await GitHub.createGitHubCheck(
Orchestrator.buildParameters.buildGuid,
);
}
await Orchestrator.setupSelectedBuildPlatform();
Orchestrator.defaultSecrets = TaskParameterSerializer.readDefaultSecrets();
Orchestrator.orchestratorEnvironmentVariables =
TaskParameterSerializer.createOrchestratorEnvironmentVariables(buildParameters);
if (GitHub.githubInputEnabled) {
const buildParameterPropertyNames = Object.getOwnPropertyNames(buildParameters);
for (const element of Orchestrator.orchestratorEnvironmentVariables) {
// OrchestratorLogger.log(`Orchestrator output ${Input.ToEnvVarFormat(element.name)} = ${element.value}`);
core.setOutput(Input.ToEnvVarFormat(element.name), element.value);
}
for (const element of buildParameterPropertyNames) {
// OrchestratorLogger.log(`Orchestrator output ${Input.ToEnvVarFormat(element)} = ${buildParameters[element]}`);
core.setOutput(Input.ToEnvVarFormat(element), buildParameters[element]);
}
core.setOutput(
Input.ToEnvVarFormat(`buildArtifact`),
`build-${Orchestrator.buildParameters.buildGuid}.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
}`,
);
}
FollowLogStreamService.Reset();
}
private static async setupSelectedBuildPlatform() {
OrchestratorLogger.log(`Orchestrator platform selected ${Orchestrator.buildParameters.providerStrategy}`);
// Detect LocalStack endpoints and handle AWS provider appropriately
// AWS_FORCE_PROVIDER options:
// - 'aws': Force AWS provider (requires LocalStack Pro with ECS support)
// - 'aws-local': Validate AWS templates/config but execute via local-docker (for CI without ECS)
// - unset/other: Auto-fallback to local-docker when LocalStack detected
const awsForceProvider = process.env.AWS_FORCE_PROVIDER || '';
const forceAwsProvider = awsForceProvider === 'aws' || awsForceProvider === 'true';
const useAwsLocalMode = awsForceProvider === 'aws-local';
const endpointsToCheck = [
process.env.AWS_ENDPOINT,
process.env.AWS_S3_ENDPOINT,
process.env.AWS_CLOUD_FORMATION_ENDPOINT,
process.env.AWS_ECS_ENDPOINT,
process.env.AWS_KINESIS_ENDPOINT,
process.env.AWS_CLOUD_WATCH_LOGS_ENDPOINT,
OrchestratorOptions.awsEndpoint,
OrchestratorOptions.awsS3Endpoint,
OrchestratorOptions.awsCloudFormationEndpoint,
OrchestratorOptions.awsEcsEndpoint,
OrchestratorOptions.awsKinesisEndpoint,
OrchestratorOptions.awsCloudWatchLogsEndpoint,
]
.filter((x) => typeof x === 'string')
.join(' ');
const isLocalStack = /localstack|localhost|127\.0\.0\.1/i.test(endpointsToCheck);
let provider = Orchestrator.buildParameters.providerStrategy;
let validateAwsTemplates = false;
if (provider === 'aws' && isLocalStack) {
if (useAwsLocalMode) {
// aws-local mode: Validate AWS templates but execute via local-docker
// This provides confidence in AWS CloudFormation without requiring LocalStack Pro
OrchestratorLogger.log('AWS_FORCE_PROVIDER=aws-local: Validating AWS templates, executing via local-docker');
validateAwsTemplates = true;
provider = 'local-docker';
} else if (forceAwsProvider) {
// Force full AWS provider (requires LocalStack Pro with ECS support)
OrchestratorLogger.log(
'LocalStack endpoints detected but AWS_FORCE_PROVIDER=aws; using full AWS provider (requires ECS support)',
);
} else {
// Auto-fallback to local-docker
OrchestratorLogger.log('LocalStack endpoints detected; routing provider to local-docker for this run');
OrchestratorLogger.log(
'Note: Set AWS_FORCE_PROVIDER=aws-local to validate AWS templates with local-docker execution',
);
provider = 'local-docker';
}
}
// Store whether we should validate AWS templates (used by aws-local mode)
Orchestrator.validateAwsTemplates = validateAwsTemplates;
switch (provider) {
case 'k8s':
Orchestrator.Provider = new Kubernetes(Orchestrator.buildParameters);
break;
case 'aws':
Orchestrator.Provider = new AwsBuildPlatform(Orchestrator.buildParameters);
// Validate that AWS provider is actually being used when expected
if (isLocalStack && forceAwsProvider) {
OrchestratorLogger.log('✓ AWS provider initialized with LocalStack - AWS functionality will be validated');
} else if (isLocalStack && !forceAwsProvider) {
OrchestratorLogger.log(
'⚠ WARNING: AWS provider was requested but LocalStack detected without AWS_FORCE_PROVIDER',
);
OrchestratorLogger.log('⚠ This may cause AWS functionality tests to fail validation');
}
break;
case 'test':
Orchestrator.Provider = new TestOrchestrator();
break;
case 'local-docker':
Orchestrator.Provider = new LocalDockerOrchestrator();
break;
case 'local-system':
Orchestrator.Provider = new LocalOrchestrator();
break;
case 'local':
Orchestrator.Provider = new LocalOrchestrator();
break;
default:
// Try to load provider using the dynamic loader for unknown providers
try {
Orchestrator.Provider = await loadProvider(provider, Orchestrator.buildParameters);
} catch (error: any) {
OrchestratorLogger.log(`Failed to load provider '${provider}' using dynamic loader: ${error.message}`);
OrchestratorLogger.log('Falling back to local provider...');
Orchestrator.Provider = new LocalOrchestrator();
}
break;
}
// Final validation: Ensure provider matches expectations
const finalProviderName = Orchestrator.Provider.constructor.name;
if (Orchestrator.buildParameters.providerStrategy === 'aws' && finalProviderName !== 'AWSBuildEnvironment') {
OrchestratorLogger.log(`⚠ WARNING: Expected AWS provider but got ${finalProviderName}`);
OrchestratorLogger.log('⚠ AWS functionality tests may not be validating AWS services correctly');
}
}
static async run(buildParameters: BuildParameters, baseImage: string) {
if (baseImage.includes(`undefined`)) {
throw new Error(`baseImage is undefined`);
}
await Orchestrator.setup(buildParameters);
// When aws-local mode is enabled, validate AWS CloudFormation templates
// This ensures AWS templates are correct even when executing via local-docker
if (Orchestrator.validateAwsTemplates) {
await Orchestrator.validateAwsCloudFormationTemplates();
}
await Orchestrator.Provider.setupWorkflow(
Orchestrator.buildParameters.buildGuid,
Orchestrator.buildParameters,
Orchestrator.buildParameters.branch,
Orchestrator.defaultSecrets,
);
try {
if (buildParameters.maxRetainedWorkspaces > 0) {
Orchestrator.lockedWorkspace = SharedWorkspaceLocking.NewWorkspaceName();
const result = await SharedWorkspaceLocking.GetLockedWorkspace(
Orchestrator.lockedWorkspace,
Orchestrator.buildParameters.buildGuid,
Orchestrator.buildParameters,
);
if (result) {
OrchestratorLogger.logLine(`Using retained workspace ${Orchestrator.lockedWorkspace}`);
Orchestrator.orchestratorEnvironmentVariables = [
...Orchestrator.orchestratorEnvironmentVariables,
{ name: `LOCKED_WORKSPACE`, value: Orchestrator.lockedWorkspace },
];
} else {
OrchestratorLogger.log(`Max retained workspaces reached ${buildParameters.maxRetainedWorkspaces}`);
buildParameters.maxRetainedWorkspaces = 0;
Orchestrator.lockedWorkspace = ``;
}
}
await Orchestrator.updateStatusWithBuildParameters();
const output = await new WorkflowCompositionRoot().run(
new OrchestratorStepParameters(
baseImage,
Orchestrator.orchestratorEnvironmentVariables,
Orchestrator.defaultSecrets,
),
);
await Orchestrator.Provider.cleanupWorkflow(
Orchestrator.buildParameters,
Orchestrator.buildParameters.branch,
Orchestrator.defaultSecrets,
);
if (!Orchestrator.buildParameters.isCliMode) core.endGroup();
if (buildParameters.asyncWorkflow && this.isOrchestratorEnvironment && this.isOrchestratorAsyncEnvironment) {
await GitHub.updateGitHubCheck(Orchestrator.buildParameters.buildGuid, `success`, `success`, `completed`);
}
if (BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) {
const workspace = Orchestrator.lockedWorkspace || ``;
await SharedWorkspaceLocking.ReleaseWorkspace(
workspace,
Orchestrator.buildParameters.buildGuid,
Orchestrator.buildParameters,
);
const isLocked = await SharedWorkspaceLocking.IsWorkspaceLocked(workspace, Orchestrator.buildParameters);
if (isLocked) {
throw new Error(
`still locked after releasing ${await SharedWorkspaceLocking.GetAllLocksForWorkspace(
workspace,
buildParameters,
)}`,
);
}
Orchestrator.lockedWorkspace = ``;
}
await GitHub.triggerWorkflowOnComplete(Orchestrator.buildParameters.finalHooks);
if (buildParameters.constantGarbageCollection) {
Orchestrator.Provider.garbageCollect(``, true, buildParameters.garbageMaxAge, true, true);
}
return new OrchestratorResult(buildParameters, output, true, true, false);
} catch (error: any) {
OrchestratorLogger.log(JSON.stringify(error, undefined, 4));
await GitHub.updateGitHubCheck(
Orchestrator.buildParameters.buildGuid,
`Failed - Error ${error?.message || error}`,
`failure`,
`completed`,
);
if (!Orchestrator.buildParameters.isCliMode) core.endGroup();
await OrchestratorError.handleException(error, Orchestrator.buildParameters, Orchestrator.defaultSecrets);
throw error;
}
}
private static async updateStatusWithBuildParameters() {
const content = { ...Orchestrator.buildParameters };
content.gitPrivateToken = ``;
content.unitySerial = ``;
content.unityEmail = ``;
content.unityPassword = ``;
const jsonContent = JSON.stringify(content, undefined, 4);
await GitHub.updateGitHubCheck(jsonContent, Orchestrator.buildParameters.buildGuid);
}
/**
* Validates AWS CloudFormation templates without deploying them.
* Used by aws-local mode to ensure AWS templates are correct when executing via local-docker.
* This provides confidence that AWS ECS deployments would work with the generated templates.
*/
private static async validateAwsCloudFormationTemplates() {
OrchestratorLogger.log('=== AWS CloudFormation Template Validation (aws-local mode) ===');
try {
// Import AWS template formations
const { BaseStackFormation } = await import('./providers/aws/cloud-formations/base-stack-formation');
const { TaskDefinitionFormation } = await import('./providers/aws/cloud-formations/task-definition-formation');
// Validate base stack template
const baseTemplate = BaseStackFormation.formation;
OrchestratorLogger.log(`✓ Base stack template generated (${baseTemplate.length} chars)`);
// Check for required resources in base stack
const requiredBaseResources = ['AWS::EC2::VPC', 'AWS::ECS::Cluster', 'AWS::S3::Bucket', 'AWS::IAM::Role'];
for (const resource of requiredBaseResources) {
if (baseTemplate.includes(resource)) {
OrchestratorLogger.log(` ✓ Contains ${resource}`);
} else {
throw new Error(`Base stack template missing required resource: ${resource}`);
}
}
// Validate task definition template
const taskTemplate = TaskDefinitionFormation.formation;
OrchestratorLogger.log(`✓ Task definition template generated (${taskTemplate.length} chars)`);
// Check for required resources in task definition
const requiredTaskResources = ['AWS::ECS::TaskDefinition', 'AWS::Logs::LogGroup'];
for (const resource of requiredTaskResources) {
if (taskTemplate.includes(resource)) {
OrchestratorLogger.log(` ✓ Contains ${resource}`);
} else {
throw new Error(`Task definition template missing required resource: ${resource}`);
}
}
// Validate YAML syntax by checking for common patterns
if (!baseTemplate.includes('AWSTemplateFormatVersion')) {
throw new Error('Base stack template missing AWSTemplateFormatVersion');
}
if (!taskTemplate.includes('AWSTemplateFormatVersion')) {
throw new Error('Task definition template missing AWSTemplateFormatVersion');
}
OrchestratorLogger.log('=== AWS CloudFormation templates validated successfully ===');
OrchestratorLogger.log('Note: Actual execution will use local-docker provider');
} catch (error: any) {
OrchestratorLogger.log(`AWS CloudFormation template validation failed: ${error.message}`);
throw error;
}
}
}
export default Orchestrator;

View File

@@ -1,222 +0,0 @@
# Provider Loader Dynamic Imports
## What is a Provider?
A **provider** is a pluggable backend that Orchestrator uses to run builds and workflows. Examples include **AWS**, **Kubernetes**, or local execution. Each provider implements the [ProviderInterface](https://github.com/game-ci/unity-builder/blob/main/src/model/orchestrator/providers/provider-interface.ts), which defines the common lifecycle methods (setup, run, cleanup, garbage collection, etc.).
This abstraction makes Orchestrator flexible: you can switch execution environments or add your own provider (via npm package, GitHub repo, or local path) without changing the rest of your pipeline.
## Dynamic Provider Loading
The provider loader now supports dynamic loading of providers from multiple sources including local file paths, GitHub repositories, and NPM packages.
## Features
- **Local File Paths**: Load providers from relative or absolute file paths
- **GitHub URLs**: Clone and load providers from GitHub repositories with automatic updates
- **NPM Packages**: Load providers from installed NPM packages
- **Automatic Updates**: GitHub repositories are automatically updated when changes are available
- **Caching**: Local caching of cloned repositories for improved performance
- **Fallback Support**: Graceful fallback to local provider if loading fails
## Usage Examples
### Loading Built-in Providers
```typescript
import { ProviderLoader } from './provider-loader';
// Load built-in providers
const awsProvider = await ProviderLoader.loadProvider('aws', buildParameters);
const k8sProvider = await ProviderLoader.loadProvider('k8s', buildParameters);
```
### Loading Local Providers
```typescript
// Load from relative path
const localProvider = await ProviderLoader.loadProvider('./my-local-provider', buildParameters);
// Load from absolute path
const absoluteProvider = await ProviderLoader.loadProvider('/path/to/provider', buildParameters);
```
### Loading GitHub Providers
```typescript
// Load from GitHub URL
const githubProvider = await ProviderLoader.loadProvider(
'https://github.com/user/my-provider',
buildParameters
);
// Load from specific branch
const branchProvider = await ProviderLoader.loadProvider(
'https://github.com/user/my-provider/tree/develop',
buildParameters
);
// Load from specific path in repository
const pathProvider = await ProviderLoader.loadProvider(
'https://github.com/user/my-provider/tree/main/src/providers',
buildParameters
);
// Shorthand notation
const shorthandProvider = await ProviderLoader.loadProvider('user/repo', buildParameters);
const branchShorthand = await ProviderLoader.loadProvider('user/repo@develop', buildParameters);
```
### Loading NPM Packages
```typescript
// Load from NPM package
const npmProvider = await ProviderLoader.loadProvider('my-provider-package', buildParameters);
// Load from scoped NPM package
const scopedProvider = await ProviderLoader.loadProvider('@scope/my-provider', buildParameters);
```
## Provider Interface
All providers must implement the `ProviderInterface`:
```typescript
interface ProviderInterface {
cleanupWorkflow(): Promise<void>;
setupWorkflow(buildGuid: string, buildParameters: BuildParameters, branchName: string, defaultSecretsArray: any[]): Promise<void>;
runTaskInWorkflow(buildGuid: string, task: string, workingDirectory: string, buildVolumeFolder: string, environmentVariables: any[], secrets: any[]): Promise<string>;
garbageCollect(): Promise<void>;
listResources(): Promise<ProviderResource[]>;
listWorkflow(): Promise<ProviderWorkflow[]>;
watchWorkflow(): Promise<void>;
}
```
## Example Provider Implementation
```typescript
// my-provider.ts
import { ProviderInterface } from './provider-interface';
import BuildParameters from './build-parameters';
export default class MyProvider implements ProviderInterface {
constructor(private buildParameters: BuildParameters) {}
async cleanupWorkflow(): Promise<void> {
// Cleanup logic
}
async setupWorkflow(buildGuid: string, buildParameters: BuildParameters, branchName: string, defaultSecretsArray: any[]): Promise<void> {
// Setup logic
}
async runTaskInWorkflow(buildGuid: string, task: string, workingDirectory: string, buildVolumeFolder: string, environmentVariables: any[], secrets: any[]): Promise<string> {
// Task execution logic
return 'Task completed';
}
async garbageCollect(): Promise<void> {
// Garbage collection logic
}
async listResources(): Promise<ProviderResource[]> {
return [];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
return [];
}
async watchWorkflow(): Promise<void> {
// Watch logic
}
}
```
## Utility Methods
### Analyze Provider Source
```typescript
// Analyze a provider source without loading it
const sourceInfo = ProviderLoader.analyzeProviderSource('https://github.com/user/repo');
console.log(sourceInfo.type); // 'github'
console.log(sourceInfo.owner); // 'user'
console.log(sourceInfo.repo); // 'repo'
```
### Clean Up Cache
```typescript
// Clean up old cached repositories (older than 30 days)
await ProviderLoader.cleanupCache();
// Clean up repositories older than 7 days
await ProviderLoader.cleanupCache(7);
```
### Get Available Providers
```typescript
// Get list of built-in providers
const providers = ProviderLoader.getAvailableProviders();
console.log(providers); // ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local']
```
## Supported URL Formats
### GitHub URLs
- `https://github.com/user/repo`
- `https://github.com/user/repo.git`
- `https://github.com/user/repo/tree/branch`
- `https://github.com/user/repo/tree/branch/path/to/provider`
- `git@github.com:user/repo.git`
### Shorthand GitHub References
- `user/repo`
- `user/repo@branch`
- `user/repo@branch/path/to/provider`
### Local Paths
- `./relative/path`
- `../relative/path`
- `/absolute/path`
- `C:\\path\\to\\provider` (Windows)
### NPM Packages
- `package-name`
- `@scope/package-name`
## Caching
GitHub repositories are automatically cached in the `.provider-cache` directory. The cache key is generated based on the repository owner, name, and branch. This ensures that:
1. Repositories are only cloned once
2. Updates are checked and applied automatically
3. Performance is improved for repeated loads
4. Storage is managed efficiently
## Error Handling
The provider loader includes comprehensive error handling:
- **Missing packages**: Clear error messages when providers cannot be found
- **Interface validation**: Ensures providers implement the required interface
- **Git operations**: Handles network issues and repository access problems
- **Fallback mechanism**: Falls back to local provider if loading fails
## Configuration
The provider loader can be configured through environment variables:
- `PROVIDER_CACHE_DIR`: Custom cache directory (default: `.provider-cache`)
- `GIT_TIMEOUT`: Git operation timeout in milliseconds (default: 30000)
## Best Practices
1. **Use specific branches or tags**: Always specify the branch or specific tag when loading from GitHub
2. **Implement proper error handling**: Wrap provider loading in try-catch blocks
3. **Clean up regularly**: Use the cleanup utility to manage cache size
4. **Test locally first**: Test providers locally before deploying
5. **Use semantic versioning**: Tag your provider repositories for stable versions

View File

@@ -1,170 +0,0 @@
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import * as core from '@actions/core';
import {
CloudFormation,
CreateStackCommand,
// eslint-disable-next-line import/named
CreateStackCommandInput,
DescribeStacksCommand,
// eslint-disable-next-line import/named
DescribeStacksCommandInput,
ListStacksCommand,
// eslint-disable-next-line import/named
Parameter,
UpdateStackCommand,
// eslint-disable-next-line import/named
UpdateStackCommandInput,
waitUntilStackCreateComplete,
waitUntilStackUpdateComplete,
} from '@aws-sdk/client-cloudformation';
import { BaseStackFormation } from './cloud-formations/base-stack-formation';
import crypto from 'node:crypto';
const DEFAULT_STACK_WAIT_TIME_SECONDS = 600;
function getStackWaitTime(): number {
const overrideValue = Number(process.env.ORCHESTRATOR_AWS_STACK_WAIT_TIME ?? '');
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}
export class AWSBaseStack {
constructor(baseStackName: string) {
this.baseStackName = baseStackName;
}
private baseStackName: string;
async setupBaseStack(CF: CloudFormation) {
const baseStackName = this.baseStackName;
const stackWaitTimeSeconds = getStackWaitTime();
const baseStack = BaseStackFormation.formation;
// Cloud Formation Input
const describeStackInput: DescribeStacksCommandInput = {
StackName: baseStackName,
};
const parametersWithoutHash: Parameter[] = [{ ParameterKey: 'EnvironmentName', ParameterValue: baseStackName }];
const parametersHash = crypto
.createHash('md5')
.update(baseStack + JSON.stringify(parametersWithoutHash))
.digest('hex');
const parameters: Parameter[] = [
...parametersWithoutHash,
...[{ ParameterKey: 'Version', ParameterValue: parametersHash }],
];
const updateInput: UpdateStackCommandInput = {
StackName: baseStackName,
TemplateBody: baseStack,
Parameters: parameters,
Capabilities: ['CAPABILITY_IAM'],
};
const createStackInput: CreateStackCommandInput = {
StackName: baseStackName,
TemplateBody: baseStack,
Parameters: parameters,
Capabilities: ['CAPABILITY_IAM'],
};
const stacks = await CF.send(
new ListStacksCommand({
StackStatusFilter: [
'CREATE_IN_PROGRESS',
'UPDATE_IN_PROGRESS',
'UPDATE_COMPLETE',
'CREATE_COMPLETE',
'ROLLBACK_COMPLETE',
],
}),
);
const stackNames = stacks.StackSummaries?.map((x) => x.StackName) || [];
const stackExists: boolean = stackNames.includes(baseStackName);
const describeStack = async () => {
return await CF.send(new DescribeStacksCommand(describeStackInput));
};
try {
if (!stackExists) {
OrchestratorLogger.log(`${baseStackName} stack does not exist (${JSON.stringify(stackNames)})`);
let created = false;
try {
await CF.send(new CreateStackCommand(createStackInput));
created = true;
} catch (error: any) {
const message = `${error?.name ?? ''} ${error?.message ?? ''}`;
if (message.includes('AlreadyExistsException')) {
OrchestratorLogger.log(`Base stack already exists, continuing with describe`);
} else {
throw error;
}
}
if (created) {
OrchestratorLogger.log(`created stack (version: ${parametersHash})`);
}
}
const CFState = await describeStack();
let stack = CFState.Stacks?.[0];
if (!stack) {
throw new Error(`Base stack doesn't exist, even after creation, stackExists check: ${stackExists}`);
}
const stackVersion = stack.Parameters?.find((x) => x.ParameterKey === 'Version')?.ParameterValue;
if (stack.StackStatus === 'CREATE_IN_PROGRESS') {
OrchestratorLogger.log(
`Waiting up to ${stackWaitTimeSeconds}s for '${baseStackName}' CloudFormation creation to finish`,
);
await waitUntilStackCreateComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
describeStackInput,
);
}
if (stackExists) {
OrchestratorLogger.log(`Base stack exists (version: ${stackVersion}, local version: ${parametersHash})`);
if (parametersHash !== stackVersion) {
OrchestratorLogger.log(`Attempting update of base stack`);
try {
await CF.send(new UpdateStackCommand(updateInput));
} catch (error: any) {
if (error['message'].includes('No updates are to be performed')) {
OrchestratorLogger.log(`No updates are to be performed`);
} else {
OrchestratorLogger.log(`Update Failed (Stack name: ${baseStackName})`);
OrchestratorLogger.log(error['message']);
}
OrchestratorLogger.log(`Continuing...`);
}
} else {
OrchestratorLogger.log(`No update required`);
}
stack = (await describeStack()).Stacks?.[0];
if (!stack) {
throw new Error(
`Base stack doesn't exist, even after updating and creation, stackExists check: ${stackExists}`,
);
}
if (stack.StackStatus === 'UPDATE_IN_PROGRESS') {
OrchestratorLogger.log(
`Waiting up to ${stackWaitTimeSeconds}s for '${baseStackName}' CloudFormation update to finish`,
);
await waitUntilStackUpdateComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
describeStackInput,
);
}
}
OrchestratorLogger.log('base stack is now ready');
} catch (error) {
core.error(JSON.stringify(await describeStack(), undefined, 4));
throw error;
}
}
}

View File

@@ -1,93 +0,0 @@
import { CloudFormation } from '@aws-sdk/client-cloudformation';
import { ECS } from '@aws-sdk/client-ecs';
import { Kinesis } from '@aws-sdk/client-kinesis';
import { CloudWatchLogs } from '@aws-sdk/client-cloudwatch-logs';
import { S3 } from '@aws-sdk/client-s3';
import { Input } from '../../..';
import OrchestratorOptions from '../../options/orchestrator-options';
export class AwsClientFactory {
private static cloudFormation: CloudFormation;
private static ecs: ECS;
private static kinesis: Kinesis;
private static cloudWatchLogs: CloudWatchLogs;
private static s3: S3;
private static getCredentials() {
// Explicitly provide credentials from environment variables for LocalStack compatibility
// LocalStack accepts any credentials, but the AWS SDK needs them to be explicitly set
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
if (accessKeyId && secretAccessKey) {
return {
accessKeyId,
secretAccessKey,
};
}
// Return undefined to let AWS SDK use default credential chain
return;
}
static getCloudFormation(): CloudFormation {
if (!this.cloudFormation) {
this.cloudFormation = new CloudFormation({
region: Input.region,
endpoint: OrchestratorOptions.awsCloudFormationEndpoint,
credentials: AwsClientFactory.getCredentials(),
});
}
return this.cloudFormation;
}
static getECS(): ECS {
if (!this.ecs) {
this.ecs = new ECS({
region: Input.region,
endpoint: OrchestratorOptions.awsEcsEndpoint,
credentials: AwsClientFactory.getCredentials(),
});
}
return this.ecs;
}
static getKinesis(): Kinesis {
if (!this.kinesis) {
this.kinesis = new Kinesis({
region: Input.region,
endpoint: OrchestratorOptions.awsKinesisEndpoint,
credentials: AwsClientFactory.getCredentials(),
});
}
return this.kinesis;
}
static getCloudWatchLogs(): CloudWatchLogs {
if (!this.cloudWatchLogs) {
this.cloudWatchLogs = new CloudWatchLogs({
region: Input.region,
endpoint: OrchestratorOptions.awsCloudWatchLogsEndpoint,
credentials: AwsClientFactory.getCredentials(),
});
}
return this.cloudWatchLogs;
}
static getS3(): S3 {
if (!this.s3) {
this.s3 = new S3({
region: Input.region,
endpoint: OrchestratorOptions.awsS3Endpoint,
forcePathStyle: true,
credentials: AwsClientFactory.getCredentials(),
});
}
return this.s3;
}
}

View File

@@ -1,40 +0,0 @@
import { TaskDefinitionFormation } from './cloud-formations/task-definition-formation';
export class AWSCloudFormationTemplates {
public static getParameterTemplate(p1: string) {
return `
${p1}:
Type: String
Default: ''
`;
}
public static getSecretTemplate(p1: string) {
return `
${p1}Secret:
Type: AWS::SecretsManager::Secret
Properties:
Name: '${p1}'
SecretString: !Ref ${p1}
`;
}
public static getSecretDefinitionTemplate(p1: string, p2: string) {
return `
Secrets:
- Name: '${p1}'
ValueFrom: !Ref ${p2}Secret
`;
}
public static insertAtTemplate(template: string, insertionKey: string, insertion: string) {
const index = template.search(insertionKey) + insertionKey.length + '\n'.length;
template = [template.slice(0, index), insertion, template.slice(index)].join('');
return template;
}
public static readTaskCloudFormationTemplate(): string {
return TaskDefinitionFormation.formation;
}
}

View File

@@ -1,16 +0,0 @@
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { CloudFormation, DescribeStackEventsCommand } from '@aws-sdk/client-cloudformation';
import * as core from '@actions/core';
import Orchestrator from '../../orchestrator';
export class AWSError {
static async handleStackCreationFailure(error: any, CF: CloudFormation, taskDefStackName: string) {
OrchestratorLogger.log('aws error: ');
core.error(JSON.stringify(error, undefined, 4));
if (Orchestrator.buildParameters.orchestratorDebug) {
OrchestratorLogger.log('Getting events and resources for task stack');
const events = (await CF.send(new DescribeStackEventsCommand({ StackName: taskDefStackName }))).StackEvents;
OrchestratorLogger.log(JSON.stringify(events, undefined, 4));
}
}
}

View File

@@ -1,242 +0,0 @@
import {
CloudFormation,
CreateStackCommand,
// eslint-disable-next-line import/named
CreateStackCommandInput,
DescribeStackResourcesCommand,
DescribeStacksCommand,
ListStacksCommand,
waitUntilStackCreateComplete,
} from '@aws-sdk/client-cloudformation';
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { AWSCloudFormationTemplates } from './aws-cloud-formation-templates';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { AWSError } from './aws-error';
import Orchestrator from '../../orchestrator';
import { CleanupCronFormation } from './cloud-formations/cleanup-cron-formation';
import OrchestratorOptions from '../../options/orchestrator-options';
import { TaskDefinitionFormation } from './cloud-formations/task-definition-formation';
const DEFAULT_STACK_WAIT_TIME_SECONDS = 600;
function getStackWaitTime(): number {
const overrideValue = Number(process.env.ORCHESTRATOR_AWS_STACK_WAIT_TIME ?? '');
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}
export class AWSJobStack {
private baseStackName: string;
constructor(baseStackName: string) {
this.baseStackName = baseStackName;
}
public async setupCloudFormations(
CF: CloudFormation,
buildGuid: string,
image: string,
entrypoint: string[],
commands: string,
mountdir: string,
workingdir: string,
secrets: OrchestratorSecret[],
): Promise<OrchestratorAWSTaskDef> {
const taskDefStackName = `${this.baseStackName}-${buildGuid}`;
let taskDefCloudFormation = AWSCloudFormationTemplates.readTaskCloudFormationTemplate();
taskDefCloudFormation = taskDefCloudFormation.replace(
`ContainerCpu:
Default: 1024`,
`ContainerCpu:
Default: ${Number.parseInt(Orchestrator.buildParameters.containerCpu)}`,
);
taskDefCloudFormation = taskDefCloudFormation.replace(
`ContainerMemory:
Default: 2048`,
`ContainerMemory:
Default: ${Number.parseInt(Orchestrator.buildParameters.containerMemory)}`,
);
if (!OrchestratorOptions.asyncOrchestrator) {
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
taskDefCloudFormation,
'# template resources logstream',
TaskDefinitionFormation.streamLogs,
);
}
for (const secret of secrets) {
secret.ParameterKey = `${buildGuid.replace(/[^\dA-Za-z]/g, '')}${secret.ParameterKey.replace(
/[^\dA-Za-z]/g,
'',
)}`;
if (typeof secret.ParameterValue == 'number') {
secret.ParameterValue = `${secret.ParameterValue}`;
}
if (!secret.ParameterValue || secret.ParameterValue === '') {
secrets = secrets.filter((x) => x !== secret);
continue;
}
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
taskDefCloudFormation,
'p1 - input',
AWSCloudFormationTemplates.getParameterTemplate(secret.ParameterKey),
);
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
taskDefCloudFormation,
'# template resources secrets',
AWSCloudFormationTemplates.getSecretTemplate(`${secret.ParameterKey}`),
);
taskDefCloudFormation = AWSCloudFormationTemplates.insertAtTemplate(
taskDefCloudFormation,
'p3 - container def',
AWSCloudFormationTemplates.getSecretDefinitionTemplate(secret.EnvironmentVariable, secret.ParameterKey),
);
}
const secretsMappedToCloudFormationParameters = secrets.map((x) => {
return { ParameterKey: x.ParameterKey.replace(/[^\dA-Za-z]/g, ''), ParameterValue: x.ParameterValue };
});
const logGroupName = `${this.baseStackName}/${taskDefStackName}`;
const parameters = [
{
ParameterKey: 'EnvironmentName',
ParameterValue: this.baseStackName,
},
{
ParameterKey: 'ImageUrl',
ParameterValue: image,
},
{
ParameterKey: 'ServiceName',
ParameterValue: taskDefStackName,
},
{
ParameterKey: 'LogGroupName',
ParameterValue: logGroupName,
},
{
ParameterKey: 'Command',
ParameterValue: 'echo "this template should be overwritten when running a task"',
},
{
ParameterKey: 'EntryPoint',
ParameterValue: entrypoint.join(','),
},
{
ParameterKey: 'WorkingDirectory',
ParameterValue: workingdir,
},
{
ParameterKey: 'EFSMountDirectory',
ParameterValue: mountdir,
},
...secretsMappedToCloudFormationParameters,
];
OrchestratorLogger.log(
`Starting AWS job with memory: ${Orchestrator.buildParameters.containerMemory} cpu: ${Orchestrator.buildParameters.containerCpu}`,
);
let previousStackExists = true;
while (previousStackExists) {
previousStackExists = false;
const stacks = await CF.send(new ListStacksCommand({}));
if (!stacks.StackSummaries) {
throw new Error('Faild to get stacks');
}
for (let index = 0; index < stacks.StackSummaries.length; index++) {
const element = stacks.StackSummaries[index];
if (element.StackName === taskDefStackName && element.StackStatus !== 'DELETE_COMPLETE') {
previousStackExists = true;
OrchestratorLogger.log(`Previous stack still exists: ${JSON.stringify(element)}`);
await new Promise((promise) => setTimeout(promise, 5000));
}
}
}
const createStackInput: CreateStackCommandInput = {
StackName: taskDefStackName,
TemplateBody: taskDefCloudFormation,
Capabilities: ['CAPABILITY_IAM'],
Parameters: parameters,
};
try {
const stackWaitTimeSeconds = getStackWaitTime();
OrchestratorLogger.log(
`Creating job aws formation ${taskDefStackName} (waiting up to ${stackWaitTimeSeconds}s for completion)`,
);
await CF.send(new CreateStackCommand(createStackInput));
await waitUntilStackCreateComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
{ StackName: taskDefStackName },
);
const describeStack = await CF.send(new DescribeStacksCommand({ StackName: taskDefStackName }));
for (const parameter of parameters) {
if (!describeStack.Stacks?.[0].Parameters?.some((x) => x.ParameterKey === parameter.ParameterKey)) {
throw new Error(`Parameter ${parameter.ParameterKey} not found in stack`);
}
}
} catch (error) {
await AWSError.handleStackCreationFailure(error, CF, taskDefStackName);
throw error;
}
const createCleanupStackInput: CreateStackCommandInput = {
StackName: `${taskDefStackName}-cleanup`,
TemplateBody: CleanupCronFormation.formation,
Capabilities: ['CAPABILITY_IAM'],
Parameters: [
{
ParameterKey: 'StackName',
ParameterValue: taskDefStackName,
},
{
ParameterKey: 'DeleteStackName',
ParameterValue: `${taskDefStackName}-cleanup`,
},
{
ParameterKey: 'TTL',
ParameterValue: `1080`,
},
{
ParameterKey: 'BUILDGUID',
ParameterValue: Orchestrator.buildParameters.buildGuid,
},
{
ParameterKey: 'EnvironmentName',
ParameterValue: this.baseStackName,
},
],
};
if (OrchestratorOptions.useCleanupCron) {
try {
OrchestratorLogger.log(`Creating job cleanup formation`);
await CF.send(new CreateStackCommand(createCleanupStackInput));
// await CF.waitFor('stackCreateComplete', { StackName: createCleanupStackInput.StackName }).promise();
} catch (error) {
await AWSError.handleStackCreationFailure(error, CF, taskDefStackName);
throw error;
}
}
const taskDefResources = (
await CF.send(
new DescribeStackResourcesCommand({
StackName: taskDefStackName,
}),
)
).StackResources;
const baseResources = (await CF.send(new DescribeStackResourcesCommand({ StackName: this.baseStackName })))
.StackResources;
return {
taskDefStackName,
taskDefCloudFormation,
taskDefResources,
baseResources,
};
}
}

View File

@@ -1,335 +0,0 @@
import { DescribeTasksCommand, RunTaskCommand, waitUntilTasksRunning } from '@aws-sdk/client-ecs';
import { DescribeStreamCommand, GetRecordsCommand, GetShardIteratorCommand } from '@aws-sdk/client-kinesis';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import * as core from '@actions/core';
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def';
import * as zlib from 'node:zlib';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { Input } from '../../..';
import Orchestrator from '../../orchestrator';
import { CommandHookService } from '../../services/hooks/command-hook-service';
import { FollowLogStreamService } from '../../services/core/follow-log-stream-service';
import OrchestratorOptions from '../../options/orchestrator-options';
import GitHub from '../../../github';
import { AwsClientFactory } from './aws-client-factory';
class AWSTaskRunner {
private static readonly encodedUnderscore = `$252F`;
/**
* Transform localhost endpoints to host.docker.internal for container environments.
* When LocalStack is used, ECS tasks run in Docker containers that need to reach
* LocalStack on the host machine via host.docker.internal.
*/
private static transformEndpointsForContainer(
environment: OrchestratorEnvironmentVariable[],
): OrchestratorEnvironmentVariable[] {
const endpointEnvironmentNames = new Set([
'AWS_S3_ENDPOINT',
'AWS_ENDPOINT',
'AWS_CLOUD_FORMATION_ENDPOINT',
'AWS_ECS_ENDPOINT',
'AWS_KINESIS_ENDPOINT',
'AWS_CLOUD_WATCH_LOGS_ENDPOINT',
'INPUT_AWSS3ENDPOINT',
'INPUT_AWSENDPOINT',
]);
return environment.map((x) => {
let value = x.value;
if (
typeof value === 'string' &&
endpointEnvironmentNames.has(x.name) &&
(value.startsWith('http://localhost') || value.startsWith('http://127.0.0.1'))
) {
// Replace localhost with host.docker.internal so ECS containers can access host services
value = value
.replace('http://localhost', 'http://host.docker.internal')
.replace('http://127.0.0.1', 'http://host.docker.internal');
OrchestratorLogger.log(`AWS TaskRunner: Replaced localhost with host.docker.internal for ${x.name}: ${value}`);
}
return { name: x.name, value };
});
}
static async runTask(
taskDef: OrchestratorAWSTaskDef,
environment: OrchestratorEnvironmentVariable[],
commands: string,
): Promise<{ output: string; shouldCleanup: boolean }> {
const cluster = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ECSCluster')?.PhysicalResourceId || '';
const taskDefinition =
taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'TaskDefinition')?.PhysicalResourceId || '';
const SubnetOne =
taskDef.baseResources?.find((x) => x.LogicalResourceId === 'PublicSubnetOne')?.PhysicalResourceId || '';
const SubnetTwo =
taskDef.baseResources?.find((x) => x.LogicalResourceId === 'PublicSubnetTwo')?.PhysicalResourceId || '';
const ContainerSecurityGroup =
taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ContainerSecurityGroup')?.PhysicalResourceId || '';
const streamName =
taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'KinesisStream')?.PhysicalResourceId || '';
// Transform localhost endpoints for container environment
const transformedEnvironment = AWSTaskRunner.transformEndpointsForContainer(environment);
const runParameters = {
cluster,
taskDefinition,
platformVersion: '1.4.0',
overrides: {
containerOverrides: [
{
name: taskDef.taskDefStackName,
environment: transformedEnvironment,
command: ['-c', CommandHookService.ApplyHooksToCommands(commands, Orchestrator.buildParameters)],
},
],
},
launchType: 'FARGATE',
networkConfiguration: {
awsvpcConfiguration: {
subnets: [SubnetOne, SubnetTwo],
assignPublicIp: 'ENABLED',
securityGroups: [ContainerSecurityGroup],
},
},
};
if (JSON.stringify(runParameters.overrides.containerOverrides).length > 8192) {
OrchestratorLogger.log(JSON.stringify(runParameters.overrides.containerOverrides, undefined, 4));
throw new Error(`Container Overrides length must be at most 8192`);
}
const task = await AwsClientFactory.getECS().send(new RunTaskCommand(runParameters as any));
const taskArn = task.tasks?.[0].taskArn || '';
OrchestratorLogger.log('Orchestrator job is starting');
await AWSTaskRunner.waitUntilTaskRunning(taskArn, cluster);
OrchestratorLogger.log(
`Orchestrator job status is running ${(await AWSTaskRunner.describeTasks(cluster, taskArn))?.lastStatus} Async:${
OrchestratorOptions.asyncOrchestrator
}`,
);
if (OrchestratorOptions.asyncOrchestrator) {
const shouldCleanup: boolean = false;
const output: string = '';
OrchestratorLogger.log(`Watch Orchestrator To End: false`);
return { output, shouldCleanup };
}
OrchestratorLogger.log(`Streaming...`);
const { output, shouldCleanup } = await this.streamLogsUntilTaskStops(cluster, taskArn, streamName);
let exitCode;
let containerState;
let taskData;
while (exitCode === undefined) {
await new Promise((resolve) => setTimeout(resolve, 10000));
taskData = await AWSTaskRunner.describeTasks(cluster, taskArn);
const containers = taskData?.containers as any[] | undefined;
if (!containers || containers.length === 0) {
continue;
}
containerState = containers[0];
exitCode = containerState?.exitCode;
}
OrchestratorLogger.log(`Container State: ${JSON.stringify(containerState, undefined, 4)}`);
if (exitCode === undefined) {
OrchestratorLogger.logWarning(`Undefined exitcode for container`);
}
const wasSuccessful = exitCode === 0;
if (wasSuccessful) {
OrchestratorLogger.log(`Orchestrator job has finished successfully`);
return { output, shouldCleanup };
}
if (taskData?.stoppedReason === 'Essential container in task exited' && exitCode === 1) {
throw new Error('Container exited with code 1');
}
throw new Error(`Task failed`);
}
private static async waitUntilTaskRunning(taskArn: string, cluster: string) {
try {
await waitUntilTasksRunning(
{
client: AwsClientFactory.getECS(),
maxWaitTime: 300,
minDelay: 5,
maxDelay: 30,
},
{ tasks: [taskArn], cluster },
);
} catch (error_) {
const error = error_ as Error;
await new Promise((resolve) => setTimeout(resolve, 3000));
const taskAfterError = await AWSTaskRunner.describeTasks(cluster, taskArn);
OrchestratorLogger.log(`Orchestrator job has ended ${taskAfterError?.containers?.[0]?.lastStatus}`);
core.setFailed(error);
core.error(error);
}
}
static async describeTasks(clusterName: string, taskArn: string) {
const maxAttempts = 10;
let delayMs = 1000;
const maxDelayMs = 60000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const tasks = await AwsClientFactory.getECS().send(
new DescribeTasksCommand({ cluster: clusterName, tasks: [taskArn] }),
);
if (tasks.tasks?.[0]) {
return tasks.tasks?.[0];
}
throw new Error('No task found');
} catch (error: any) {
const isThrottle = error?.name === 'ThrottlingException' || /rate exceeded/i.test(String(error?.message));
if (!isThrottle || attempt === maxAttempts) {
throw error;
}
const jitterMs = Math.floor(Math.random() * Math.min(1000, delayMs));
const sleepMs = delayMs + jitterMs;
OrchestratorLogger.log(
`AWS throttled DescribeTasks (attempt ${attempt}/${maxAttempts}), backing off ${sleepMs}ms (${delayMs} + jitter ${jitterMs})`,
);
await new Promise((r) => setTimeout(r, sleepMs));
delayMs = Math.min(delayMs * 2, maxDelayMs);
}
}
}
static async streamLogsUntilTaskStops(clusterName: string, taskArn: string, kinesisStreamName: string) {
await new Promise((resolve) => setTimeout(resolve, 3000));
OrchestratorLogger.log(`Streaming...`);
const stream = await AWSTaskRunner.getLogStream(kinesisStreamName);
let iterator = await AWSTaskRunner.getLogIterator(stream);
const logBaseUrl = `https://${Input.region}.console.aws.amazon.com/cloudwatch/home?region=${Input.region}#logsV2:log-groups/log-group/${Orchestrator.buildParameters.awsStackName}${AWSTaskRunner.encodedUnderscore}${Orchestrator.buildParameters.awsStackName}-${Orchestrator.buildParameters.buildGuid}`;
OrchestratorLogger.log(`You view the log stream on AWS Cloud Watch: ${logBaseUrl}`);
await GitHub.updateGitHubCheck(`You view the log stream on AWS Cloud Watch: ${logBaseUrl}`, ``);
let shouldReadLogs = true;
let shouldCleanup = true;
let timestamp: number = 0;
let output = '';
while (shouldReadLogs) {
await new Promise((resolve) => setTimeout(resolve, 1500));
const taskData = await AWSTaskRunner.describeTasks(clusterName, taskArn);
({ timestamp, shouldReadLogs } = AWSTaskRunner.checkStreamingShouldContinue(taskData, timestamp, shouldReadLogs));
if (taskData?.lastStatus !== 'RUNNING') {
await new Promise((resolve) => setTimeout(resolve, 3500));
}
({ iterator, shouldReadLogs, output, shouldCleanup } = await AWSTaskRunner.handleLogStreamIteration(
iterator,
shouldReadLogs,
output,
shouldCleanup,
));
}
return { output, shouldCleanup };
}
private static async handleLogStreamIteration(
iterator: string,
shouldReadLogs: boolean,
output: string,
shouldCleanup: boolean,
) {
let records: any;
try {
records = await AwsClientFactory.getKinesis().send(new GetRecordsCommand({ ShardIterator: iterator }));
} catch (error: any) {
const isThrottle = error?.name === 'ThrottlingException' || /rate exceeded/i.test(String(error?.message));
if (isThrottle) {
const baseBackoffMs = 1000;
const jitterMs = Math.floor(Math.random() * 1000);
const sleepMs = baseBackoffMs + jitterMs;
OrchestratorLogger.log(`AWS throttled GetRecords, backing off ${sleepMs}ms (1000 + jitter ${jitterMs})`);
await new Promise((r) => setTimeout(r, sleepMs));
return { iterator, shouldReadLogs, output, shouldCleanup };
}
throw error;
}
iterator = records.NextShardIterator || '';
({ shouldReadLogs, output, shouldCleanup } = AWSTaskRunner.logRecords(
records,
iterator,
shouldReadLogs,
output,
shouldCleanup,
));
return { iterator, shouldReadLogs, output, shouldCleanup };
}
private static checkStreamingShouldContinue(taskData: any, timestamp: number, shouldReadLogs: boolean) {
if (taskData?.lastStatus === 'UNKNOWN') {
OrchestratorLogger.log('## Orchestrator job unknwon');
}
if (taskData?.lastStatus !== 'RUNNING') {
if (timestamp === 0) {
OrchestratorLogger.log('## Orchestrator job stopped, streaming end of logs');
timestamp = Date.now();
}
if (timestamp !== 0 && Date.now() - timestamp > 30000) {
OrchestratorLogger.log('## Orchestrator status is not RUNNING for 30 seconds, last query for logs');
shouldReadLogs = false;
}
OrchestratorLogger.log(`## Status of job: ${taskData.lastStatus}`);
}
return { timestamp, shouldReadLogs };
}
private static logRecords(
records: any,
iterator: string,
shouldReadLogs: boolean,
output: string,
shouldCleanup: boolean,
) {
if ((records.Records ?? []).length > 0 && iterator) {
for (const record of records.Records ?? []) {
const json = JSON.parse(
zlib.gunzipSync(Buffer.from(record.Data as unknown as string, 'base64')).toString('utf8'),
);
if (json.messageType === 'DATA_MESSAGE') {
for (const logEvent of json.logEvents) {
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
logEvent.message,
shouldReadLogs,
shouldCleanup,
output,
));
}
}
}
}
return { shouldReadLogs, output, shouldCleanup };
}
private static async getLogStream(kinesisStreamName: string) {
return await AwsClientFactory.getKinesis().send(new DescribeStreamCommand({ StreamName: kinesisStreamName }));
}
private static async getLogIterator(stream: any) {
return (
(
await AwsClientFactory.getKinesis().send(
new GetShardIteratorCommand({
ShardIteratorType: 'TRIM_HORIZON',
StreamName: stream.StreamDescription?.StreamName ?? '',
ShardId: stream.StreamDescription?.Shards?.[0]?.ShardId || '',
}),
)
).ShardIterator || ''
);
}
}
export default AWSTaskRunner;

View File

@@ -1,397 +0,0 @@
export class BaseStackFormation {
public static readonly baseStackDecription = `Game-CI base stack`;
public static readonly formation: string = `AWSTemplateFormatVersion: '2010-09-09'
Description: ${BaseStackFormation.baseStackDecription}
Parameters:
EnvironmentName:
Type: String
Default: development
Description: 'Your deployment environment: DEV, QA , PROD'
Version:
Type: String
Description: 'hash of template'
# ContainerPort:
# Type: Number
# Default: 80
# Description: What port number the application inside the docker container is binding to
Mappings:
# Hard values for the subnet masks. These masks define
# the range of internal IP addresses that can be assigned.
# The VPC can have all IP's from 10.0.0.0 to 10.0.255.255
# There are four subnets which cover the ranges:
#
# 10.0.0.0 - 10.0.0.255
# 10.0.1.0 - 10.0.1.255
# 10.0.2.0 - 10.0.2.255
# 10.0.3.0 - 10.0.3.255
SubnetConfig:
VPC:
CIDR: '10.0.0.0/16'
PublicOne:
CIDR: '10.0.0.0/24'
PublicTwo:
CIDR: '10.0.1.0/24'
Resources:
# VPC in which containers will be networked.
# It has two public subnets, and two private subnets.
# We distribute the subnets across the first two available subnets
# for the region, for high availability.
VPC:
Type: AWS::EC2::VPC
Properties:
EnableDnsSupport: true
EnableDnsHostnames: true
CidrBlock: !FindInMap ['SubnetConfig', 'VPC', 'CIDR']
MainBucket:
Type: "AWS::S3::Bucket"
Properties:
BucketName: !Ref EnvironmentName
EFSServerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: 'efs-server-endpoints'
GroupDescription: Which client ip addrs are allowed to access EFS server
VpcId: !Ref 'VPC'
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 2049
ToPort: 2049
SourceSecurityGroupId: !Ref ContainerSecurityGroup
#CidrIp: !FindInMap ['SubnetConfig', 'VPC', 'CIDR']
# A security group for the containers we will run in Fargate.
# Rules are added to this security group based on what ingress you
# add for the cluster.
ContainerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: 'task security group'
GroupDescription: Access to the Fargate containers
VpcId: !Ref 'VPC'
# SecurityGroupIngress:
# - IpProtocol: tcp
# FromPort: !Ref ContainerPort
# ToPort: !Ref ContainerPort
# CidrIp: 0.0.0.0/0
SecurityGroupEgress:
- IpProtocol: -1
FromPort: 2049
ToPort: 2049
CidrIp: '0.0.0.0/0'
# Two public subnets, where containers can have public IP addresses
PublicSubnetOne:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone: !Select
- 0
- Fn::GetAZs: !Ref 'AWS::Region'
VpcId: !Ref 'VPC'
CidrBlock: !FindInMap ['SubnetConfig', 'PublicOne', 'CIDR']
# MapPublicIpOnLaunch: true
PublicSubnetTwo:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone: !Select
- 1
- Fn::GetAZs: !Ref 'AWS::Region'
VpcId: !Ref 'VPC'
CidrBlock: !FindInMap ['SubnetConfig', 'PublicTwo', 'CIDR']
# MapPublicIpOnLaunch: true
# Setup networking resources for the public subnets. Containers
# in the public subnets have public IP addresses and the routing table
# sends network traffic via the internet gateway.
InternetGateway:
Type: AWS::EC2::InternetGateway
GatewayAttachement:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref 'VPC'
InternetGatewayId: !Ref 'InternetGateway'
# Attaching a Internet Gateway to route table makes it public.
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref 'VPC'
PublicRoute:
Type: AWS::EC2::Route
DependsOn: GatewayAttachement
Properties:
RouteTableId: !Ref 'PublicRouteTable'
DestinationCidrBlock: '0.0.0.0/0'
GatewayId: !Ref 'InternetGateway'
# Attaching a public route table makes a subnet public.
PublicSubnetOneRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnetOne
RouteTableId: !Ref PublicRouteTable
PublicSubnetTwoRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnetTwo
RouteTableId: !Ref PublicRouteTable
# ECS Resources
ECSCluster:
Type: AWS::ECS::Cluster
# A role used to allow AWS Autoscaling to inspect stats and adjust scaleable targets
# on your AWS account
AutoscalingRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: [application-autoscaling.amazonaws.com]
Action: ['sts:AssumeRole']
Path: /
Policies:
- PolicyName: service-autoscaling
PolicyDocument:
Statement:
- Effect: Allow
Action:
- 'application-autoscaling:*'
- 'cloudwatch:DescribeAlarms'
- 'cloudwatch:PutMetricAlarm'
- 'ecs:DescribeServices'
- 'ecs:UpdateService'
Resource: '*'
# This is an IAM role which authorizes ECS to manage resources on your
# account on your behalf, such as updating your load balancer with the
# details of where your containers are, so that traffic can reach your
# containers.
ECSRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: [ecs.amazonaws.com]
Action: ['sts:AssumeRole']
Path: /
Policies:
- PolicyName: ecs-service
PolicyDocument:
Statement:
- Effect: Allow
Action:
# Rules which allow ECS to attach network interfaces to instances
# on your behalf in order for awsvpc networking mode to work right
- 'ec2:AttachNetworkInterface'
- 'ec2:CreateNetworkInterface'
- 'ec2:CreateNetworkInterfacePermission'
- 'ec2:DeleteNetworkInterface'
- 'ec2:DeleteNetworkInterfacePermission'
- 'ec2:Describe*'
- 'ec2:DetachNetworkInterface'
# Rules which allow ECS to update load balancers on your behalf
# with the information sabout how to send traffic to your containers
- 'elasticloadbalancing:DeregisterInstancesFromLoadBalancer'
- 'elasticloadbalancing:DeregisterTargets'
- 'elasticloadbalancing:Describe*'
- 'elasticloadbalancing:RegisterInstancesWithLoadBalancer'
- 'elasticloadbalancing:RegisterTargets'
Resource: '*'
# This is a role which is used by the ECS tasks themselves.
ECSTaskExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: [ecs-tasks.amazonaws.com]
Action: ['sts:AssumeRole']
Path: /
Policies:
- PolicyName: AmazonECSTaskExecutionRolePolicy
PolicyDocument:
Statement:
- Effect: Allow
Action:
# Allow the use of secret manager
- 'secretsmanager:GetSecretValue'
- 'kms:Decrypt'
# Allow the ECS Tasks to download images from ECR
- 'ecr:GetAuthorizationToken'
- 'ecr:BatchCheckLayerAvailability'
- 'ecr:GetDownloadUrlForLayer'
- 'ecr:BatchGetImage'
# Allow the ECS tasks to upload logs to CloudWatch
- 'logs:CreateLogStream'
- 'logs:PutLogEvents'
Resource: '*'
DeleteCFNLambdaExecutionRole:
Type: 'AWS::IAM::Role'
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: 'Allow'
Principal:
Service: ['lambda.amazonaws.com']
Action: 'sts:AssumeRole'
Path: '/'
Policies:
- PolicyName: DeleteCFNLambdaExecutionRole
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: 'Allow'
Action:
- 'logs:CreateLogGroup'
- 'logs:CreateLogStream'
- 'logs:PutLogEvents'
Resource: 'arn:aws:logs:*:*:*'
- Effect: 'Allow'
Action:
- 'cloudformation:DeleteStack'
- 'kinesis:DeleteStream'
- 'secretsmanager:DeleteSecret'
- 'kinesis:DescribeStreamSummary'
- 'logs:DeleteLogGroup'
- 'logs:DeleteSubscriptionFilter'
- 'ecs:DeregisterTaskDefinition'
- 'lambda:DeleteFunction'
- 'lambda:InvokeFunction'
- 'events:RemoveTargets'
- 'events:DeleteRule'
- 'lambda:RemovePermission'
Resource: '*'
### cloud watch to kinesis role
CloudWatchIAMRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: [logs.amazonaws.com]
Action: ['sts:AssumeRole']
Path: /
Policies:
- PolicyName: service-autoscaling
PolicyDocument:
Statement:
- Effect: Allow
Action:
- 'kinesis:PutRecord'
Resource: '*'
#####################EFS#####################
EfsFileStorage:
Type: 'AWS::EFS::FileSystem'
Properties:
BackupPolicy:
Status: ENABLED
PerformanceMode: maxIO
Encrypted: false
FileSystemPolicy:
Version: '2012-10-17'
Statement:
- Effect: 'Allow'
Action:
- 'elasticfilesystem:ClientMount'
- 'elasticfilesystem:ClientWrite'
- 'elasticfilesystem:ClientRootAccess'
Principal:
AWS: '*'
MountTargetResource1:
Type: AWS::EFS::MountTarget
Properties:
FileSystemId: !Ref EfsFileStorage
SubnetId: !Ref PublicSubnetOne
SecurityGroups:
- !Ref EFSServerSecurityGroup
MountTargetResource2:
Type: AWS::EFS::MountTarget
Properties:
FileSystemId: !Ref EfsFileStorage
SubnetId: !Ref PublicSubnetTwo
SecurityGroups:
- !Ref EFSServerSecurityGroup
Outputs:
EfsFileStorageId:
Description: 'The connection endpoint for the database.'
Value: !Ref EfsFileStorage
Export:
Name: !Sub ${'${EnvironmentName}'}:EfsFileStorageId
ClusterName:
Description: The name of the ECS cluster
Value: !Ref 'ECSCluster'
Export:
Name: !Sub${' ${EnvironmentName}'}:ClusterName
AutoscalingRole:
Description: The ARN of the role used for autoscaling
Value: !GetAtt 'AutoscalingRole.Arn'
Export:
Name: !Sub ${'${EnvironmentName}'}:AutoscalingRole
ECSRole:
Description: The ARN of the ECS role
Value: !GetAtt 'ECSRole.Arn'
Export:
Name: !Sub ${'${EnvironmentName}'}:ECSRole
ECSTaskExecutionRole:
Description: The ARN of the ECS role tsk execution role
Value: !GetAtt 'ECSTaskExecutionRole.Arn'
Export:
Name: !Sub ${'${EnvironmentName}'}:ECSTaskExecutionRole
DeleteCFNLambdaExecutionRole:
Description: Lambda execution role for cleaning up cloud formations
Value: !GetAtt 'DeleteCFNLambdaExecutionRole.Arn'
Export:
Name: !Sub ${'${EnvironmentName}'}:DeleteCFNLambdaExecutionRole
CloudWatchIAMRole:
Description: The ARN of the CloudWatch role for subscription filter
Value: !GetAtt 'CloudWatchIAMRole.Arn'
Export:
Name: !Sub ${'${EnvironmentName}'}:CloudWatchIAMRole
VpcId:
Description: The ID of the VPC that this stack is deployed in
Value: !Ref 'VPC'
Export:
Name: !Sub ${'${EnvironmentName}'}:VpcId
PublicSubnetOne:
Description: Public subnet one
Value: !Ref 'PublicSubnetOne'
Export:
Name: !Sub ${'${EnvironmentName}'}:PublicSubnetOne
PublicSubnetTwo:
Description: Public subnet two
Value: !Ref 'PublicSubnetTwo'
Export:
Name: !Sub ${'${EnvironmentName}'}:PublicSubnetTwo
ContainerSecurityGroup:
Description: A security group used to allow Fargate containers to receive traffic
Value: !Ref 'ContainerSecurityGroup'
Export:
Name: !Sub ${'${EnvironmentName}'}:ContainerSecurityGroup
`;
}

View File

@@ -1,146 +0,0 @@
export class CleanupCronFormation {
public static readonly formation: string = `AWSTemplateFormatVersion: '2010-09-09'
Description: Schedule automatic deletion of CloudFormation stacks
Metadata:
AWS::CloudFormation::Interface:
ParameterGroups:
- Label:
default: Input configuration
Parameters:
- StackName
- TTL
ParameterLabels:
StackName:
default: Stack name
TTL:
default: Time-to-live
Parameters:
EnvironmentName:
Type: String
Default: development
Description: 'Your deployment environment: DEV, QA , PROD'
BUILDGUID:
Type: String
Default: ''
StackName:
Type: String
Description: Stack name that will be deleted.
DeleteStackName:
Type: String
Description: Stack name that will be deleted.
TTL:
Type: Number
Description: Time-to-live in minutes for the stack.
Resources:
DeleteCFNLambda:
Type: "AWS::Lambda::Function"
Properties:
FunctionName: !Join [ "", [ 'DeleteCFNLambda', !Ref BUILDGUID ] ]
Code:
ZipFile: |
import boto3
import os
import json
stack_name = os.environ['stackName']
delete_stack_name = os.environ['deleteStackName']
def delete_cfn(stack_name):
try:
cfn = boto3.resource('cloudformation')
stack = cfn.Stack(stack_name)
stack.delete()
return "SUCCESS"
except:
return "ERROR"
def handler(event, context):
print("Received event:")
print(json.dumps(event))
result = delete_cfn(stack_name)
delete_cfn(delete_stack_name)
return result
Environment:
Variables:
stackName: !Ref 'StackName'
deleteStackName: !Ref 'DeleteStackName'
Handler: "index.handler"
Runtime: "python3.9"
Timeout: "5"
Role:
'Fn::ImportValue': !Sub '\${EnvironmentName}:DeleteCFNLambdaExecutionRole'
DeleteStackEventRule:
DependsOn:
- DeleteCFNLambda
- GenerateCronExpression
Type: "AWS::Events::Rule"
Properties:
Name: !Join [ "", [ 'DeleteStackEventRule', !Ref BUILDGUID ] ]
Description: Delete stack event
ScheduleExpression: !GetAtt GenerateCronExpression.cron_exp
State: "ENABLED"
Targets:
-
Arn: !GetAtt DeleteCFNLambda.Arn
Id: 'DeleteCFNLambda'
PermissionForDeleteCFNLambda:
Type: "AWS::Lambda::Permission"
DependsOn:
- DeleteStackEventRule
Properties:
FunctionName: !Join [ "", [ 'DeleteCFNLambda', !Ref BUILDGUID ] ]
Action: "lambda:InvokeFunction"
Principal: "events.amazonaws.com"
SourceArn: !GetAtt DeleteStackEventRule.Arn
GenerateCronExpLambda:
Type: "AWS::Lambda::Function"
Properties:
FunctionName: !Join [ "", [ 'GenerateCronExpressionLambda', !Ref BUILDGUID ] ]
Code:
ZipFile: |
from datetime import datetime, timedelta
import os
import logging
import json
import cfnresponse
def deletion_time(ttl):
delete_at_time = datetime.now() + timedelta(minutes=int(ttl))
hh = delete_at_time.hour
mm = delete_at_time.minute
yyyy = delete_at_time.year
month = delete_at_time.month
dd = delete_at_time.day
# minutes hours day month day-of-week year
cron_exp = "cron({} {} {} {} ? {})".format(mm, hh, dd, month, yyyy)
return cron_exp
def handler(event, context):
print('Received event: %s' % json.dumps(event))
status = cfnresponse.SUCCESS
try:
if event['RequestType'] == 'Delete':
cfnresponse.send(event, context, status, {})
else:
ttl = event['ResourceProperties']['ttl']
responseData = {}
responseData['cron_exp'] = deletion_time(ttl)
cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData)
except Exception as e:
logging.error('Exception: %s' % e, exc_info=True)
status = cfnresponse.FAILED
cfnresponse.send(event, context, status, {}, None)
Handler: "index.handler"
Runtime: "python3.9"
Timeout: "5"
Role:
'Fn::ImportValue': !Sub '\${EnvironmentName}:DeleteCFNLambdaExecutionRole'
GenerateCronExpression:
Type: "Custom::GenerateCronExpression"
Version: "1.0"
Properties:
Name: !Join [ "", [ 'GenerateCronExpression', !Ref BUILDGUID ] ]
ServiceToken: !GetAtt GenerateCronExpLambda.Arn
ttl: !Ref 'TTL'
`;
}

View File

@@ -1,168 +0,0 @@
import Orchestrator from '../../../orchestrator';
export class TaskDefinitionFormation {
public static readonly description: string = `Game CI Orchestrator Task Stack`;
public static get formation(): string {
return `AWSTemplateFormatVersion: 2010-09-09
Description: ${TaskDefinitionFormation.description}
Parameters:
EnvironmentName:
Type: String
Default: development
Description: 'Your deployment environment: DEV, QA , PROD'
ServiceName:
Type: String
Default: example
Description: A name for the service
LogGroupName:
Type: String
Default: example
Description: Name to use for the log group created for this task
ImageUrl:
Type: String
Default: nginx
Description: >-
The url of a docker image that contains the application process that will
handle the traffic for this service
ContainerPort:
Type: Number
Default: 80
Description: What port number the application inside the docker container is binding to
ContainerCpu:
Default: ${Orchestrator.buildParameters.containerCpu}
Type: Number
Description: How much CPU to give the container. 1024 is 1 CPU
ContainerMemory:
Default: ${Orchestrator.buildParameters.containerMemory}
Type: Number
Description: How much memory in megabytes to give the container
BUILDGUID:
Type: String
Default: ''
Command:
Type: String
Default: 'ls'
EntryPoint:
Type: String
Default: '/bin/sh'
WorkingDirectory:
Type: String
Default: '/efsdata/'
Role:
Type: String
Default: ''
Description: >-
(Optional) An IAM role to give the service's containers if the code within
needs to access other AWS resources like S3 buckets, DynamoDB tables, etc
EFSMountDirectory:
Type: String
Default: '/efsdata'
# template secrets p1 - input
Mappings:
SubnetConfig:
VPC:
CIDR: 10.0.0.0/16
PublicOne:
CIDR: 10.0.0.0/24
PublicTwo:
CIDR: 10.0.1.0/24
Conditions:
HasCustomRole: !Not
- !Equals
- Ref: Role
- ''
Resources:
LogGroup:
Type: 'AWS::Logs::LogGroup'
Properties:
LogGroupName: !Ref LogGroupName
Metadata:
'AWS::CloudFormation::Designer':
id: aece53ae-b82d-4267-bc16-ed964b05db27
# template resources secrets
# template resources logstream
TaskDefinition:
Type: 'AWS::ECS::TaskDefinition'
Properties:
Family: !Ref ServiceName
Cpu: !Ref ContainerCpu
Memory: !Ref ContainerMemory
NetworkMode: awsvpc
Volumes:
- Name: efs-data
EFSVolumeConfiguration:
FilesystemId:
'Fn::ImportValue': !Sub '${'${EnvironmentName}'}:EfsFileStorageId'
TransitEncryption: DISABLED
RequiresCompatibilities:
- FARGATE
ExecutionRoleArn:
'Fn::ImportValue': !Sub '${'${EnvironmentName}'}:ECSTaskExecutionRole'
TaskRoleArn:
'Fn::If':
- HasCustomRole
- !Ref Role
- !Ref 'AWS::NoValue'
ContainerDefinitions:
- Name: !Ref ServiceName
Cpu: !Ref ContainerCpu
Memory: !Ref ContainerMemory
Image: !Ref ImageUrl
EntryPoint:
Fn::Split:
- ','
- !Ref EntryPoint
Command:
Fn::Split:
- ','
- !Ref Command
WorkingDirectory: !Ref WorkingDirectory
Environment:
- Name: ALLOW_EMPTY_PASSWORD
Value: 'yes'
# template - env vars
MountPoints:
- SourceVolume: efs-data
ContainerPath: !Ref EFSMountDirectory
ReadOnly: false
# template secrets p3 - container def
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref LogGroupName
awslogs-region: !Ref 'AWS::Region'
awslogs-stream-prefix: !Ref ServiceName
DependsOn:
- LogGroup
`;
}
public static streamLogs = `
SubscriptionFilter:
Type: 'AWS::Logs::SubscriptionFilter'
Properties:
FilterPattern: ''
RoleArn:
'Fn::ImportValue': !Sub '${'${EnvironmentName}'}:CloudWatchIAMRole'
LogGroupName: !Ref LogGroupName
DestinationArn:
'Fn::GetAtt':
- KinesisStream
- Arn
Metadata:
'AWS::CloudFormation::Designer':
id: 7f809e91-9e5d-4678-98c1-c5085956c480
DependsOn:
- LogGroup
- KinesisStream
KinesisStream:
Type: 'AWS::Kinesis::Stream'
Properties:
Name: !Ref ServiceName
ShardCount: 1
Metadata:
'AWS::CloudFormation::Designer':
id: c6f18447-b879-4696-8873-f981b2cedd2b
`;
}

View File

@@ -1,176 +0,0 @@
import { CloudFormation, DeleteStackCommand, waitUntilStackDeleteComplete } from '@aws-sdk/client-cloudformation';
import OrchestratorSecret from '../../options/orchestrator-secret';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def';
import AwsTaskRunner from './aws-task-runner';
import { ProviderInterface } from '../provider-interface';
import BuildParameters from '../../../build-parameters';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { AWSJobStack as AwsJobStack } from './aws-job-stack';
import { AWSBaseStack as AwsBaseStack } from './aws-base-stack';
import { Input } from '../../..';
import { GarbageCollectionService } from './services/garbage-collection-service';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { TaskService } from './services/task-service';
import OrchestratorOptions from '../../options/orchestrator-options';
import { AwsClientFactory } from './aws-client-factory';
import ResourceTracking from '../../services/core/resource-tracking';
const DEFAULT_STACK_WAIT_TIME_SECONDS = 600;
function getStackWaitTime(): number {
const overrideValue = Number(process.env.ORCHESTRATOR_AWS_STACK_WAIT_TIME ?? '');
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}
class AWSBuildEnvironment implements ProviderInterface {
private baseStackName: string;
constructor(buildParameters: BuildParameters) {
this.baseStackName = buildParameters.awsStackName;
}
async listResources(): Promise<ProviderResource[]> {
await TaskService.getCloudFormationJobStacks();
await TaskService.getLogGroups();
await TaskService.getTasks();
return [];
}
listWorkflow(): Promise<ProviderWorkflow[]> {
throw new Error('Method not implemented.');
}
async watchWorkflow(): Promise<string> {
return await TaskService.watch();
}
async listOtherResources(): Promise<string> {
await TaskService.getLogGroups();
return '';
}
async garbageCollect(
filter: string,
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
await GarbageCollectionService.cleanup(!previewOnly);
return ``;
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {}
async setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
process.env.AWS_REGION = Input.region;
const CF = AwsClientFactory.getCloudFormation();
await new AwsBaseStack(this.baseStackName).setupBaseStack(CF);
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
process.env.AWS_REGION = Input.region;
ResourceTracking.logAllocationSummary('aws workflow');
await ResourceTracking.logDiskUsageSnapshot('aws workflow (host)');
AwsClientFactory.getECS();
const CF = AwsClientFactory.getCloudFormation();
AwsClientFactory.getKinesis();
OrchestratorLogger.log(`AWS Region: ${CF.config.region}`);
const entrypoint = ['/bin/sh'];
const startTimeMs = Date.now();
const taskDef = await new AwsJobStack(this.baseStackName).setupCloudFormations(
CF,
buildGuid,
image,
entrypoint,
commands,
mountdir,
workingdir,
secrets,
);
let postRunTaskTimeMs;
try {
const postSetupStacksTimeMs = Date.now();
OrchestratorLogger.log(`Setup job time: ${Math.floor((postSetupStacksTimeMs - startTimeMs) / 1000)}s`);
const { output, shouldCleanup } = await AwsTaskRunner.runTask(taskDef, environment, commands);
postRunTaskTimeMs = Date.now();
OrchestratorLogger.log(`Run job time: ${Math.floor((postRunTaskTimeMs - postSetupStacksTimeMs) / 1000)}s`);
if (shouldCleanup) {
await this.cleanupResources(CF, taskDef);
}
const postCleanupTimeMs = Date.now();
if (postRunTaskTimeMs !== undefined)
OrchestratorLogger.log(`Cleanup job time: ${Math.floor((postCleanupTimeMs - postRunTaskTimeMs) / 1000)}s`);
return output;
} catch (error) {
OrchestratorLogger.log(`error running task ${error}`);
await this.cleanupResources(CF, taskDef);
throw error;
}
}
async cleanupResources(CF: CloudFormation, taskDef: OrchestratorAWSTaskDef) {
const stackWaitTimeSeconds = getStackWaitTime();
OrchestratorLogger.log(`Cleanup starting (waiting up to ${stackWaitTimeSeconds}s for stack deletion)`);
await CF.send(new DeleteStackCommand({ StackName: taskDef.taskDefStackName }));
if (OrchestratorOptions.useCleanupCron) {
await CF.send(new DeleteStackCommand({ StackName: `${taskDef.taskDefStackName}-cleanup` }));
}
await waitUntilStackDeleteComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
{
StackName: taskDef.taskDefStackName,
},
);
await waitUntilStackDeleteComplete(
{
client: CF,
maxWaitTime: stackWaitTimeSeconds,
},
{
StackName: `${taskDef.taskDefStackName}-cleanup`,
},
);
OrchestratorLogger.log(`Deleted Stack: ${taskDef.taskDefStackName}`);
OrchestratorLogger.log('Cleanup complete');
}
}
export default AWSBuildEnvironment;

View File

@@ -1,10 +0,0 @@
// eslint-disable-next-line import/named
import { StackResource } from '@aws-sdk/client-cloudformation';
class OrchestratorAWSTaskDef {
public taskDefStackName!: string;
public taskDefCloudFormation!: string;
public taskDefResources: StackResource[] | undefined;
public baseResources: StackResource[] | undefined;
}
export default OrchestratorAWSTaskDef;

View File

@@ -1,75 +0,0 @@
import { DeleteStackCommand, DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation';
import { DeleteLogGroupCommand } from '@aws-sdk/client-cloudwatch-logs';
import { StopTaskCommand } from '@aws-sdk/client-ecs';
import Input from '../../../../input';
import OrchestratorLogger from '../../../services/core/orchestrator-logger';
import { TaskService } from './task-service';
import { AwsClientFactory } from '../aws-client-factory';
export class GarbageCollectionService {
static isOlderThan1day(date: Date) {
const ageDate = new Date(date.getTime() - Date.now());
return ageDate.getDay() > 0;
}
public static async cleanup(deleteResources = false, OneDayOlderOnly: boolean = false) {
process.env.AWS_REGION = Input.region;
const CF = AwsClientFactory.getCloudFormation();
const ecs = AwsClientFactory.getECS();
const cwl = AwsClientFactory.getCloudWatchLogs();
const taskDefinitionsInUse = new Array();
const tasks = await TaskService.getTasks();
for (const task of tasks) {
const { taskElement, element } = task;
taskDefinitionsInUse.push(taskElement.taskDefinitionArn);
if (deleteResources && (!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(taskElement.createdAt!))) {
OrchestratorLogger.log(`Stopping task ${taskElement.containers?.[0].name}`);
await ecs.send(new StopTaskCommand({ task: taskElement.taskArn || '', cluster: element }));
}
}
const jobStacks = await TaskService.getCloudFormationJobStacks();
for (const element of jobStacks) {
if (
(await CF.send(new DescribeStackResourcesCommand({ StackName: element.StackName }))).StackResources?.some(
(x) => x.ResourceType === 'AWS::ECS::TaskDefinition' && taskDefinitionsInUse.includes(x.PhysicalResourceId),
)
) {
OrchestratorLogger.log(`Skipping ${element.StackName} - active task was running not deleting`);
return;
}
if (
deleteResources &&
(!OneDayOlderOnly || (element.CreationTime && GarbageCollectionService.isOlderThan1day(element.CreationTime)))
) {
if (element.StackName === 'game-ci' || element.TemplateDescription === 'Game-CI base stack') {
OrchestratorLogger.log(`Skipping ${element.StackName} ignore list`);
return;
}
OrchestratorLogger.log(`Deleting ${element.StackName}`);
await CF.send(new DeleteStackCommand({ StackName: element.StackName }));
}
}
const logGroups = await TaskService.getLogGroups();
for (const element of logGroups) {
if (
deleteResources &&
(!OneDayOlderOnly || GarbageCollectionService.isOlderThan1day(new Date(element.creationTime!)))
) {
OrchestratorLogger.log(`Deleting ${element.logGroupName}`);
await cwl.send(new DeleteLogGroupCommand({ logGroupName: element.logGroupName || '' }));
}
}
const locks = await TaskService.getLocks();
for (const element of locks) {
OrchestratorLogger.log(`Lock: ${element.Key}`);
}
}
}

View File

@@ -1,220 +0,0 @@
import {
DescribeStackResourcesCommand,
DescribeStacksCommand,
ListStacksCommand,
} from '@aws-sdk/client-cloudformation';
import type { StackSummary } from '@aws-sdk/client-cloudformation';
// eslint-disable-next-line import/named
import { DescribeLogGroupsCommand, DescribeLogGroupsCommandInput } from '@aws-sdk/client-cloudwatch-logs';
import type { LogGroup } from '@aws-sdk/client-cloudwatch-logs';
import { DescribeTasksCommand, ListClustersCommand, ListTasksCommand } from '@aws-sdk/client-ecs';
import type { Task } from '@aws-sdk/client-ecs';
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
import Input from '../../../../input';
import OrchestratorLogger from '../../../services/core/orchestrator-logger';
import { BaseStackFormation } from '../cloud-formations/base-stack-formation';
import AwsTaskRunner from '../aws-task-runner';
import Orchestrator from '../../../orchestrator';
import { AwsClientFactory } from '../aws-client-factory';
import SharedWorkspaceLocking from '../../../services/core/shared-workspace-locking';
export class TaskService {
static async watch() {
// eslint-disable-next-line no-unused-vars
const { output, shouldCleanup } = await AwsTaskRunner.streamLogsUntilTaskStops(
process.env.cluster || ``,
process.env.taskArn || ``,
process.env.streamName || ``,
);
return output;
}
public static async getCloudFormationJobStacks(): Promise<StackSummary[]> {
const result: StackSummary[] = [];
OrchestratorLogger.log(``);
OrchestratorLogger.log(`List Cloud Formation Stacks`);
process.env.AWS_REGION = Input.region;
const CF = AwsClientFactory.getCloudFormation();
const stacks =
(await CF.send(new ListStacksCommand({}))).StackSummaries?.filter(
(_x) =>
_x.StackStatus !== 'DELETE_COMPLETE' && _x.TemplateDescription !== BaseStackFormation.baseStackDecription,
) || [];
OrchestratorLogger.log(``);
OrchestratorLogger.log(`Cloud Formation Stacks ${stacks.length}`);
for (const element of stacks) {
if (!element.CreationTime) {
OrchestratorLogger.log(`${element.StackName} due to undefined CreationTime`);
}
const ageDate: Date = new Date(Date.now() - (element.CreationTime?.getTime() ?? 0));
OrchestratorLogger.log(
`Task Stack ${element.StackName} - Age D${Math.floor(
ageDate.getHours() / 24,
)} H${ageDate.getHours()} M${ageDate.getMinutes()}`,
);
result.push(element);
}
const baseStacks =
(await CF.send(new ListStacksCommand({}))).StackSummaries?.filter(
(_x) =>
_x.StackStatus !== 'DELETE_COMPLETE' && _x.TemplateDescription === BaseStackFormation.baseStackDecription,
) || [];
OrchestratorLogger.log(``);
OrchestratorLogger.log(`Base Stacks ${baseStacks.length}`);
for (const element of baseStacks) {
if (!element.CreationTime) {
OrchestratorLogger.log(`${element.StackName} due to undefined CreationTime`);
}
const ageDate: Date = new Date(Date.now() - (element.CreationTime?.getTime() ?? 0));
OrchestratorLogger.log(
`Task Stack ${element.StackName} - Age D${Math.floor(
ageDate.getHours() / 24,
)} H${ageDate.getHours()} M${ageDate.getMinutes()}`,
);
result.push(element);
}
OrchestratorLogger.log(``);
return result;
}
public static async getTasks(): Promise<{ taskElement: Task; element: string }[]> {
const result: { taskElement: Task; element: string }[] = [];
OrchestratorLogger.log(``);
OrchestratorLogger.log(`List Tasks`);
process.env.AWS_REGION = Input.region;
const ecs = AwsClientFactory.getECS();
const clusters: string[] = [];
{
let nextToken: string | undefined;
do {
const clusterResponse = await ecs.send(new ListClustersCommand({ nextToken }));
clusters.push(...(clusterResponse.clusterArns ?? []));
nextToken = clusterResponse.nextToken;
} while (nextToken);
}
OrchestratorLogger.log(`Task Clusters ${clusters.length}`);
for (const element of clusters) {
const taskArns: string[] = [];
{
let nextToken: string | undefined;
do {
const taskResponse = await ecs.send(new ListTasksCommand({ cluster: element, nextToken }));
taskArns.push(...(taskResponse.taskArns ?? []));
nextToken = taskResponse.nextToken;
} while (nextToken);
}
if (taskArns.length > 0) {
const describeInput = { tasks: taskArns, cluster: element };
const describeList = (await ecs.send(new DescribeTasksCommand(describeInput))).tasks || [];
if (describeList.length === 0) {
OrchestratorLogger.log(`No Tasks`);
continue;
}
OrchestratorLogger.log(`Tasks ${describeList.length}`);
for (const taskElement of describeList) {
if (taskElement === undefined) {
continue;
}
if (taskElement.createdAt === undefined) {
OrchestratorLogger.log(`Skipping ${taskElement.taskDefinitionArn} no createdAt date`);
continue;
}
result.push({ taskElement, element });
}
}
}
OrchestratorLogger.log(``);
return result;
}
public static async awsDescribeJob(job: string) {
process.env.AWS_REGION = Input.region;
const CF = AwsClientFactory.getCloudFormation();
try {
const stack =
(await CF.send(new ListStacksCommand({}))).StackSummaries?.find((_x) => _x.StackName === job) || undefined;
const stackInfo = (await CF.send(new DescribeStackResourcesCommand({ StackName: job }))) || undefined;
const stackInfo2 = (await CF.send(new DescribeStacksCommand({ StackName: job }))) || undefined;
if (stack === undefined) {
throw new Error('stack not defined');
}
if (!stack.CreationTime) {
OrchestratorLogger.log(`${stack.StackName} due to undefined CreationTime`);
}
const ageDate: Date = new Date(Date.now() - (stack.CreationTime?.getTime() ?? 0));
const message = `
Task Stack ${stack.StackName}
Age D${Math.floor(ageDate.getHours() / 24)} H${ageDate.getHours()} M${ageDate.getMinutes()}
${JSON.stringify(stack, undefined, 4)}
${JSON.stringify(stackInfo, undefined, 4)}
${JSON.stringify(stackInfo2, undefined, 4)}
`;
OrchestratorLogger.log(message);
return message;
} catch (error) {
OrchestratorLogger.error(
`Failed to describe job ${job}: ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
}
}
public static async getLogGroups(): Promise<LogGroup[]> {
const result: LogGroup[] = [];
process.env.AWS_REGION = Input.region;
const cwl = AwsClientFactory.getCloudWatchLogs();
let logStreamInput: DescribeLogGroupsCommandInput = {
/* logGroupNamePrefix: 'game-ci' */
};
let logGroupsDescribe = await cwl.send(new DescribeLogGroupsCommand(logStreamInput));
const logGroups = logGroupsDescribe.logGroups || [];
while (logGroupsDescribe.nextToken) {
logStreamInput = {
/* logGroupNamePrefix: 'game-ci',*/
nextToken: logGroupsDescribe.nextToken,
};
logGroupsDescribe = await cwl.send(new DescribeLogGroupsCommand(logStreamInput));
logGroups.push(...(logGroupsDescribe?.logGroups || []));
}
OrchestratorLogger.log(`Log Groups ${logGroups.length}`);
for (const element of logGroups) {
if (element.creationTime === undefined) {
OrchestratorLogger.log(`Skipping ${element.logGroupName} no createdAt date`);
continue;
}
const ageDate: Date = new Date(Date.now() - element.creationTime);
OrchestratorLogger.log(
`Task Stack ${element.logGroupName} - Age D${Math.floor(
ageDate.getHours() / 24,
)} H${ageDate.getHours()} M${ageDate.getMinutes()}`,
);
result.push(element);
}
return result;
}
public static async getLocks(): Promise<Array<{ Key: string }>> {
process.env.AWS_REGION = Input.region;
if (Orchestrator.buildParameters.storageProvider === 'rclone') {
// eslint-disable-next-line no-unused-vars
type ListObjectsFunction = (prefix: string) => Promise<string[]>;
const objects = await (SharedWorkspaceLocking as unknown as { listObjects: ListObjectsFunction }).listObjects('');
return objects.map((x: string) => ({ Key: x }));
}
const s3 = AwsClientFactory.getS3();
const listRequest = {
Bucket: Orchestrator.buildParameters.awsStackName,
};
const results = await s3.send(new ListObjectsV2Command(listRequest));
return (results.Contents || []).map((object) => ({ Key: object.Key || '' }));
}
}

View File

@@ -1,196 +0,0 @@
import BuildParameters from '../../../build-parameters';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import Docker from '../../../docker';
import { Action } from '../../..';
import { writeFileSync } from 'node:fs';
import Orchestrator from '../../orchestrator';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import * as fs from 'node:fs';
import { CommandHookService } from '../../services/hooks/command-hook-service';
import { StringKeyValuePair } from '../../../shared-types';
class LocalDockerOrchestrator implements ProviderInterface {
public buildParameters!: BuildParameters;
listResources(): Promise<ProviderResource[]> {
return new Promise((resolve) => resolve([]));
}
listWorkflow(): Promise<ProviderWorkflow[]> {
throw new Error('Method not implemented.');
}
watchWorkflow(): Promise<string> {
throw new Error('Method not implemented.');
}
garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
return new Promise((result) => result(``));
}
async cleanupWorkflow(
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
const { workspace } = Action;
if (
fs.existsSync(
`${workspace}/orchestrator-cache/cache/build/build-${buildParameters.buildGuid}.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
}`,
)
) {
await OrchestratorSystem.Run(`ls ${workspace}/orchestrator-cache/cache/build/`);
await OrchestratorSystem.Run(
`rm -r ${workspace}/orchestrator-cache/cache/build/build-${buildParameters.buildGuid}.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
}`,
);
}
}
setupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
this.buildParameters = buildParameters;
}
public async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(buildGuid);
OrchestratorLogger.log(commands);
const { workspace, actionFolder } = Action;
const content: StringKeyValuePair[] = [];
for (const x of secrets) {
content.push({ name: x.EnvironmentVariable, value: x.ParameterValue });
}
// Replace localhost with host.docker.internal for LocalStack endpoints (similar to K8s)
// This allows Docker containers to access LocalStack running on the host
const endpointEnvironmentNames = new Set([
'AWS_S3_ENDPOINT',
'AWS_ENDPOINT',
'AWS_CLOUD_FORMATION_ENDPOINT',
'AWS_ECS_ENDPOINT',
'AWS_KINESIS_ENDPOINT',
'AWS_CLOUD_WATCH_LOGS_ENDPOINT',
'INPUT_AWSS3ENDPOINT',
'INPUT_AWSENDPOINT',
]);
for (const x of environment) {
let value = x.value;
if (
typeof value === 'string' &&
endpointEnvironmentNames.has(x.name) &&
(value.startsWith('http://localhost') || value.startsWith('http://127.0.0.1'))
) {
// Replace localhost with host.docker.internal so containers can access host services
value = value
.replace('http://localhost', 'http://host.docker.internal')
.replace('http://127.0.0.1', 'http://host.docker.internal');
OrchestratorLogger.log(`Replaced localhost with host.docker.internal for ${x.name}: ${value}`);
}
content.push({ name: x.name, value });
}
// if (this.buildParameters?.orchestratorIntegrationTests) {
// core.info(JSON.stringify(content, undefined, 4));
// core.info(JSON.stringify(secrets, undefined, 4));
// core.info(JSON.stringify(environment, undefined, 4));
// }
// eslint-disable-next-line unicorn/no-for-loop
for (let index = 0; index < content.length; index++) {
if (content[index] === undefined) {
delete content[index];
}
}
let myOutput = '';
const sharedFolder = `/data/`;
// core.info(JSON.stringify({ workspace, actionFolder, ...this.buildParameters, ...content }, undefined, 4));
const entrypointFilePath = `start.sh`;
// Use #!/bin/sh for POSIX compatibility (Alpine-based images like rclone/rclone don't have bash)
const fileContents = `#!/bin/sh
set -e
mkdir -p /github/workspace/orchestrator-cache
mkdir -p /data/cache
cp -a /github/workspace/orchestrator-cache/. ${sharedFolder}
${CommandHookService.ApplyHooksToCommands(commands, this.buildParameters)}
# Only copy cache directory, exclude retained workspaces to avoid running out of disk space
if [ -d "${sharedFolder}cache" ]; then
cp -a ${sharedFolder}cache/. /github/workspace/orchestrator-cache/cache/ || true
fi
# Copy test files from /data/ root to workspace for test assertions
# This allows tests to write files to /data/ and have them available in the workspace
find ${sharedFolder} -maxdepth 1 -type f -name "test-*" -exec cp -a {} /github/workspace/orchestrator-cache/ \\; || true
`;
writeFileSync(`${workspace}/${entrypointFilePath}`, fileContents, {
flag: 'w',
});
if (Orchestrator.buildParameters.orchestratorDebug) {
OrchestratorLogger.log(`Running local-docker: \n ${fileContents}`);
}
if (fs.existsSync(`${workspace}/orchestrator-cache`)) {
await OrchestratorSystem.Run(`ls ${workspace}/orchestrator-cache && du -sh ${workspace}/orchestrator-cache`);
}
const exitCode = await Docker.run(
image,
{ workspace, actionFolder, ...this.buildParameters },
false,
`chmod +x /github/workspace/${entrypointFilePath} && /github/workspace/${entrypointFilePath}`,
content,
{
listeners: {
stdout: (data: Buffer) => {
myOutput += data.toString();
},
stderr: (data: Buffer) => {
myOutput += `[LOCAL-DOCKER-ERROR]${data.toString()}`;
},
},
},
true,
);
// Docker doesn't exit on fail now so adding this to ensure behavior is unchanged
// TODO: Is there a helpful way to consume the exit code or is it best to except
if (exitCode !== 0) {
throw new Error(`Build failed with exit code ${exitCode}`);
}
return myOutput;
}
}
export default LocalDockerOrchestrator;

View File

@@ -1,460 +0,0 @@
import * as k8s from '@kubernetes/client-node';
import { BuildParameters } from '../../..';
import * as core from '@actions/core';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import KubernetesStorage from './kubernetes-storage';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import KubernetesTaskRunner from './kubernetes-task-runner';
import KubernetesSecret from './kubernetes-secret';
import KubernetesJobSpecFactory from './kubernetes-job-spec-factory';
import KubernetesServiceAccount from './kubernetes-service-account';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { CoreV1Api } from '@kubernetes/client-node';
import Orchestrator from '../../orchestrator';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
import { KubernetesRole } from './kubernetes-role';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import ResourceTracking from '../../services/core/resource-tracking';
class Kubernetes implements ProviderInterface {
public static Instance: Kubernetes;
public kubeConfig!: k8s.KubeConfig;
public kubeClient!: k8s.CoreV1Api;
public kubeClientApps!: k8s.AppsV1Api;
public kubeClientBatch!: k8s.BatchV1Api;
public rbacAuthorizationV1Api!: k8s.RbacAuthorizationV1Api;
public buildGuid: string = '';
public buildParameters!: BuildParameters;
public pvcName: string = '';
public secretName: string = '';
public jobName: string = '';
public namespace!: string;
public podName: string = '';
public containerName: string = '';
public cleanupCronJobName: string = '';
public serviceAccountName: string = '';
public ip: string = '';
constructor(buildParameters: BuildParameters) {
Kubernetes.Instance = this;
this.kubeConfig = new k8s.KubeConfig();
this.kubeConfig.loadFromDefault();
this.kubeClient = this.kubeConfig.makeApiClient(k8s.CoreV1Api);
this.kubeClientApps = this.kubeConfig.makeApiClient(k8s.AppsV1Api);
this.kubeClientBatch = this.kubeConfig.makeApiClient(k8s.BatchV1Api);
this.rbacAuthorizationV1Api = this.kubeConfig.makeApiClient(k8s.RbacAuthorizationV1Api);
this.namespace = buildParameters.containerNamespace ? buildParameters.containerNamespace : 'default';
OrchestratorLogger.log('Loaded default Kubernetes configuration for this environment');
}
async PushLogUpdate(logs: string) {
// push logs to nginx file server via 'LOG_SERVICE_IP' env var
const ip = process.env[`LOG_SERVICE_IP`];
if (ip === undefined) {
RemoteClientLogger.logWarning(`LOG_SERVICE_IP not set, skipping log push`);
return;
}
const url = `http://${ip}/api/log`;
RemoteClientLogger.log(`Pushing logs to ${url}`);
// logs to base64
logs = Buffer.from(logs).toString('base64');
const response = await OrchestratorSystem.Run(`curl -X POST -d "${logs}" ${url}`, false, true);
RemoteClientLogger.log(`Pushed logs to ${url} ${response}`);
}
async listResources(): Promise<ProviderResource[]> {
const pods = await this.kubeClient.listNamespacedPod(this.namespace);
const serviceAccounts = await this.kubeClient.listNamespacedServiceAccount(this.namespace);
const secrets = await this.kubeClient.listNamespacedSecret(this.namespace);
const jobs = await this.kubeClientBatch.listNamespacedJob(this.namespace);
return [
...pods.body.items.map((x) => {
return { Name: x.metadata?.name || `` };
}),
...serviceAccounts.body.items.map((x) => {
return { Name: x.metadata?.name || `` };
}),
...secrets.body.items.map((x) => {
return { Name: x.metadata?.name || `` };
}),
...jobs.body.items.map((x) => {
return { Name: x.metadata?.name || `` };
}),
];
}
listWorkflow(): Promise<ProviderWorkflow[]> {
throw new Error('Method not implemented.');
}
watchWorkflow(): Promise<string> {
throw new Error('Method not implemented.');
}
garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
return new Promise((result) => result(``));
}
public async setupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
try {
this.buildParameters = buildParameters;
this.cleanupCronJobName = `unity-builder-cronjob-${buildParameters.buildGuid}`;
this.serviceAccountName = `service-account-${buildParameters.buildGuid}`;
await KubernetesServiceAccount.createServiceAccount(this.serviceAccountName, this.namespace, this.kubeClient);
} catch (error) {
throw error;
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
try {
OrchestratorLogger.log('Orchestrator K8s workflow!');
ResourceTracking.logAllocationSummary('k8s workflow');
await ResourceTracking.logDiskUsageSnapshot('k8s workflow (host)');
await ResourceTracking.logK3dNodeDiskUsage('k8s workflow (before job)');
// Setup
const id =
BuildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(this.buildParameters)
? Orchestrator.lockedWorkspace
: this.buildParameters.buildGuid;
this.pvcName = `unity-builder-pvc-${id}`;
await KubernetesStorage.createPersistentVolumeClaim(
this.buildParameters,
this.pvcName,
this.kubeClient,
this.namespace,
);
this.buildGuid = buildGuid;
this.secretName = `build-credentials-${this.buildGuid}`;
this.jobName = `unity-builder-job-${this.buildGuid}`;
this.containerName = `main`;
await KubernetesSecret.createSecret(secrets, this.secretName, this.namespace, this.kubeClient);
// For tests, clean up old images before creating job to free space for image pull
// IMPORTANT: Preserve the Unity image to avoid re-pulling it
if (process.env['orchestratorTests'] === 'true') {
try {
OrchestratorLogger.log('Cleaning up old images in k3d node before pulling new image...');
const { OrchestratorSystem: OrchestratorSystemModule } = await import(
'../../services/core/orchestrator-system'
);
// Aggressive cleanup: remove stopped containers and non-Unity images
// IMPORTANT: Preserve Unity images (unityci/editor) to avoid re-pulling the 3.9GB image
const K3D_NODE_CONTAINERS = ['k3d-unity-builder-agent-0', 'k3d-unity-builder-server-0'];
const cleanupCommands: string[] = [];
for (const NODE of K3D_NODE_CONTAINERS) {
// Remove all stopped containers (this frees runtime space but keeps images)
cleanupCommands.push(
`docker exec ${NODE} sh -c "crictl rm --all 2>/dev/null || true" || true`,
`docker exec ${NODE} sh -c "for img in $(crictl images -q 2>/dev/null); do repo=$(crictl inspecti $img --format '{{.repo}}' 2>/dev/null || echo ''); if echo "$repo" | grep -qvE 'unityci/editor|unity'; then crictl rmi $img 2>/dev/null || true; fi; done" || true`,
`docker exec ${NODE} sh -c "crictl rmi --prune 2>/dev/null || true" || true`,
);
}
for (const cmd of cleanupCommands) {
try {
await OrchestratorSystemModule.Run(cmd, true, true);
} catch (cmdError) {
// Ignore individual command failures - cleanup is best effort
OrchestratorLogger.log(`Cleanup command failed (non-fatal): ${cmdError}`);
}
}
OrchestratorLogger.log('Cleanup completed (containers and non-Unity images removed, Unity images preserved)');
} catch (cleanupError) {
OrchestratorLogger.logWarning(`Failed to cleanup images before job creation: ${cleanupError}`);
// Continue anyway - image might already be cached
}
}
let output = '';
try {
// Before creating the job, verify we have the Unity image cached on the agent node
// If not cached, try to ensure it's available to avoid disk pressure during pull
if (process.env['orchestratorTests'] === 'true' && image.includes('unityci/editor')) {
try {
const { OrchestratorSystem: OrchestratorSystemModule2 } = await import(
'../../services/core/orchestrator-system'
);
// Check if image is cached on agent node (where pods run)
const agentImageCheck = await OrchestratorSystemModule2.Run(
`docker exec k3d-unity-builder-agent-0 sh -c "crictl images | grep -q unityci/editor && echo 'cached' || echo 'not_cached'" || echo 'not_cached'`,
true,
true,
);
if (agentImageCheck.includes('not_cached')) {
// Check if image is on server node
const serverImageCheck = await OrchestratorSystemModule2.Run(
`docker exec k3d-unity-builder-server-0 sh -c "crictl images | grep -q unityci/editor && echo 'cached' || echo 'not_cached'" || echo 'not_cached'`,
true,
true,
);
// Check available disk space on agent node
const diskInfo = await OrchestratorSystemModule2.Run(
'docker exec k3d-unity-builder-agent-0 sh -c "df -h /var/lib/rancher/k3s 2>/dev/null | tail -1 || df -h / 2>/dev/null | tail -1 || echo unknown" || echo unknown',
true,
true,
);
OrchestratorLogger.logWarning(
`Unity image not cached on agent node (where pods run). Server node: ${
serverImageCheck.includes('cached') ? 'has image' : 'no image'
}. Disk info: ${diskInfo.trim()}. Pod will attempt to pull image (3.9GB) which may fail due to disk pressure.`,
);
// If image is on server but not agent, log a warning
// NOTE: We don't attempt to pull here because:
// 1. Pulling a 3.9GB image can take several minutes and block the test
// 2. If there's not enough disk space, the pull will hang indefinitely
// 3. The pod will attempt to pull during scheduling anyway
// 4. If the pull fails, Kubernetes will provide proper error messages
if (serverImageCheck.includes('cached')) {
OrchestratorLogger.logWarning(
'Unity image exists on server node but not agent node. Pod will attempt to pull during scheduling. If pull fails due to disk pressure, ensure cleanup runs before this test.',
);
} else {
// Image not on either node - check if we have enough space to pull
// Extract available space from disk info
const availableSpaceMatch = diskInfo.match(/(\d+(?:\.\d+)?)\s*([gkm]?i?b)/i);
if (availableSpaceMatch) {
const availableValue = Number.parseFloat(availableSpaceMatch[1]);
const availableUnit = availableSpaceMatch[2].toUpperCase();
let availableGB = availableValue;
if (availableUnit.includes('M')) {
availableGB = availableValue / 1024;
} else if (availableUnit.includes('K')) {
availableGB = availableValue / (1024 * 1024);
}
// Unity image is ~3.9GB, need at least 4.5GB to be safe
if (availableGB < 4.5) {
OrchestratorLogger.logWarning(
`CRITICAL: Unity image not cached and only ${availableGB.toFixed(
2,
)}GB available. Image pull (3.9GB) will likely fail. Consider running cleanup or ensuring pre-pull step succeeds.`,
);
}
}
}
} else {
OrchestratorLogger.log('Unity image is cached on agent node - pod should start without pulling');
}
} catch (checkError) {
// Ignore check errors - continue with job creation
OrchestratorLogger.logWarning(`Failed to verify Unity image cache: ${checkError}`);
}
}
OrchestratorLogger.log('Job does not exist');
await this.createJob(commands, image, mountdir, workingdir, environment, secrets);
OrchestratorLogger.log('Watching pod until running');
await KubernetesTaskRunner.watchUntilPodRunning(this.kubeClient, this.podName, this.namespace);
OrchestratorLogger.log('Pod is running');
output += await KubernetesTaskRunner.runTask(
this.kubeConfig,
this.kubeClient,
this.jobName,
this.podName,
this.containerName,
this.namespace,
);
} catch (error: any) {
OrchestratorLogger.log(`error running k8s workflow ${error}`);
await new Promise((resolve) => setTimeout(resolve, 3000));
OrchestratorLogger.log(
JSON.stringify(
(await this.kubeClient.listNamespacedEvent(this.namespace)).body.items
.map((x) => {
return {
message: x.message || ``,
name: x.metadata.name || ``,
reason: x.reason || ``,
};
})
.filter((x) => x.name.includes(this.podName)),
undefined,
4,
),
);
await this.cleanupTaskResources();
throw error;
}
await this.cleanupTaskResources();
return output;
} catch (error) {
OrchestratorLogger.log('Running job failed');
core.error(JSON.stringify(error, undefined, 4));
// await this.cleanupTaskResources();
throw error;
}
}
private async createJob(
commands: string,
image: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
) {
await this.createNamespacedJob(commands, image, mountdir, workingdir, environment, secrets);
const find = await Kubernetes.findPodFromJob(this.kubeClient, this.jobName, this.namespace);
this.setPodNameAndContainerName(find);
}
private async doesJobExist(name: string) {
const jobs = await this.kubeClientBatch.listNamespacedJob(this.namespace);
return jobs.body.items.some((x) => x.metadata?.name === name);
}
private async doesFailedJobExist() {
const podStatus = await this.kubeClient.readNamespacedPodStatus(this.podName, this.namespace);
return podStatus.body.status?.phase === `Failed`;
}
private async createNamespacedJob(
commands: string,
image: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
) {
for (let index = 0; index < 3; index++) {
try {
const jobSpec = KubernetesJobSpecFactory.getJobSpec(
commands,
image,
mountdir,
workingdir,
environment,
secrets,
this.buildGuid,
this.buildParameters,
this.secretName,
this.pvcName,
this.jobName,
k8s,
this.containerName,
this.ip,
);
await new Promise((promise) => setTimeout(promise, 15000));
// await KubernetesRole.createRole(this.serviceAccountName, this.namespace, this.rbacAuthorizationV1Api);
const result = await this.kubeClientBatch.createNamespacedJob(this.namespace, jobSpec);
OrchestratorLogger.log(`Build job created`);
await new Promise((promise) => setTimeout(promise, 5000));
OrchestratorLogger.log('Job created');
return result.body.metadata?.name;
} catch (error) {
OrchestratorLogger.log(`Error occured creating job: ${error}`);
throw error;
}
}
}
setPodNameAndContainerName(pod: k8s.V1Pod) {
this.podName = pod.metadata?.name || '';
this.containerName = pod.status?.containerStatuses?.[0].name || this.containerName;
}
async cleanupTaskResources() {
OrchestratorLogger.log('cleaning up');
try {
await this.kubeClientBatch.deleteNamespacedJob(this.jobName, this.namespace);
await this.kubeClient.deleteNamespacedPod(this.podName, this.namespace);
await KubernetesRole.deleteRole(this.serviceAccountName, this.namespace, this.rbacAuthorizationV1Api);
} catch (error: any) {
OrchestratorLogger.log(`Failed to cleanup`);
if (error.response.body.reason !== `NotFound`) {
OrchestratorLogger.log(`Wasn't a not found error: ${error.response.body.reason}`);
throw error;
}
}
try {
await this.kubeClient.deleteNamespacedSecret(this.secretName, this.namespace);
} catch (error: any) {
OrchestratorLogger.log(`Failed to cleanup secret`);
OrchestratorLogger.log(error.response.body.reason);
}
OrchestratorLogger.log('cleaned up Secret, Job and Pod');
OrchestratorLogger.log('cleaning up finished');
}
async cleanupWorkflow(
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
if (BuildParameters && BuildParameters.shouldUseRetainedWorkspaceMode(buildParameters)) {
return;
}
OrchestratorLogger.log(`deleting PVC`);
try {
await this.kubeClient.deleteNamespacedPersistentVolumeClaim(this.pvcName, this.namespace);
await this.kubeClient.deleteNamespacedServiceAccount(this.serviceAccountName, this.namespace);
OrchestratorLogger.log('cleaned up PVC and Service Account');
} catch (error: any) {
OrchestratorLogger.log(`Cleanup failed ${JSON.stringify(error, undefined, 4)}`);
throw error;
}
}
static async findPodFromJob(kubeClient: CoreV1Api, jobName: string, namespace: string) {
const namespacedPods = await kubeClient.listNamespacedPod(namespace);
const pod = namespacedPods.body.items.find((x) => x.metadata?.labels?.['job-name'] === jobName);
if (pod === undefined) {
throw new Error("pod with job-name label doesn't exist");
}
return pod;
}
}
export default Kubernetes;

View File

@@ -1,208 +0,0 @@
import { V1EnvVar, V1EnvVarSource, V1SecretKeySelector } from '@kubernetes/client-node';
import BuildParameters from '../../../build-parameters';
import { CommandHookService } from '../../services/hooks/command-hook-service';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorSecret from '../../options/orchestrator-secret';
import Orchestrator from '../../orchestrator';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
class KubernetesJobSpecFactory {
static getJobSpec(
command: string,
image: string,
mountdir: string,
workingDirectory: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
buildGuid: string,
buildParameters: BuildParameters,
secretName: string,
pvcName: string,
jobName: string,
k8s: any,
containerName: string,
ip: string = '',
) {
const endpointEnvironmentNames = new Set([
'AWS_S3_ENDPOINT',
'AWS_ENDPOINT',
'AWS_CLOUD_FORMATION_ENDPOINT',
'AWS_ECS_ENDPOINT',
'AWS_KINESIS_ENDPOINT',
'AWS_CLOUD_WATCH_LOGS_ENDPOINT',
'INPUT_AWSS3ENDPOINT',
'INPUT_AWSENDPOINT',
]);
// Determine the LocalStack hostname to use for K8s pods
// Priority: K8S_LOCALSTACK_HOST env var > localstack-main (container name on shared network)
// Note: Using K8S_LOCALSTACK_HOST instead of LOCALSTACK_HOST to avoid conflict with awslocal CLI
const localstackHost = process.env['K8S_LOCALSTACK_HOST'] || 'localstack-main';
OrchestratorLogger.log(`K8s pods will use LocalStack host: ${localstackHost}`);
const adjustedEnvironment = environment.map((x) => {
let value = x.value;
if (
typeof value === 'string' &&
endpointEnvironmentNames.has(x.name) &&
(value.startsWith('http://localhost') || value.startsWith('http://127.0.0.1'))
) {
// Replace localhost with the LocalStack container hostname
// When k3d and LocalStack are on the same Docker network, pods can reach LocalStack by container name
value = value
.replace('http://localhost', `http://${localstackHost}`)
.replace('http://127.0.0.1', `http://${localstackHost}`);
OrchestratorLogger.log(`Replaced localhost with ${localstackHost} for ${x.name}: ${value}`);
}
return { name: x.name, value } as OrchestratorEnvironmentVariable;
});
const job = new k8s.V1Job();
job.apiVersion = 'batch/v1';
job.kind = 'Job';
job.metadata = {
name: jobName,
labels: {
app: 'unity-builder',
buildGuid,
},
};
// Reduce TTL for tests to free up resources faster (default 9999s = ~2.8 hours)
// For CI/test environments, use shorter TTL (300s = 5 minutes) to prevent disk pressure
const jobTTL = process.env['orchestratorTests'] === 'true' ? 300 : 9999;
job.spec = {
ttlSecondsAfterFinished: jobTTL,
backoffLimit: 0,
template: {
spec: {
terminationGracePeriodSeconds: 90, // Give PreStopHook (60s sleep) time to complete
volumes: [
{
name: 'build-mount',
persistentVolumeClaim: {
claimName: pvcName,
},
},
],
containers: [
{
ttlSecondsAfterFinished: 9999,
name: containerName,
image,
imagePullPolicy: process.env['orchestratorTests'] === 'true' ? 'IfNotPresent' : 'Always',
command: ['/bin/sh'],
args: [
'-c',
`${CommandHookService.ApplyHooksToCommands(`${command}\nsleep 2m`, Orchestrator.buildParameters)}`,
],
workingDir: `${workingDirectory}`,
resources: {
requests: (() => {
// Use smaller resource requests for lightweight hook containers
// Hook containers typically use utility images like aws-cli, rclone, etc.
const lightweightImages = ['amazon/aws-cli', 'rclone/rclone', 'steamcmd/steamcmd', 'ubuntu'];
const isLightweightContainer = lightweightImages.some((lightImage) => image.includes(lightImage));
if (isLightweightContainer && process.env['orchestratorTests'] === 'true') {
// For test environments, use minimal resources for hook containers
return {
memory: '128Mi',
cpu: '100m', // 0.1 CPU
};
}
// For main build containers, use the configured resources
const memoryMB = Number.parseInt(buildParameters.containerMemory);
const cpuMB = Number.parseInt(buildParameters.containerCpu);
return {
memory: !Number.isNaN(memoryMB) && memoryMB > 0 ? `${memoryMB / 1024}G` : '750M',
cpu: !Number.isNaN(cpuMB) && cpuMB > 0 ? `${cpuMB / 1024}` : '1',
};
})(),
},
env: [
...adjustedEnvironment.map((x) => {
const environmentVariable = new V1EnvVar();
environmentVariable.name = x.name;
environmentVariable.value = x.value;
return environmentVariable;
}),
...secrets.map((x) => {
const secret = new V1EnvVarSource();
secret.secretKeyRef = new V1SecretKeySelector();
secret.secretKeyRef.key = x.ParameterKey;
secret.secretKeyRef.name = secretName;
const environmentVariable = new V1EnvVar();
environmentVariable.name = x.EnvironmentVariable;
environmentVariable.valueFrom = secret;
return environmentVariable;
}),
{ name: 'LOG_SERVICE_IP', value: ip },
],
volumeMounts: [
{
name: 'build-mount',
mountPath: `${mountdir}`,
},
],
lifecycle: {
preStop: {
exec: {
command: [
'/bin/sh',
'-c',
'sleep 60; cd /data/builder/action/steps && chmod +x /steps/return_license.sh 2>/dev/null || true; /steps/return_license.sh 2>/dev/null || true',
],
},
},
},
},
],
restartPolicy: 'Never',
// Add tolerations for CI/test environments to allow scheduling even with disk pressure
// This is acceptable for CI where we aggressively clean up disk space
tolerations: [
{
key: 'node.kubernetes.io/disk-pressure',
operator: 'Exists',
effect: 'NoSchedule',
},
],
},
},
};
if (process.env['ORCHESTRATOR_MINIKUBE']) {
job.spec.template.spec.volumes[0] = {
name: 'build-mount',
hostPath: {
path: `/data`,
type: `Directory`,
},
};
}
// Set ephemeral-storage request to a reasonable value to prevent evictions
// For tests, don't set a request (or use minimal 128Mi) since k3d nodes have very limited disk space
// Kubernetes will use whatever is available without a request, which is better for constrained environments
// For production, use 2Gi to allow for larger builds
// The node needs some free space headroom, so requesting too much causes evictions
// With node at 96% usage and only ~2.7GB free, we can't request much without triggering evictions
if (process.env['orchestratorTests'] !== 'true') {
// Only set ephemeral-storage request for production builds
job.spec.template.spec.containers[0].resources.requests[`ephemeral-storage`] = '2Gi';
}
// For tests, don't set ephemeral-storage request - let Kubernetes use available space
return job;
}
}
export default KubernetesJobSpecFactory;

View File

@@ -1,194 +0,0 @@
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { CoreV1Api } from '@kubernetes/client-node';
class KubernetesPods {
public static async IsPodRunning(podName: string, namespace: string, kubeClient: CoreV1Api) {
const pods = (await kubeClient.listNamespacedPod(namespace)).body.items.filter((x) => podName === x.metadata?.name);
const running = pods.length > 0 && (pods[0].status?.phase === `Running` || pods[0].status?.phase === `Pending`);
const phase = pods[0]?.status?.phase || 'undefined status';
OrchestratorLogger.log(`Getting pod status: ${phase}`);
if (phase === `Failed`) {
const pod = pods[0];
const containerStatuses = pod.status?.containerStatuses || [];
const conditions = pod.status?.conditions || [];
const events = (await kubeClient.listNamespacedEvent(namespace)).body.items
.filter((x) => x.involvedObject?.name === podName)
.map((x) => ({
message: x.message || '',
reason: x.reason || '',
type: x.type || '',
}));
const errorDetails: string[] = [];
errorDetails.push(`Pod: ${podName}`, `Phase: ${phase}`);
if (conditions.length > 0) {
errorDetails.push(
`Conditions: ${JSON.stringify(
conditions.map((c) => ({ type: c.type, status: c.status, reason: c.reason, message: c.message })),
undefined,
2,
)}`,
);
}
let containerExitCode: number | undefined;
let containerSucceeded = false;
if (containerStatuses.length > 0) {
for (const [index, cs] of containerStatuses.entries()) {
if (cs.state?.waiting) {
errorDetails.push(
`Container ${index} (${cs.name}) waiting: ${cs.state.waiting.reason} - ${cs.state.waiting.message || ''}`,
);
}
if (cs.state?.terminated) {
const exitCode = cs.state.terminated.exitCode;
containerExitCode = exitCode;
if (exitCode === 0) {
containerSucceeded = true;
}
errorDetails.push(
`Container ${index} (${cs.name}) terminated: ${cs.state.terminated.reason} - ${
cs.state.terminated.message || ''
} (exit code: ${exitCode})`,
);
}
}
}
if (events.length > 0) {
errorDetails.push(`Recent events: ${JSON.stringify(events.slice(-5), undefined, 2)}`);
}
// Check if only PreStopHook failed but container succeeded
const hasPreStopHookFailure = events.some((event) => event.reason === 'FailedPreStopHook');
const wasKilled = events.some((event) => event.reason === 'Killing');
const hasExceededGracePeriod = events.some((event) => event.reason === 'ExceededGracePeriod');
// If container succeeded (exit code 0), PreStopHook failure is non-critical
// Also check if pod was killed but container might have succeeded
if (containerSucceeded && containerExitCode === 0) {
// Container succeeded - PreStopHook failure is non-critical
if (hasPreStopHookFailure) {
OrchestratorLogger.logWarning(
`Pod ${podName} marked as Failed due to PreStopHook failure, but container exited successfully (exit code 0). This is non-fatal.`,
);
} else {
OrchestratorLogger.log(
`Pod ${podName} container succeeded (exit code 0), but pod phase is Failed. Checking details...`,
);
}
OrchestratorLogger.log(`Pod details: ${errorDetails.join('\n')}`);
// Don't throw error - container succeeded, PreStopHook failure is non-critical
return false; // Pod is not running, but we don't treat it as a failure
}
// If pod was killed and we have PreStopHook failure, wait for container status
// The container might have succeeded but status hasn't been updated yet
if (wasKilled && hasPreStopHookFailure && (containerExitCode === undefined || !containerSucceeded)) {
OrchestratorLogger.log(
`Pod ${podName} was killed with PreStopHook failure. Waiting for container status to determine if container succeeded...`,
);
// Wait a bit for container status to become available (up to 30 seconds)
for (let index = 0; index < 6; index++) {
await new Promise((resolve) => setTimeout(resolve, 5000));
try {
const updatedPod = (await kubeClient.listNamespacedPod(namespace)).body.items.find(
(x) => podName === x.metadata?.name,
);
if (updatedPod?.status?.containerStatuses && updatedPod.status.containerStatuses.length > 0) {
const updatedContainerStatus = updatedPod.status.containerStatuses[0];
if (updatedContainerStatus.state?.terminated) {
const updatedExitCode = updatedContainerStatus.state.terminated.exitCode;
if (updatedExitCode === 0) {
OrchestratorLogger.logWarning(
`Pod ${podName} container succeeded (exit code 0) after waiting. PreStopHook failure is non-fatal.`,
);
return false; // Pod is not running, but container succeeded
} else {
OrchestratorLogger.log(
`Pod ${podName} container failed with exit code ${updatedExitCode} after waiting.`,
);
errorDetails.push(`Container terminated after wait: exit code ${updatedExitCode}`);
containerExitCode = updatedExitCode;
containerSucceeded = false;
break;
}
}
}
} catch (waitError) {
OrchestratorLogger.log(`Error while waiting for container status: ${waitError}`);
}
}
// If we still don't have container status after waiting, but only PreStopHook failed,
// be lenient - the container might have succeeded but status wasn't updated
if (containerExitCode === undefined && hasPreStopHookFailure && !hasExceededGracePeriod) {
OrchestratorLogger.logWarning(
`Pod ${podName} container status not available after waiting, but only PreStopHook failed (no ExceededGracePeriod). Assuming container may have succeeded.`,
);
return false; // Be lenient - PreStopHook failure alone is not fatal
}
OrchestratorLogger.log(
`Container status check completed. Exit code: ${containerExitCode}, PreStopHook failure: ${hasPreStopHookFailure}`,
);
}
// If we only have PreStopHook failure and no actual container failure, be lenient
if (hasPreStopHookFailure && !hasExceededGracePeriod && containerExitCode === undefined) {
OrchestratorLogger.logWarning(
`Pod ${podName} has PreStopHook failure but no container failure detected. Treating as non-fatal.`,
);
return false; // PreStopHook failure alone is not fatal if container status is unclear
}
// Check if pod was evicted due to disk pressure - this is an infrastructure issue
const wasEvicted = errorDetails.some(
(detail) => detail.toLowerCase().includes('evicted') || detail.toLowerCase().includes('diskpressure'),
);
if (wasEvicted) {
const evictionMessage = `Pod ${podName} was evicted due to disk pressure. This is a test infrastructure issue - the cluster doesn't have enough disk space.`;
OrchestratorLogger.logWarning(evictionMessage);
OrchestratorLogger.log(`Pod details: ${errorDetails.join('\n')}`);
throw new Error(
`${evictionMessage}\nThis indicates the test environment needs more disk space or better cleanup.\n${errorDetails.join(
'\n',
)}`,
);
}
// Exit code 137 (128 + 9) means SIGKILL - container was killed by system (often OOM)
// If this happened with PreStopHook failure, it might be a resource issue, not a real failure
// Be lenient if we only have PreStopHook/ExceededGracePeriod issues
if (containerExitCode === 137 && (hasPreStopHookFailure || hasExceededGracePeriod)) {
OrchestratorLogger.logWarning(
`Pod ${podName} was killed (exit code 137 - likely OOM or resource limit) with PreStopHook/grace period issues. This may be a resource constraint issue rather than a build failure.`,
);
// Still log the details but don't fail the test - the build might have succeeded before being killed
OrchestratorLogger.log(`Pod details: ${errorDetails.join('\n')}`);
return false; // Don't treat system kills as test failures if only PreStopHook issues
}
const errorMessage = `K8s pod failed\n${errorDetails.join('\n')}`;
OrchestratorLogger.log(errorMessage);
throw new Error(errorMessage);
}
return running;
}
public static async GetPodStatus(podName: string, namespace: string, kubeClient: CoreV1Api) {
const pods = (await kubeClient.listNamespacedPod(namespace)).body.items.find((x) => podName === x.metadata?.name);
const phase = pods?.status?.phase || 'undefined status';
return phase;
}
}
export default KubernetesPods;

View File

@@ -1,53 +0,0 @@
import { RbacAuthorizationV1Api } from '@kubernetes/client-node';
class KubernetesRole {
static async createRole(serviceAccountName: string, namespace: string, rbac: RbacAuthorizationV1Api) {
// create admin kubernetes role and role binding
const roleBinding = {
apiVersion: 'rbac.authorization.k8s.io/v1',
kind: 'RoleBinding',
metadata: {
name: `${serviceAccountName}-admin`,
namespace,
},
subjects: [
{
kind: 'ServiceAccount',
name: serviceAccountName,
namespace,
},
],
roleRef: {
apiGroup: 'rbac.authorization.k8s.io',
kind: 'Role',
name: `${serviceAccountName}-admin`,
},
};
const role = {
apiVersion: 'rbac.authorization.k8s.io/v1',
kind: 'Role',
metadata: {
name: `${serviceAccountName}-admin`,
namespace,
},
rules: [
{
apiGroups: ['*'],
resources: ['*'],
verbs: ['*'],
},
],
};
const roleBindingResponse = await rbac.createNamespacedRoleBinding(namespace, roleBinding);
const roleResponse = await rbac.createNamespacedRole(namespace, role);
return { roleBindingResponse, roleResponse };
}
public static async deleteRole(serviceAccountName: string, namespace: string, rbac: RbacAuthorizationV1Api) {
await rbac.deleteNamespacedRoleBinding(`${serviceAccountName}-admin`, namespace);
await rbac.deleteNamespacedRole(`${serviceAccountName}-admin`, namespace);
}
}
export { KubernetesRole };

View File

@@ -1,45 +0,0 @@
import { CoreV1Api } from '@kubernetes/client-node';
import OrchestratorSecret from '../../options/orchestrator-secret';
import * as k8s from '@kubernetes/client-node';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import * as base64 from 'base-64';
class KubernetesSecret {
static async createSecret(
secrets: OrchestratorSecret[],
secretName: string,
namespace: string,
kubeClient: CoreV1Api,
) {
try {
const secret = new k8s.V1Secret();
secret.apiVersion = 'v1';
secret.kind = 'Secret';
secret.type = 'Opaque';
secret.metadata = {
name: secretName,
};
secret.data = {};
for (const buildSecret of secrets) {
secret.data[buildSecret.ParameterKey] = base64.encode(buildSecret.ParameterValue);
}
OrchestratorLogger.log(`Creating secret: ${secretName}`);
const existingSecrets = await kubeClient.listNamespacedSecret(namespace);
const mappedSecrets = existingSecrets.body.items.map((x) => {
return x.metadata?.name || `no name`;
});
OrchestratorLogger.log(
`ExistsAlready: ${mappedSecrets.includes(secretName)} SecretsCount: ${mappedSecrets.length}`,
);
await new Promise((promise) => setTimeout(promise, 15000));
await kubeClient.createNamespacedSecret(namespace, secret);
OrchestratorLogger.log('Created secret');
} catch (error) {
OrchestratorLogger.log(`Created secret failed ${error}`);
throw new Error(`Failed to create kubernetes secret`);
}
}
}
export default KubernetesSecret;

View File

@@ -1,18 +0,0 @@
import { CoreV1Api } from '@kubernetes/client-node';
import * as k8s from '@kubernetes/client-node';
class KubernetesServiceAccount {
static async createServiceAccount(serviceAccountName: string, namespace: string, kubeClient: CoreV1Api) {
const serviceAccount = new k8s.V1ServiceAccount();
serviceAccount.apiVersion = 'v1';
serviceAccount.kind = 'ServiceAccount';
serviceAccount.metadata = {
name: serviceAccountName,
};
serviceAccount.automountServiceAccountToken = true;
return kubeClient.createNamespacedServiceAccount(namespace, serviceAccount);
}
}
export default KubernetesServiceAccount;

View File

@@ -1,276 +0,0 @@
import { waitUntil } from 'async-wait-until';
import * as core from '@actions/core';
import * as k8s from '@kubernetes/client-node';
import BuildParameters from '../../../build-parameters';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { IncomingMessage } from 'node:http';
import GitHub from '../../../github';
class KubernetesStorage {
public static async createPersistentVolumeClaim(
buildParameters: BuildParameters,
pvcName: string,
kubeClient: k8s.CoreV1Api,
namespace: string,
) {
if (buildParameters.kubeVolume !== ``) {
OrchestratorLogger.log(`Kube Volume was input was set ${buildParameters.kubeVolume} overriding ${pvcName}`);
pvcName = buildParameters.kubeVolume;
return;
}
const allPvc = (await kubeClient.listNamespacedPersistentVolumeClaim(namespace)).body.items;
const pvcList = allPvc.map((x) => x.metadata?.name);
OrchestratorLogger.log(`Current PVCs in namespace ${namespace}`);
OrchestratorLogger.log(JSON.stringify(pvcList, undefined, 4));
if (pvcList.includes(pvcName)) {
OrchestratorLogger.log(`pvc ${pvcName} already exists`);
if (GitHub.githubInputEnabled) {
core.setOutput('volume', pvcName);
}
return;
}
OrchestratorLogger.log(`Creating PVC ${pvcName} (does not exist)`);
const result = await KubernetesStorage.createPVC(pvcName, buildParameters, kubeClient, namespace);
await KubernetesStorage.handleResult(result, kubeClient, namespace, pvcName);
}
public static async getPVCPhase(kubeClient: k8s.CoreV1Api, name: string, namespace: string) {
try {
return (await kubeClient.readNamespacedPersistentVolumeClaim(name, namespace)).body.status?.phase;
} catch (error) {
core.error('Failed to get PVC phase');
core.error(JSON.stringify(error, undefined, 4));
throw error;
}
}
public static async watchUntilPVCNotPending(kubeClient: k8s.CoreV1Api, name: string, namespace: string) {
let checkCount = 0;
try {
OrchestratorLogger.log(`watch Until PVC Not Pending ${name} ${namespace}`);
// Check if storage class uses WaitForFirstConsumer binding mode
// If so, skip waiting - PVC will bind when pod is created
let shouldSkipWait = false;
try {
const pvcBody = (await kubeClient.readNamespacedPersistentVolumeClaim(name, namespace)).body;
const storageClassName = pvcBody.spec?.storageClassName;
if (storageClassName) {
const kubeConfig = new k8s.KubeConfig();
kubeConfig.loadFromDefault();
const storageV1Api = kubeConfig.makeApiClient(k8s.StorageV1Api);
try {
const sc = await storageV1Api.readStorageClass(storageClassName);
const volumeBindingMode = sc.body.volumeBindingMode;
if (volumeBindingMode === 'WaitForFirstConsumer') {
OrchestratorLogger.log(
`StorageClass "${storageClassName}" uses WaitForFirstConsumer binding mode. PVC will bind when pod is created. Skipping wait.`,
);
shouldSkipWait = true;
}
} catch (scError) {
// If we can't check the storage class, proceed with normal wait
OrchestratorLogger.log(
`Could not check storage class binding mode: ${scError}. Proceeding with normal wait.`,
);
}
}
} catch (pvcReadError) {
// If we can't read PVC, proceed with normal wait
OrchestratorLogger.log(
`Could not read PVC to check storage class: ${pvcReadError}. Proceeding with normal wait.`,
);
}
if (shouldSkipWait) {
OrchestratorLogger.log(`Skipping PVC wait - will bind when pod is created`);
return;
}
const initialPhase = await this.getPVCPhase(kubeClient, name, namespace);
OrchestratorLogger.log(`Initial PVC phase: ${initialPhase}`);
// Wait until PVC is NOT Pending (i.e., Bound or Available)
await waitUntil(
async () => {
checkCount++;
const phase = await this.getPVCPhase(kubeClient, name, namespace);
// Log progress every 4 checks (every ~60 seconds)
if (checkCount % 4 === 0) {
OrchestratorLogger.log(`PVC ${name} still ${phase} (check ${checkCount})`);
// Fetch and log PVC events for diagnostics
try {
const events = await kubeClient.listNamespacedEvent(namespace);
const pvcEvents = events.body.items
.filter((x) => x.involvedObject?.kind === 'PersistentVolumeClaim' && x.involvedObject?.name === name)
.map((x) => ({
message: x.message || '',
reason: x.reason || '',
type: x.type || '',
count: x.count || 0,
}))
.slice(-5); // Get last 5 events
if (pvcEvents.length > 0) {
OrchestratorLogger.log(`PVC Events: ${JSON.stringify(pvcEvents, undefined, 2)}`);
// Check if event indicates WaitForFirstConsumer
const waitForConsumerEvent = pvcEvents.find(
(event) =>
event.reason === 'WaitForFirstConsumer' || event.message?.includes('waiting for first consumer'),
);
if (waitForConsumerEvent) {
OrchestratorLogger.log(
`PVC is waiting for first consumer. This is normal for WaitForFirstConsumer storage classes. Proceeding without waiting.`,
);
return true; // Exit wait loop - PVC will bind when pod is created
}
}
} catch {
// Ignore event fetch errors
}
}
return phase !== 'Pending';
},
{
timeout: 750000,
intervalBetweenAttempts: 15000,
},
);
const finalPhase = await this.getPVCPhase(kubeClient, name, namespace);
OrchestratorLogger.log(`PVC phase after wait: ${finalPhase}`);
if (finalPhase === 'Pending') {
throw new Error(`PVC ${name} is still Pending after timeout`);
}
} catch (error: any) {
core.error('Failed to watch PVC');
core.error(error.toString());
try {
const pvcBody = (await kubeClient.readNamespacedPersistentVolumeClaim(name, namespace)).body;
// Fetch PVC events for detailed diagnostics
let pvcEvents: any[] = [];
try {
const events = await kubeClient.listNamespacedEvent(namespace);
pvcEvents = events.body.items
.filter((x) => x.involvedObject?.kind === 'PersistentVolumeClaim' && x.involvedObject?.name === name)
.map((x) => ({
message: x.message || '',
reason: x.reason || '',
type: x.type || '',
count: x.count || 0,
}));
} catch {
// Ignore event fetch errors
}
// Check if storage class exists
let storageClassInfo = '';
try {
const storageClassName = pvcBody.spec?.storageClassName;
if (storageClassName) {
// Create StorageV1Api from default config
const kubeConfig = new k8s.KubeConfig();
kubeConfig.loadFromDefault();
const storageV1Api = kubeConfig.makeApiClient(k8s.StorageV1Api);
try {
const sc = await storageV1Api.readStorageClass(storageClassName);
storageClassInfo = `StorageClass "${storageClassName}" exists. Provisioner: ${
sc.body.provisioner || 'unknown'
}`;
} catch (scError: any) {
storageClassInfo =
scError.statusCode === 404
? `StorageClass "${storageClassName}" does NOT exist! This is likely why the PVC is stuck in Pending.`
: `Failed to check StorageClass "${storageClassName}": ${scError.message || scError}`;
}
}
} catch (scCheckError) {
// Ignore storage class check errors - not critical for diagnostics
storageClassInfo = `Could not check storage class: ${scCheckError}`;
}
core.error(
`PVC Body: ${JSON.stringify(
{
phase: pvcBody.status?.phase,
conditions: pvcBody.status?.conditions,
accessModes: pvcBody.spec?.accessModes,
storageClassName: pvcBody.spec?.storageClassName,
storageRequest: pvcBody.spec?.resources?.requests?.storage,
},
undefined,
4,
)}`,
);
if (storageClassInfo) {
core.error(storageClassInfo);
}
if (pvcEvents.length > 0) {
core.error(`PVC Events: ${JSON.stringify(pvcEvents, undefined, 2)}`);
} else {
core.error('No PVC events found - this may indicate the storage provisioner is not responding');
}
} catch {
// Ignore PVC read errors
}
throw error;
}
}
private static async createPVC(
pvcName: string,
buildParameters: BuildParameters,
kubeClient: k8s.CoreV1Api,
namespace: string,
) {
const pvc = new k8s.V1PersistentVolumeClaim();
pvc.apiVersion = 'v1';
pvc.kind = 'PersistentVolumeClaim';
pvc.metadata = {
name: pvcName,
};
pvc.spec = {
accessModes: ['ReadWriteOnce'],
storageClassName: buildParameters.kubeStorageClass === '' ? 'standard' : buildParameters.kubeStorageClass,
resources: {
requests: {
storage: buildParameters.kubeVolumeSize,
},
},
};
const result = await kubeClient.createNamespacedPersistentVolumeClaim(namespace, pvc);
return result;
}
private static async handleResult(
result: { response: IncomingMessage; body: k8s.V1PersistentVolumeClaim },
kubeClient: k8s.CoreV1Api,
namespace: string,
pvcName: string,
) {
const name = result.body.metadata?.name || '';
OrchestratorLogger.log(`PVC ${name} created`);
await this.watchUntilPVCNotPending(kubeClient, name, namespace);
OrchestratorLogger.log(`PVC ${name} is ready and not pending`);
core.setOutput('volume', pvcName);
}
}
export default KubernetesStorage;

View File

@@ -1,763 +0,0 @@
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { waitUntil } from 'async-wait-until';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import Orchestrator from '../../orchestrator';
import KubernetesPods from './kubernetes-pods';
import { FollowLogStreamService } from '../../services/core/follow-log-stream-service';
class KubernetesTaskRunner {
static readonly maxRetry: number = 3;
static lastReceivedMessage: string = ``;
static async runTask(
kubeConfig: KubeConfig,
kubeClient: CoreV1Api,
jobName: string,
podName: string,
containerName: string,
namespace: string,
) {
let output = '';
let shouldReadLogs = true;
let shouldCleanup = true;
let retriesAfterFinish = 0;
let kubectlLogsFailedCount = 0;
const maxKubectlLogsFailures = 3;
// eslint-disable-next-line no-constant-condition
while (true) {
await new Promise((resolve) => setTimeout(resolve, 3000));
OrchestratorLogger.log(
`Streaming logs from pod: ${podName} container: ${containerName} namespace: ${namespace} ${Orchestrator.buildParameters.kubeVolumeSize}/${Orchestrator.buildParameters.containerCpu}/${Orchestrator.buildParameters.containerMemory}`,
);
const isRunning = await KubernetesPods.IsPodRunning(podName, namespace, kubeClient);
const callback = (outputChunk: string) => {
// Filter out kubectl error messages about being unable to retrieve container logs
// These errors pollute the output and don't contain useful information
const lowerChunk = outputChunk.toLowerCase();
if (lowerChunk.includes('unable to retrieve container logs')) {
OrchestratorLogger.log(`Filtered kubectl error: ${outputChunk.trim()}`);
return;
}
output += outputChunk;
// split output chunk and handle per line
for (const chunk of outputChunk.split(`\n`)) {
// Skip empty chunks and kubectl error messages (case-insensitive)
const lowerCaseChunk = chunk.toLowerCase();
if (chunk.trim() && !lowerCaseChunk.includes('unable to retrieve container logs')) {
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
chunk,
shouldReadLogs,
shouldCleanup,
output,
));
}
}
};
try {
// Always specify container name explicitly to avoid containerd:// errors
// Use -f for running pods, --previous for terminated pods
await OrchestratorSystem.Run(
`kubectl logs ${podName} -c ${containerName} -n ${namespace}${isRunning ? ' -f' : ' --previous'}`,
false,
true,
callback,
);
// Reset failure count on success
kubectlLogsFailedCount = 0;
} catch (error: any) {
kubectlLogsFailedCount++;
await new Promise((resolve) => setTimeout(resolve, 3000));
const continueStreaming = await KubernetesPods.IsPodRunning(podName, namespace, kubeClient);
OrchestratorLogger.log(`K8s logging error ${error} ${continueStreaming}`);
// Filter out kubectl error messages from the error output
const errorMessage = error?.message || error?.toString() || '';
const isKubectlLogsError =
errorMessage.includes('unable to retrieve container logs for containerd://') ||
errorMessage.toLowerCase().includes('unable to retrieve container logs');
if (isKubectlLogsError) {
OrchestratorLogger.log(
`Kubectl unable to retrieve logs, attempt ${kubectlLogsFailedCount}/${maxKubectlLogsFailures}`,
);
// If kubectl logs has failed multiple times, try reading the log file directly from the pod
// This works even if the pod is terminated, as long as it hasn't been deleted
if (kubectlLogsFailedCount >= maxKubectlLogsFailures && !isRunning && !continueStreaming) {
OrchestratorLogger.log(`Attempting to read log file directly from pod as fallback...`);
try {
// Try to read the log file from the pod
// Use kubectl exec for running pods, or try to access via PVC if pod is terminated
let logFileContent = '';
if (isRunning) {
// Pod is still running, try exec
logFileContent = await OrchestratorSystem.Run(
`kubectl exec ${podName} -c ${containerName} -n ${namespace} -- cat /home/job-log.txt 2>/dev/null || echo ""`,
true,
true,
);
} else {
// Pod is terminated, try to create a temporary pod to read from the PVC
// First, check if we can still access the pod's filesystem
OrchestratorLogger.log(`Pod is terminated, attempting to read log file via temporary pod...`);
// For terminated pods, we might not be able to exec, so we'll skip this fallback
// and rely on the log file being written to the PVC (if mounted)
OrchestratorLogger.logWarning(`Cannot read log file from terminated pod via exec`);
}
if (logFileContent && logFileContent.trim()) {
OrchestratorLogger.log(`Successfully read log file from pod (${logFileContent.length} chars)`);
// Process the log file content line by line
for (const line of logFileContent.split(`\n`)) {
const lowerLine = line.toLowerCase();
if (line.trim() && !lowerLine.includes('unable to retrieve container logs')) {
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
line,
shouldReadLogs,
shouldCleanup,
output,
));
}
}
// Check if we got the end of transmission marker
if (FollowLogStreamService.DidReceiveEndOfTransmission) {
OrchestratorLogger.log('end of log stream (from log file)');
break;
}
} else {
OrchestratorLogger.logWarning(`Log file read returned empty content, continuing with available logs`);
// If we can't read the log file, break out of the loop to return whatever logs we have
// This prevents infinite retries when kubectl logs consistently fails
break;
}
} catch (execError: any) {
OrchestratorLogger.logWarning(`Failed to read log file from pod: ${execError}`);
// If we've exhausted all options, break to return whatever logs we have
break;
}
}
}
// If pod is not running and we tried --previous but it failed, try without --previous
if (!isRunning && !continueStreaming && error?.message?.includes('previous terminated container')) {
OrchestratorLogger.log(`Previous container not found, trying current container logs...`);
try {
await OrchestratorSystem.Run(
`kubectl logs ${podName} -c ${containerName} -n ${namespace}`,
false,
true,
callback,
);
// If we successfully got logs, check for end of transmission
if (FollowLogStreamService.DidReceiveEndOfTransmission) {
OrchestratorLogger.log('end of log stream');
break;
}
// If we got logs but no end marker, continue trying (might be more logs)
if (retriesAfterFinish < KubernetesTaskRunner.maxRetry) {
retriesAfterFinish++;
continue;
}
// If we've exhausted retries, break
break;
} catch (fallbackError: any) {
OrchestratorLogger.log(`Fallback log fetch also failed: ${fallbackError}`);
// If both fail, continue retrying if we haven't exhausted retries
if (retriesAfterFinish < KubernetesTaskRunner.maxRetry) {
retriesAfterFinish++;
continue;
}
// Only break if we've exhausted all retries
OrchestratorLogger.logWarning(
`Could not fetch any container logs after ${KubernetesTaskRunner.maxRetry} retries`,
);
break;
}
}
if (continueStreaming) {
continue;
}
if (retriesAfterFinish < KubernetesTaskRunner.maxRetry) {
retriesAfterFinish++;
continue;
}
// If we've exhausted retries and it's not a previous container issue, throw
if (!error?.message?.includes('previous terminated container')) {
throw error;
}
// For previous container errors, we've already tried fallback, so just break
OrchestratorLogger.logWarning(
`Could not fetch previous container logs after retries, but continuing with available logs`,
);
break;
}
if (FollowLogStreamService.DidReceiveEndOfTransmission) {
OrchestratorLogger.log('end of log stream');
break;
}
}
// After kubectl logs loop ends, read log file as fallback to capture any messages
// written after kubectl stopped reading (e.g., "Collected Logs" from post-build)
// This ensures all log messages are included in BuildResults for test assertions
// If output is empty, we need to be more aggressive about getting logs
const needsFallback = output.trim().length === 0;
const missingCollectedLogs = !output.includes('Collected Logs');
if (needsFallback) {
OrchestratorLogger.log('Output is empty, attempting aggressive log collection fallback...');
// Give the pod a moment to finish writing logs before we try to read them
await new Promise((resolve) => setTimeout(resolve, 5000));
}
// Always try fallback if output is empty, if pod is terminated, or if "Collected Logs" is missing
// The "Collected Logs" check ensures we try to get post-build messages even if we have some output
try {
const isPodStillRunning = await KubernetesPods.IsPodRunning(podName, namespace, kubeClient);
const shouldTryFallback = !isPodStillRunning || needsFallback || missingCollectedLogs;
if (shouldTryFallback) {
const reason = needsFallback
? 'output is empty'
: missingCollectedLogs
? 'Collected Logs missing from output'
: 'pod is terminated';
OrchestratorLogger.log(
`Pod is ${isPodStillRunning ? 'running' : 'terminated'} and ${reason}, reading log file as fallback...`,
);
try {
// Try to read the log file from the pod
// For killed pods (OOM), kubectl exec might not work, so we try multiple approaches
// First try --previous flag for terminated containers, then try without it
let logFileContent = '';
// Try multiple approaches to get the log file
// Order matters: try terminated container first, then current, then PVC, then kubectl logs as last resort
// For K8s, the PVC is mounted at /data, so try reading from there too
const attempts = [
// For terminated pods, try --previous first
`kubectl exec ${podName} -c ${containerName} -n ${namespace} --previous -- cat /home/job-log.txt 2>/dev/null || echo ""`,
// Try current container
`kubectl exec ${podName} -c ${containerName} -n ${namespace} -- cat /home/job-log.txt 2>/dev/null || echo ""`,
// Try reading from PVC (/data) in case log was copied there
`kubectl exec ${podName} -c ${containerName} -n ${namespace} --previous -- cat /data/job-log.txt 2>/dev/null || echo ""`,
`kubectl exec ${podName} -c ${containerName} -n ${namespace} -- cat /data/job-log.txt 2>/dev/null || echo ""`,
// Try kubectl logs as fallback (might capture stdout even if exec fails)
`kubectl logs ${podName} -c ${containerName} -n ${namespace} --previous 2>/dev/null || echo ""`,
`kubectl logs ${podName} -c ${containerName} -n ${namespace} 2>/dev/null || echo ""`,
];
for (const attempt of attempts) {
// If we already have content with "Collected Logs", no need to try more
if (logFileContent && logFileContent.trim() && logFileContent.includes('Collected Logs')) {
OrchestratorLogger.log('Found "Collected Logs" in fallback content, stopping attempts.');
break;
}
try {
OrchestratorLogger.log(`Trying fallback method: ${attempt.slice(0, 80)}...`);
const result = await OrchestratorSystem.Run(attempt, true, true);
if (result && result.trim()) {
// Prefer content that has "Collected Logs" over content that doesn't
if (!logFileContent || !logFileContent.includes('Collected Logs')) {
logFileContent = result;
OrchestratorLogger.log(
`Successfully read logs using fallback method (${logFileContent.length} chars): ${attempt.slice(
0,
50,
)}...`,
);
// If this content has "Collected Logs", we're done
if (logFileContent.includes('Collected Logs')) {
OrchestratorLogger.log('Fallback method successfully captured "Collected Logs".');
break;
}
} else {
OrchestratorLogger.log(`Skipping this result - already have content with "Collected Logs".`);
}
} else {
OrchestratorLogger.log(`Fallback method returned empty result: ${attempt.slice(0, 50)}...`);
}
} catch (attemptError: any) {
OrchestratorLogger.log(
`Fallback method failed: ${attempt.slice(0, 50)}... Error: ${attemptError?.message || attemptError}`,
);
// Continue to next attempt
}
}
if (!logFileContent || !logFileContent.trim()) {
OrchestratorLogger.logWarning(
'Could not read log file from pod after all fallback attempts (may be OOM-killed or pod not accessible).',
);
}
if (logFileContent && logFileContent.trim()) {
OrchestratorLogger.log(
`Read log file from pod as fallback (${logFileContent.length} chars) to capture missing messages`,
);
// Get the lines we already have in output to avoid duplicates
const existingLines = new Set(output.split('\n').map((line) => line.trim()));
// Process the log file content line by line and add missing lines
for (const line of logFileContent.split(`\n`)) {
const trimmedLine = line.trim();
const lowerLine = trimmedLine.toLowerCase();
// Skip empty lines, kubectl errors, and lines we already have
if (
trimmedLine &&
!lowerLine.includes('unable to retrieve container logs') &&
!existingLines.has(trimmedLine)
) {
// Process through FollowLogStreamService - it will append to output
// Don't add to output manually since handleIteration does it
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
trimmedLine,
shouldReadLogs,
shouldCleanup,
output,
));
}
}
}
} catch (logFileError: any) {
OrchestratorLogger.logWarning(
`Could not read log file from pod as fallback: ${logFileError?.message || logFileError}`,
);
// Continue with existing output - this is a best-effort fallback
}
}
// If output is still empty or missing "Collected Logs" after fallback attempts, add a warning message
// This ensures BuildResults is not completely empty, which would cause test failures
if ((needsFallback && output.trim().length === 0) || (!output.includes('Collected Logs') && shouldTryFallback)) {
OrchestratorLogger.logWarning(
'Could not retrieve "Collected Logs" from pod after all attempts. Pod may have been killed before logs were written.',
);
// Add a minimal message so BuildResults is not completely empty
// This helps with debugging and prevents test failures due to empty results
if (output.trim().length === 0) {
output = 'Pod logs unavailable - pod may have been terminated before logs could be collected.\n';
} else if (!output.includes('Collected Logs')) {
// We have some output but missing "Collected Logs" - append the fallback message
output +=
'\nPod logs incomplete - "Collected Logs" marker not found. Pod may have been terminated before post-build completed.\n';
}
}
} catch (fallbackError: any) {
OrchestratorLogger.logWarning(
`Error checking pod status for log file fallback: ${fallbackError?.message || fallbackError}`,
);
// If output is empty and we hit an error, still add a message so BuildResults isn't empty
if (needsFallback && output.trim().length === 0) {
output = `Error retrieving logs: ${fallbackError?.message || fallbackError}\n`;
}
// Continue with existing output - this is a best-effort fallback
}
// Filter out kubectl error messages from the final output
// These errors can be added via stderr even when kubectl fails
// We filter them out so they don't pollute the BuildResults
const lines = output.split('\n');
const filteredLines = lines.filter((line) => !line.toLowerCase().includes('unable to retrieve container logs'));
const filteredOutput = filteredLines.join('\n');
// Log if we filtered out significant content
const originalLineCount = lines.length;
const filteredLineCount = filteredLines.length;
if (originalLineCount > filteredLineCount) {
OrchestratorLogger.log(
`Filtered out ${originalLineCount - filteredLineCount} kubectl error message(s) from output`,
);
}
return filteredOutput;
}
static async watchUntilPodRunning(kubeClient: CoreV1Api, podName: string, namespace: string) {
let waitComplete: boolean = false;
let message = ``;
let lastPhase = '';
let consecutivePendingCount = 0;
OrchestratorLogger.log(`Watching ${podName} ${namespace}`);
try {
await waitUntil(
async () => {
const status = await kubeClient.readNamespacedPodStatus(podName, namespace);
const phase = status?.body.status?.phase || 'Unknown';
const conditions = status?.body.status?.conditions || [];
const containerStatuses = status?.body.status?.containerStatuses || [];
// Log phase changes
if (phase !== lastPhase) {
OrchestratorLogger.log(`Pod ${podName} phase changed: ${lastPhase} -> ${phase}`);
lastPhase = phase;
consecutivePendingCount = 0;
}
// Check for failure conditions that mean the pod will never start (permanent failures)
// Note: We don't treat "Failed" phase as a permanent failure because the pod might have
// completed its work before being killed (OOM), and we should still try to get logs
const permanentFailureReasons = [
'Unschedulable',
'ImagePullBackOff',
'ErrImagePull',
'CreateContainerError',
'CreateContainerConfigError',
];
const hasPermanentFailureCondition = conditions.some((condition: any) =>
permanentFailureReasons.some((reason) => condition.reason?.includes(reason)),
);
const hasPermanentFailureContainerStatus = containerStatuses.some((containerStatus: any) =>
permanentFailureReasons.some((reason) => containerStatus.state?.waiting?.reason?.includes(reason)),
);
// Only treat permanent failures as errors - pods that completed (Failed/Succeeded) should continue
if (hasPermanentFailureCondition || hasPermanentFailureContainerStatus) {
// Get detailed failure information
const failureCondition = conditions.find((condition: any) =>
permanentFailureReasons.some((reason) => condition.reason?.includes(reason)),
);
const failureContainer = containerStatuses.find((containerStatus: any) =>
permanentFailureReasons.some((reason) => containerStatus.state?.waiting?.reason?.includes(reason)),
);
message = `Pod ${podName} failed to start (permanent failure):\nPhase: ${phase}\n`;
if (failureCondition) {
message += `Condition Reason: ${failureCondition.reason}\nCondition Message: ${failureCondition.message}\n`;
}
if (failureContainer) {
message += `Container Reason: ${failureContainer.state?.waiting?.reason}\nContainer Message: ${failureContainer.state?.waiting?.message}\n`;
}
// Log pod events for additional context
try {
const events = await kubeClient.listNamespacedEvent(namespace);
const podEvents = events.body.items
.filter((x) => x.involvedObject?.name === podName)
.map((x) => ({
message: x.message || ``,
reason: x.reason || ``,
type: x.type || ``,
}));
if (podEvents.length > 0) {
message += `\nRecent Events:\n${JSON.stringify(podEvents.slice(-5), undefined, 2)}`;
}
} catch {
// Ignore event fetch errors
}
OrchestratorLogger.logWarning(message);
// For permanent failures, mark as incomplete and store the error message
// We'll throw an error after the wait loop exits
waitComplete = false;
return true; // Return true to exit wait loop
}
// Pod is complete if it's not Pending or Unknown - it might be Running, Succeeded, or Failed
// For Failed/Succeeded pods, we still want to try to get logs, so we mark as complete
waitComplete = phase !== 'Pending' && phase !== 'Unknown';
// If pod completed (Succeeded/Failed), log it but don't throw - we'll try to get logs
if (waitComplete && phase !== 'Running') {
OrchestratorLogger.log(`Pod ${podName} completed with phase: ${phase}. Will attempt to retrieve logs.`);
}
if (phase === 'Pending') {
consecutivePendingCount++;
// Check for scheduling failures in events (faster than waiting for conditions)
try {
const events = await kubeClient.listNamespacedEvent(namespace);
const podEvents = events.body.items.filter((x) => x.involvedObject?.name === podName);
const failedSchedulingEvents = podEvents.filter(
(x) => x.reason === 'FailedScheduling' || x.reason === 'SchedulingGated',
);
if (failedSchedulingEvents.length > 0) {
const schedulingMessage = failedSchedulingEvents
.map((x) => `${x.reason}: ${x.message || ''}`)
.join('; ');
message = `Pod ${podName} cannot be scheduled:\n${schedulingMessage}`;
OrchestratorLogger.logWarning(message);
waitComplete = false;
return true; // Exit wait loop to throw error
}
// Check if pod is actively pulling an image - if so, allow more time
const isPullingImage = podEvents.some(
(x) => x.reason === 'Pulling' || x.reason === 'Pulled' || x.message?.includes('Pulling image'),
);
const hasImagePullError = podEvents.some(
(x) => x.reason === 'Failed' && (x.message?.includes('pull') || x.message?.includes('image')),
);
if (hasImagePullError) {
message = `Pod ${podName} failed to pull image. Check image availability and credentials.`;
OrchestratorLogger.logWarning(message);
waitComplete = false;
return true; // Exit wait loop to throw error
}
// If actively pulling image, reset pending count to allow more time
// Large images (like Unity 3.9GB) can take 3-5 minutes to pull
if (isPullingImage && consecutivePendingCount > 4) {
OrchestratorLogger.log(
`Pod ${podName} is pulling image (check ${consecutivePendingCount}). This may take several minutes for large images.`,
);
// Don't increment consecutivePendingCount if we're actively pulling
consecutivePendingCount = Math.max(4, consecutivePendingCount - 1);
}
} catch {
// Ignore event fetch errors
}
// For tests, allow more time if image is being pulled (large images need 5+ minutes)
// Otherwise fail faster if stuck in Pending (2 minutes = 8 checks at 15s interval)
const isTest = process.env['orchestratorTests'] === 'true';
const isPullingImage =
containerStatuses.some(
(cs: any) => cs.state?.waiting?.reason === 'ImagePull' || cs.state?.waiting?.reason === 'ErrImagePull',
) || conditions.some((c: any) => c.reason?.includes('Pulling'));
// Allow up to 20 minutes for image pulls in tests (80 checks), 2 minutes otherwise
const maxPendingChecks = isTest && isPullingImage ? 80 : isTest ? 8 : 80;
if (consecutivePendingCount >= maxPendingChecks) {
message = `Pod ${podName} stuck in Pending state for too long (${consecutivePendingCount} checks). This indicates a scheduling problem.`;
// Get events for context
try {
const events = await kubeClient.listNamespacedEvent(namespace);
const podEvents = events.body.items
.filter((x) => x.involvedObject?.name === podName)
.slice(-10)
.map((x) => `${x.type}: ${x.reason} - ${x.message}`);
if (podEvents.length > 0) {
message += `\n\nRecent Events:\n${podEvents.join('\n')}`;
}
// Get pod details to check for scheduling issues
try {
const podStatus = await kubeClient.readNamespacedPodStatus(podName, namespace);
const podSpec = podStatus.body.spec;
const podStatusDetails = podStatus.body.status;
// Check container resource requests
if (podSpec?.containers?.[0]?.resources?.requests) {
const requests = podSpec.containers[0].resources.requests;
message += `\n\nContainer Resource Requests:\n CPU: ${requests.cpu || 'not set'}\n Memory: ${
requests.memory || 'not set'
}\n Ephemeral Storage: ${requests['ephemeral-storage'] || 'not set'}`;
}
// Check node selector and tolerations
if (podSpec?.nodeSelector && Object.keys(podSpec.nodeSelector).length > 0) {
message += `\n\nNode Selector: ${JSON.stringify(podSpec.nodeSelector)}`;
}
if (podSpec?.tolerations && podSpec.tolerations.length > 0) {
message += `\n\nTolerations: ${JSON.stringify(podSpec.tolerations)}`;
}
// Check pod conditions for scheduling issues
if (podStatusDetails?.conditions) {
const allConditions = podStatusDetails.conditions.map(
(c: any) =>
`${c.type}: ${c.status}${c.reason ? ` (${c.reason})` : ''}${
c.message ? ` - ${c.message}` : ''
}`,
);
message += `\n\nPod Conditions:\n${allConditions.join('\n')}`;
const unschedulable = podStatusDetails.conditions.find(
(c: any) => c.type === 'PodScheduled' && c.status === 'False',
);
if (unschedulable) {
message += `\n\nScheduling Issue: ${unschedulable.reason || 'Unknown'} - ${
unschedulable.message || 'No message'
}`;
}
// Check if pod is assigned to a node
message += podStatusDetails?.hostIP
? `\n\nPod assigned to node: ${podStatusDetails.hostIP}`
: `\n\nPod not yet assigned to a node (scheduling pending)`;
}
// Check node resources if pod is assigned
if (podStatusDetails?.hostIP) {
try {
const nodes = await kubeClient.listNode();
const hostIP = podStatusDetails.hostIP;
const assignedNode = nodes.body.items.find((n: any) =>
n.status?.addresses?.some((a: any) => a.address === hostIP),
);
if (assignedNode?.status && assignedNode.metadata?.name) {
const allocatable = assignedNode.status.allocatable || {};
message += `\n\nNode Resources (${assignedNode.metadata.name}):\n Allocatable CPU: ${
allocatable.cpu || 'unknown'
}\n Allocatable Memory: ${allocatable.memory || 'unknown'}\n Allocatable Ephemeral Storage: ${
allocatable['ephemeral-storage'] || 'unknown'
}`;
// Check for taints that might prevent scheduling
if (assignedNode.spec?.taints && assignedNode.spec.taints.length > 0) {
const taints = assignedNode.spec.taints
.map((t: any) => `${t.key}=${t.value}:${t.effect}`)
.join(', ');
message += `\n Node Taints: ${taints}`;
}
}
} catch {
// Ignore node check errors
}
}
} catch {
// Ignore pod status fetch errors
}
} catch {
// Ignore event fetch errors
}
OrchestratorLogger.logWarning(message);
waitComplete = false;
return true; // Exit wait loop to throw error
}
// Log diagnostic info every 4 checks (1 minute) if still pending
if (consecutivePendingCount % 4 === 0) {
const pendingMessage = `Pod ${podName} still Pending (check ${consecutivePendingCount}/${maxPendingChecks}). Phase: ${phase}`;
const conditionMessages = conditions
.map((c: any) => `${c.type}: ${c.reason || 'N/A'} - ${c.message || 'N/A'}`)
.join('; ');
OrchestratorLogger.log(`${pendingMessage}. Conditions: ${conditionMessages || 'None'}`);
// Log events periodically to help diagnose
if (consecutivePendingCount % 8 === 0) {
try {
const events = await kubeClient.listNamespacedEvent(namespace);
const podEvents = events.body.items
.filter((x) => x.involvedObject?.name === podName)
.slice(-3)
.map((x) => `${x.type}: ${x.reason} - ${x.message}`)
.join('; ');
if (podEvents) {
OrchestratorLogger.log(`Recent pod events: ${podEvents}`);
}
} catch {
// Ignore event fetch errors
}
}
}
}
message = `Phase:${phase} \n Reason:${conditions[0]?.reason || ''} \n Message:${
conditions[0]?.message || ''
}`;
if (waitComplete || phase !== 'Pending') return true;
return false;
},
{
timeout: process.env['orchestratorTests'] === 'true' ? 300000 : 2000000, // 5 minutes for tests, ~33 minutes for production
intervalBetweenAttempts: 15000, // 15 seconds
},
);
} catch (waitError: any) {
// If waitUntil times out or throws, get final pod status
try {
const finalStatus = await kubeClient.readNamespacedPodStatus(podName, namespace);
const phase = finalStatus?.body.status?.phase || 'Unknown';
const conditions = finalStatus?.body.status?.conditions || [];
message = `Pod ${podName} timed out waiting to start.\nFinal Phase: ${phase}\n`;
message += conditions.map((c: any) => `${c.type}: ${c.reason} - ${c.message}`).join('\n');
// Get events for context
try {
const events = await kubeClient.listNamespacedEvent(namespace);
const podEvents = events.body.items
.filter((x) => x.involvedObject?.name === podName)
.slice(-5)
.map((x) => `${x.type}: ${x.reason} - ${x.message}`);
if (podEvents.length > 0) {
message += `\n\nRecent Events:\n${podEvents.join('\n')}`;
}
} catch {
// Ignore event fetch errors
}
OrchestratorLogger.logWarning(message);
} catch {
message = `Pod ${podName} timed out and could not retrieve final status: ${waitError?.message || waitError}`;
OrchestratorLogger.logWarning(message);
}
throw new Error(`Pod ${podName} failed to start within timeout. ${message}`);
}
// Only throw if we detected a permanent failure condition
// If the pod completed (Failed/Succeeded), we should still try to get logs
if (!waitComplete) {
// Check the final phase to see if it's a permanent failure or just completed
try {
const finalStatus = await kubeClient.readNamespacedPodStatus(podName, namespace);
const finalPhase = finalStatus?.body.status?.phase || 'Unknown';
if (finalPhase === 'Failed' || finalPhase === 'Succeeded') {
OrchestratorLogger.logWarning(
`Pod ${podName} completed with phase ${finalPhase} before reaching Running state. Will attempt to retrieve logs.`,
);
return true; // Allow workflow to continue and try to get logs
}
} catch {
// If we can't check status, fall through to throw error
}
OrchestratorLogger.logWarning(`Pod ${podName} did not reach running state: ${message}`);
throw new Error(`Pod ${podName} did not start successfully: ${message}`);
}
return waitComplete;
}
}
export default KubernetesTaskRunner;

View File

@@ -1,87 +0,0 @@
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { quote } from 'shell-quote';
class LocalOrchestrator implements ProviderInterface {
listResources(): Promise<ProviderResource[]> {
throw new Error('Method not implemented.');
}
listWorkflow(): Promise<ProviderWorkflow[]> {
throw new Error('Method not implemented.');
}
watchWorkflow(): Promise<string> {
throw new Error('Method not implemented.');
}
garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
throw new Error('Method not implemented.');
}
cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {}
public setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {}
public async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
// eslint-disable-next-line no-unused-vars
mountdir: string,
// eslint-disable-next-line no-unused-vars
workingdir: string,
// eslint-disable-next-line no-unused-vars
environment: OrchestratorEnvironmentVariable[],
// eslint-disable-next-line no-unused-vars
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(image);
OrchestratorLogger.log(buildGuid);
OrchestratorLogger.log(commands);
// On Windows, many built-in hooks use POSIX shell syntax. Execute via bash if available.
if (process.platform === 'win32') {
const inline = commands
.replace(/\r/g, '')
.split('\n')
.filter((x) => x.trim().length > 0)
.join(' ; ');
// Use shell-quote to properly escape the command string, preventing command injection
const bashWrapped = `bash -lc ${quote([inline])}`;
return await OrchestratorSystem.Run(bashWrapped);
}
return await OrchestratorSystem.Run(commands);
}
}
export default LocalOrchestrator;

View File

@@ -1,278 +0,0 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import path from 'path';
import OrchestratorLogger from '../services/core/orchestrator-logger';
import { GitHubUrlInfo, generateCacheKey } from './provider-url-parser';
const execAsync = promisify(exec);
export interface GitCloneResult {
success: boolean;
localPath: string;
error?: string;
}
export interface GitUpdateResult {
success: boolean;
updated: boolean;
error?: string;
}
/**
* Manages git operations for provider repositories
*/
export class ProviderGitManager {
private static readonly CACHE_DIR = path.join(process.cwd(), '.provider-cache');
private static readonly GIT_TIMEOUT = 30000; // 30 seconds
/**
* Ensures the cache directory exists
*/
private static ensureCacheDir(): void {
if (!fs.existsSync(this.CACHE_DIR)) {
fs.mkdirSync(this.CACHE_DIR, { recursive: true });
OrchestratorLogger.log(`Created provider cache directory: ${this.CACHE_DIR}`);
}
}
/**
* Gets the local path for a cached repository
* @param urlInfo GitHub URL information
* @returns Local path to the repository
*/
private static getLocalPath(urlInfo: GitHubUrlInfo): string {
const cacheKey = generateCacheKey(urlInfo);
return path.join(this.CACHE_DIR, cacheKey);
}
/**
* Checks if a repository is already cloned locally
* @param urlInfo GitHub URL information
* @returns True if repository exists locally
*/
private static isRepositoryCloned(urlInfo: GitHubUrlInfo): boolean {
const localPath = this.getLocalPath(urlInfo);
return fs.existsSync(localPath) && fs.existsSync(path.join(localPath, '.git'));
}
/**
* Clones a GitHub repository to the local cache
* @param urlInfo GitHub URL information
* @returns Clone result with success status and local path
*/
static async cloneRepository(urlInfo: GitHubUrlInfo): Promise<GitCloneResult> {
this.ensureCacheDir();
const localPath = this.getLocalPath(urlInfo);
// Remove existing directory if it exists
if (fs.existsSync(localPath)) {
OrchestratorLogger.log(`Removing existing directory: ${localPath}`);
fs.rmSync(localPath, { recursive: true, force: true });
}
try {
OrchestratorLogger.log(`Cloning repository: ${urlInfo.url} to ${localPath}`);
const cloneCommand = `git clone --depth 1 --branch ${urlInfo.branch} ${urlInfo.url} "${localPath}"`;
OrchestratorLogger.log(`Executing: ${cloneCommand}`);
const { stderr } = await execAsync(cloneCommand, {
timeout: this.GIT_TIMEOUT,
cwd: this.CACHE_DIR,
});
if (stderr && !stderr.includes('warning')) {
OrchestratorLogger.log(`Git clone stderr: ${stderr}`);
}
OrchestratorLogger.log(`Successfully cloned repository to: ${localPath}`);
return {
success: true,
localPath,
};
} catch (error: any) {
const errorMessage = `Failed to clone repository ${urlInfo.url}: ${error.message}`;
OrchestratorLogger.log(`Error: ${errorMessage}`);
return {
success: false,
localPath,
error: errorMessage,
};
}
}
/**
* Updates a locally cloned repository
* @param urlInfo GitHub URL information
* @returns Update result with success status and whether it was updated
*/
static async updateRepository(urlInfo: GitHubUrlInfo): Promise<GitUpdateResult> {
const localPath = this.getLocalPath(urlInfo);
if (!this.isRepositoryCloned(urlInfo)) {
return {
success: false,
updated: false,
error: 'Repository not found locally',
};
}
try {
OrchestratorLogger.log(`Updating repository: ${localPath}`);
// Fetch latest changes
await execAsync('git fetch origin', {
timeout: this.GIT_TIMEOUT,
cwd: localPath,
});
// Check if there are updates
const { stdout: statusOutput } = await execAsync(`git status -uno`, {
timeout: this.GIT_TIMEOUT,
cwd: localPath,
});
const hasUpdates =
statusOutput.includes('Your branch is behind') || statusOutput.includes('can be fast-forwarded');
if (hasUpdates) {
OrchestratorLogger.log(`Updates available, pulling latest changes...`);
// Reset to origin/branch to get latest changes
await execAsync(`git reset --hard origin/${urlInfo.branch}`, {
timeout: this.GIT_TIMEOUT,
cwd: localPath,
});
OrchestratorLogger.log(`Repository updated successfully`);
return {
success: true,
updated: true,
};
} else {
OrchestratorLogger.log(`Repository is already up to date`);
return {
success: true,
updated: false,
};
}
} catch (error: any) {
const errorMessage = `Failed to update repository ${localPath}: ${error.message}`;
OrchestratorLogger.log(`Error: ${errorMessage}`);
return {
success: false,
updated: false,
error: errorMessage,
};
}
}
/**
* Ensures a repository is available locally (clone if needed, update if exists)
* @param urlInfo GitHub URL information
* @returns Local path to the repository
*/
static async ensureRepositoryAvailable(urlInfo: GitHubUrlInfo): Promise<string> {
this.ensureCacheDir();
if (this.isRepositoryCloned(urlInfo)) {
OrchestratorLogger.log(`Repository already exists locally, checking for updates...`);
const updateResult = await this.updateRepository(urlInfo);
if (!updateResult.success) {
OrchestratorLogger.log(`Failed to update repository, attempting fresh clone...`);
const cloneResult = await this.cloneRepository(urlInfo);
if (!cloneResult.success) {
throw new Error(`Failed to ensure repository availability: ${cloneResult.error}`);
}
return cloneResult.localPath;
}
return this.getLocalPath(urlInfo);
} else {
OrchestratorLogger.log(`Repository not found locally, cloning...`);
const cloneResult = await this.cloneRepository(urlInfo);
if (!cloneResult.success) {
throw new Error(`Failed to clone repository: ${cloneResult.error}`);
}
return cloneResult.localPath;
}
}
/**
* Gets the path to the provider module within a repository
* @param urlInfo GitHub URL information
* @param localPath Local path to the repository
* @returns Path to the provider module
*/
static getProviderModulePath(urlInfo: GitHubUrlInfo, localPath: string): string {
if (urlInfo.path) {
return path.join(localPath, urlInfo.path);
}
// Look for common provider entry points
const commonEntryPoints = [
'index.js',
'index.ts',
'src/index.js',
'src/index.ts',
'lib/index.js',
'lib/index.ts',
'dist/index.js',
'dist/index.js.map',
];
for (const entryPoint of commonEntryPoints) {
const fullPath = path.join(localPath, entryPoint);
if (fs.existsSync(fullPath)) {
OrchestratorLogger.log(`Found provider entry point: ${entryPoint}`);
return fullPath;
}
}
// Default to repository root
OrchestratorLogger.log(`No specific entry point found, using repository root`);
return localPath;
}
/**
* Cleans up old cached repositories (optional maintenance)
* @param maxAgeDays Maximum age in days for cached repositories
*/
static async cleanupOldRepositories(maxAgeDays: number = 30): Promise<void> {
this.ensureCacheDir();
try {
const entries = fs.readdirSync(this.CACHE_DIR, { withFileTypes: true });
const now = Date.now();
const maxAge = maxAgeDays * 24 * 60 * 60 * 1000; // Convert to milliseconds
for (const entry of entries) {
if (entry.isDirectory()) {
const entryPath = path.join(this.CACHE_DIR, entry.name);
const stats = fs.statSync(entryPath);
if (now - stats.mtime.getTime() > maxAge) {
OrchestratorLogger.log(`Cleaning up old repository: ${entry.name}`);
fs.rmSync(entryPath, { recursive: true, force: true });
}
}
}
} catch (error: any) {
OrchestratorLogger.log(`Error during cleanup: ${error.message}`);
}
}
}

View File

@@ -1,57 +0,0 @@
import BuildParameters from '../../build-parameters';
import OrchestratorEnvironmentVariable from '../options/orchestrator-environment-variable';
import OrchestratorSecret from '../options/orchestrator-secret';
import { ProviderResource } from './provider-resource';
import { ProviderWorkflow } from './provider-workflow';
export interface ProviderInterface {
cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): any;
setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): any;
runTaskInWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
image: string,
// eslint-disable-next-line no-unused-vars
commands: string,
// eslint-disable-next-line no-unused-vars
mountdir: string,
// eslint-disable-next-line no-unused-vars
workingdir: string,
// eslint-disable-next-line no-unused-vars
environment: OrchestratorEnvironmentVariable[],
// eslint-disable-next-line no-unused-vars
secrets: OrchestratorSecret[],
): Promise<string>;
garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string>;
listResources(): Promise<ProviderResource[]>;
listWorkflow(): Promise<ProviderWorkflow[]>;
watchWorkflow(): Promise<string>;
}

View File

@@ -1,158 +0,0 @@
import { ProviderInterface } from './provider-interface';
import BuildParameters from '../../build-parameters';
import OrchestratorLogger from '../services/core/orchestrator-logger';
import { parseProviderSource, logProviderSource, ProviderSourceInfo } from './provider-url-parser';
import { ProviderGitManager } from './provider-git-manager';
// import path from 'path'; // Not currently used
/**
* Dynamically load a provider package by name, URL, or path.
* @param providerSource Provider source (name, URL, or path)
* @param buildParameters Build parameters passed to the provider constructor
* @throws Error when the provider cannot be loaded or does not implement ProviderInterface
*/
export default async function loadProvider(
providerSource: string,
buildParameters: BuildParameters,
): Promise<ProviderInterface> {
OrchestratorLogger.log(`Loading provider: ${providerSource}`);
// Parse the provider source to determine its type
const sourceInfo = parseProviderSource(providerSource);
logProviderSource(providerSource, sourceInfo);
let modulePath: string;
let importedModule: any;
try {
// Handle different source types
switch (sourceInfo.type) {
case 'github': {
OrchestratorLogger.log(`Processing GitHub repository: ${sourceInfo.owner}/${sourceInfo.repo}`);
// Ensure the repository is available locally
const localRepoPath = await ProviderGitManager.ensureRepositoryAvailable(sourceInfo);
// Get the path to the provider module within the repository
modulePath = ProviderGitManager.getProviderModulePath(sourceInfo, localRepoPath);
OrchestratorLogger.log(`Loading provider from: ${modulePath}`);
break;
}
case 'local': {
modulePath = sourceInfo.path;
OrchestratorLogger.log(`Loading provider from local path: ${modulePath}`);
break;
}
case 'npm': {
modulePath = sourceInfo.packageName;
OrchestratorLogger.log(`Loading provider from NPM package: ${modulePath}`);
break;
}
default: {
// Fallback to built-in providers or direct import
const providerModuleMap: Record<string, string> = {
aws: './aws',
k8s: './k8s',
test: './test',
'local-docker': './docker',
'local-system': './local',
local: './local',
};
modulePath = providerModuleMap[providerSource] || providerSource;
OrchestratorLogger.log(`Loading provider from module path: ${modulePath}`);
break;
}
}
// Import the module
importedModule = await import(modulePath);
} catch (error) {
throw new Error(`Failed to load provider package '${providerSource}': ${(error as Error).message}`);
}
// Extract the provider class/function
const Provider = importedModule.default || importedModule;
// Validate that we have a constructor
if (typeof Provider !== 'function') {
throw new TypeError(`Provider package '${providerSource}' does not export a constructor function`);
}
// Instantiate the provider
let instance: any;
try {
instance = new Provider(buildParameters);
} catch (error) {
throw new Error(`Failed to instantiate provider '${providerSource}': ${(error as Error).message}`);
}
// Validate that the instance implements the required interface
const requiredMethods = [
'cleanupWorkflow',
'setupWorkflow',
'runTaskInWorkflow',
'garbageCollect',
'listResources',
'listWorkflow',
'watchWorkflow',
];
for (const method of requiredMethods) {
if (typeof instance[method] !== 'function') {
throw new TypeError(
`Provider package '${providerSource}' does not implement ProviderInterface. Missing method '${method}'.`,
);
}
}
OrchestratorLogger.log(`Successfully loaded provider: ${providerSource}`);
return instance as ProviderInterface;
}
/**
* ProviderLoader class for backward compatibility and additional utilities
*/
export class ProviderLoader {
/**
* Dynamically loads a provider by name, URL, or path (wrapper around loadProvider function)
* @param providerSource - The provider source (name, URL, or path) to load
* @param buildParameters - Build parameters to pass to the provider constructor
* @returns Promise<ProviderInterface> - The loaded provider instance
* @throws Error if provider package is missing or doesn't implement ProviderInterface
*/
static async loadProvider(providerSource: string, buildParameters: BuildParameters): Promise<ProviderInterface> {
return loadProvider(providerSource, buildParameters);
}
/**
* Gets a list of available provider names
* @returns string[] - Array of available provider names
*/
static getAvailableProviders(): string[] {
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local'];
}
/**
* Cleans up old cached repositories
* @param maxAgeDays Maximum age in days for cached repositories (default: 30)
*/
static async cleanupCache(maxAgeDays: number = 30): Promise<void> {
await ProviderGitManager.cleanupOldRepositories(maxAgeDays);
}
/**
* Gets information about a provider source without loading it
* @param providerSource The provider source to analyze
* @returns ProviderSourceInfo object with parsed details
*/
static analyzeProviderSource(providerSource: string): ProviderSourceInfo {
return parseProviderSource(providerSource);
}
}

View File

@@ -1,3 +0,0 @@
export class ProviderResource {
public Name!: string;
}

View File

@@ -1,138 +0,0 @@
import OrchestratorLogger from '../services/core/orchestrator-logger';
export interface GitHubUrlInfo {
type: 'github';
owner: string;
repo: string;
branch?: string;
path?: string;
url: string;
}
export interface LocalPathInfo {
type: 'local';
path: string;
}
export interface NpmPackageInfo {
type: 'npm';
packageName: string;
}
export type ProviderSourceInfo = GitHubUrlInfo | LocalPathInfo | NpmPackageInfo;
/**
* Parses a provider source string and determines its type and details
* @param source The provider source string (URL, path, or package name)
* @returns ProviderSourceInfo object with parsed details
*/
export function parseProviderSource(source: string): ProviderSourceInfo {
// Check if it's a GitHub URL
const githubMatch = source.match(
/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?(?:tree\/([^/]+))?(?:\/(.+))?$/,
);
if (githubMatch) {
const [, owner, repo, branch, path] = githubMatch;
return {
type: 'github',
owner,
repo,
branch: branch || 'main',
path: path || '',
url: `https://github.com/${owner}/${repo}`,
};
}
// Check if it's a GitHub SSH URL
const githubSshMatch = source.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?\/?(?:tree\/([^/]+))?(?:\/(.+))?$/);
if (githubSshMatch) {
const [, owner, repo, branch, path] = githubSshMatch;
return {
type: 'github',
owner,
repo,
branch: branch || 'main',
path: path || '',
url: `https://github.com/${owner}/${repo}`,
};
}
// Check if it's a shorthand GitHub reference (owner/repo)
const shorthandMatch = source.match(/^([^/@]+)\/([^/@]+)(?:@([^/]+))?(?:\/(.+))?$/);
if (shorthandMatch && !source.startsWith('.') && !source.startsWith('/') && !source.includes('\\')) {
const [, owner, repo, branch, path] = shorthandMatch;
return {
type: 'github',
owner,
repo,
branch: branch || 'main',
path: path || '',
url: `https://github.com/${owner}/${repo}`,
};
}
// Check if it's a local path
if (source.startsWith('./') || source.startsWith('../') || source.startsWith('/') || source.includes('\\')) {
return {
type: 'local',
path: source,
};
}
// Default to npm package
return {
type: 'npm',
packageName: source,
};
}
/**
* Generates a cache key for a GitHub repository
* @param urlInfo GitHub URL information
* @returns Cache key string
*/
export function generateCacheKey(urlInfo: GitHubUrlInfo): string {
return `github_${urlInfo.owner}_${urlInfo.repo}_${urlInfo.branch}`.replace(/[^\w-]/g, '_');
}
/**
* Validates if a string looks like a valid GitHub URL or reference
* @param source The source string to validate
* @returns True if it looks like a GitHub reference
*/
export function isGitHubSource(source: string): boolean {
const parsed = parseProviderSource(source);
return parsed.type === 'github';
}
/**
* Logs the parsed provider source information
* @param source The original source string
* @param parsed The parsed source information
*/
export function logProviderSource(source: string, parsed: ProviderSourceInfo): void {
OrchestratorLogger.log(`Provider source: ${source}`);
switch (parsed.type) {
case 'github':
OrchestratorLogger.log(` Type: GitHub repository`);
OrchestratorLogger.log(` Owner: ${parsed.owner}`);
OrchestratorLogger.log(` Repository: ${parsed.repo}`);
OrchestratorLogger.log(` Branch: ${parsed.branch}`);
if (parsed.path) {
OrchestratorLogger.log(` Path: ${parsed.path}`);
}
break;
case 'local':
OrchestratorLogger.log(` Type: Local path`);
OrchestratorLogger.log(` Path: ${parsed.path}`);
break;
case 'npm':
OrchestratorLogger.log(` Type: NPM package`);
OrchestratorLogger.log(` Package: ${parsed.packageName}`);
break;
}
}

View File

@@ -1,3 +0,0 @@
export class ProviderWorkflow {
public Name!: string;
}

View File

@@ -1,67 +0,0 @@
import BuildParameters from '../../../build-parameters';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
class TestOrchestrator implements ProviderInterface {
listResources(): Promise<ProviderResource[]> {
throw new Error('Method not implemented.');
}
listWorkflow(): Promise<ProviderWorkflow[]> {
throw new Error('Method not implemented.');
}
watchWorkflow(): Promise<string> {
throw new Error('Method not implemented.');
}
garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
): Promise<string> {
throw new Error('Method not implemented.');
}
cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {}
setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {}
public async runTaskInWorkflow(
commands: string,
buildGuid: string,
image: string,
// eslint-disable-next-line no-unused-vars
mountdir: string,
// eslint-disable-next-line no-unused-vars
workingdir: string,
// eslint-disable-next-line no-unused-vars
environment: OrchestratorEnvironmentVariable[],
// eslint-disable-next-line no-unused-vars
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(image);
OrchestratorLogger.log(buildGuid);
OrchestratorLogger.log(commands);
return await new Promise((result) => {
result(commands);
});
}
}
export default TestOrchestrator;

View File

@@ -1,483 +0,0 @@
import { assert } from 'node:console';
import fs from 'node:fs';
import path from 'node:path';
import Orchestrator from '../orchestrator';
import OrchestratorLogger from '../services/core/orchestrator-logger';
import { OrchestratorFolders } from '../options/orchestrator-folders';
import { OrchestratorSystem } from '../services/core/orchestrator-system';
import { LfsHashing } from '../services/utility/lfs-hashing';
import { RemoteClientLogger } from './remote-client-logger';
import { Cli } from '../../cli/cli';
import { CliFunction } from '../../cli/cli-functions-repository';
// eslint-disable-next-line github/no-then
const fileExists = async (fpath: fs.PathLike) => !!(await fs.promises.stat(fpath).catch(() => false));
export class Caching {
@CliFunction(`cache-push`, `push to cache`)
static async cachePush() {
try {
const buildParameter = JSON.parse(process.env.BUILD_PARAMETERS || '{}');
Orchestrator.buildParameters = buildParameter;
await Caching.PushToCache(
Cli.options!['cachePushTo'],
Cli.options!['cachePushFrom'],
Cli.options!['artifactName'] || '',
);
} catch (error: any) {
OrchestratorLogger.log(`${error}`);
}
}
@CliFunction(`cache-pull`, `pull from cache`)
static async cachePull() {
try {
const buildParameter = JSON.parse(process.env.BUILD_PARAMETERS || '{}');
Orchestrator.buildParameters = buildParameter;
await Caching.PullFromCache(
Cli.options!['cachePushFrom'],
Cli.options!['cachePushTo'],
Cli.options!['artifactName'] || '',
);
} catch (error: any) {
OrchestratorLogger.log(`${error}`);
}
}
public static async PushToCache(cacheFolder: string, sourceFolder: string, cacheArtifactName: string) {
OrchestratorLogger.log(`Pushing to cache ${sourceFolder}`);
cacheArtifactName = cacheArtifactName.replace(' ', '');
const startPath = process.cwd();
let compressionSuffix = '';
if (Orchestrator.buildParameters.useCompressionStrategy === true) {
compressionSuffix = `.lz4`;
}
OrchestratorLogger.log(`Compression: ${Orchestrator.buildParameters.useCompressionStrategy} ${compressionSuffix}`);
try {
if (!(await fileExists(cacheFolder))) {
await OrchestratorSystem.Run(`mkdir -p ${cacheFolder}`);
}
process.chdir(path.resolve(sourceFolder, '..'));
if (Orchestrator.buildParameters.orchestratorDebug === true) {
OrchestratorLogger.log(
`Hashed cache folder ${await LfsHashing.hashAllFiles(sourceFolder)} ${sourceFolder} ${path.basename(
sourceFolder,
)}`,
);
}
const contents = await fs.promises.readdir(path.basename(sourceFolder));
OrchestratorLogger.log(
`There is ${contents.length} files/dir in the source folder ${path.basename(sourceFolder)}`,
);
if (contents.length === 0) {
OrchestratorLogger.log(
`Did not push source folder to cache because it was empty ${path.basename(sourceFolder)}`,
);
process.chdir(`${startPath}`);
return;
}
// Check disk space before creating tar archive and clean up if needed
let diskUsagePercent = 0;
try {
const diskCheckOutput = await OrchestratorSystem.Run(`df . 2>/dev/null || df /data 2>/dev/null || true`);
OrchestratorLogger.log(`Disk space before tar: ${diskCheckOutput}`);
// Parse disk usage percentage (e.g., "72G 72G 196M 100%")
const usageMatch = diskCheckOutput.match(/(\d+)%/);
if (usageMatch) {
diskUsagePercent = Number.parseInt(usageMatch[1], 10);
}
} catch {
// Ignore disk check errors
}
// If disk usage is high (>90%), proactively clean up old cache files
if (diskUsagePercent > 90) {
OrchestratorLogger.log(`Disk usage is ${diskUsagePercent}% - cleaning up old cache files before tar operation`);
try {
const cacheParent = path.dirname(cacheFolder);
if (await fileExists(cacheParent)) {
// Try to fix permissions first to avoid permission denied errors
await OrchestratorSystem.Run(
`chmod -R u+w ${cacheParent} 2>/dev/null || chown -R $(whoami) ${cacheParent} 2>/dev/null || true`,
);
// Remove cache files older than 6 hours (more aggressive than 1 day)
// Use multiple methods to handle permission issues
await OrchestratorSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +360 -delete 2>/dev/null || true`,
);
// Try with sudo if available
await OrchestratorSystem.Run(
`sudo find ${cacheParent} -name "*.tar*" -type f -mmin +360 -delete 2>/dev/null || true`,
);
// As last resort, try to remove files one by one
await OrchestratorSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +360 -exec rm -f {} + 2>/dev/null || true`,
);
// Also try to remove old cache directories
await OrchestratorSystem.Run(`find ${cacheParent} -type d -empty -delete 2>/dev/null || true`);
// If disk is still very high (>95%), be even more aggressive
if (diskUsagePercent > 95) {
OrchestratorLogger.log(
`Disk usage is very high (${diskUsagePercent}%), performing aggressive cleanup...`,
);
// Remove files older than 1 hour
await OrchestratorSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
);
await OrchestratorSystem.Run(
`sudo find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
);
}
OrchestratorLogger.log(`Cleanup completed. Checking disk space again...`);
const diskCheckAfter = await OrchestratorSystem.Run(`df . 2>/dev/null || df /data 2>/dev/null || true`);
OrchestratorLogger.log(`Disk space after cleanup: ${diskCheckAfter}`);
// Check disk usage again after cleanup
let diskUsageAfterCleanup = 0;
try {
const usageMatchAfter = diskCheckAfter.match(/(\d+)%/);
if (usageMatchAfter) {
diskUsageAfterCleanup = Number.parseInt(usageMatchAfter[1], 10);
}
} catch {
// Ignore parsing errors
}
// If disk is still at 100% after cleanup, skip tar operation to prevent hang.
// Do NOT fail the build here it's better to skip caching than to fail the job
// due to shared CI disk pressure.
if (diskUsageAfterCleanup >= 100) {
const message = `Cannot create cache archive: disk is still at ${diskUsageAfterCleanup}% after cleanup. Tar operation would hang. Skipping cache push; please free up disk space manually if this persists.`;
OrchestratorLogger.logWarning(message);
RemoteClientLogger.log(message);
// Restore working directory before early return
process.chdir(`${startPath}`);
return;
}
}
} catch (cleanupError) {
// If cleanupError is our disk space error, rethrow it
if (cleanupError instanceof Error && cleanupError.message.includes('Cannot create cache archive')) {
throw cleanupError;
}
OrchestratorLogger.log(`Proactive cleanup failed: ${cleanupError}`);
}
}
// Clean up any existing incomplete tar files
try {
await OrchestratorSystem.Run(`rm -f ${cacheArtifactName}.tar${compressionSuffix} 2>/dev/null || true`);
} catch {
// Ignore cleanup errors
}
try {
// Add timeout to tar command to prevent hanging when disk is full
// Use timeout command with 10 minute limit (600 seconds) if available
// Check if timeout command exists, otherwise use regular tar
const tarCommand = `tar -cf ${cacheArtifactName}.tar${compressionSuffix} "${path.basename(sourceFolder)}"`;
let tarCommandToRun = tarCommand;
try {
// Check if timeout command is available
await OrchestratorSystem.Run(`which timeout > /dev/null 2>&1`, true, true);
// Use timeout if available (600 seconds = 10 minutes)
tarCommandToRun = `timeout 600 ${tarCommand}`;
} catch {
// timeout command not available, use regular tar
// Note: This could still hang if disk is full, but the disk space check above should prevent this
tarCommandToRun = tarCommand;
}
await OrchestratorSystem.Run(tarCommandToRun);
} catch (error: any) {
// Check if error is due to disk space or timeout
const errorMessage = error?.message || error?.toString() || '';
if (
errorMessage.includes('No space left') ||
errorMessage.includes('Wrote only') ||
errorMessage.includes('timeout') ||
errorMessage.includes('Terminated')
) {
OrchestratorLogger.log(`Disk space error detected. Attempting aggressive cleanup...`);
// Try to clean up old cache files more aggressively
try {
const cacheParent = path.dirname(cacheFolder);
if (await fileExists(cacheParent)) {
// Try to fix permissions first to avoid permission denied errors
await OrchestratorSystem.Run(
`chmod -R u+w ${cacheParent} 2>/dev/null || chown -R $(whoami) ${cacheParent} 2>/dev/null || true`,
);
// Remove cache files older than 1 hour (very aggressive)
// Use multiple methods to handle permission issues
await OrchestratorSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
);
await OrchestratorSystem.Run(
`sudo find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
);
// As last resort, try to remove files one by one
await OrchestratorSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +60 -exec rm -f {} + 2>/dev/null || true`,
);
// Remove empty cache directories
await OrchestratorSystem.Run(`find ${cacheParent} -type d -empty -delete 2>/dev/null || true`);
// Also try to clean up the entire cache folder if it's getting too large
const cacheRoot = path.resolve(cacheParent, '..');
if (await fileExists(cacheRoot)) {
// Try to fix permissions for cache root too
await OrchestratorSystem.Run(
`chmod -R u+w ${cacheRoot} 2>/dev/null || chown -R $(whoami) ${cacheRoot} 2>/dev/null || true`,
);
// Remove cache entries older than 30 minutes
await OrchestratorSystem.Run(
`find ${cacheRoot} -name "*.tar*" -type f -mmin +30 -delete 2>/dev/null || true`,
);
await OrchestratorSystem.Run(
`sudo find ${cacheRoot} -name "*.tar*" -type f -mmin +30 -delete 2>/dev/null || true`,
);
}
OrchestratorLogger.log(`Aggressive cleanup completed. Retrying tar operation...`);
// Retry the tar operation once after cleanup
let retrySucceeded = false;
try {
await OrchestratorSystem.Run(
`tar -cf ${cacheArtifactName}.tar${compressionSuffix} "${path.basename(sourceFolder)}"`,
);
// If retry succeeds, mark it - we'll continue normally without throwing
retrySucceeded = true;
} catch (retryError: any) {
throw new Error(
`Failed to create cache archive after cleanup. Original error: ${errorMessage}. Retry error: ${
retryError?.message || retryError
}`,
);
}
// If retry succeeded, don't throw the original error - let execution continue after catch block
if (!retrySucceeded) {
throw error;
}
// If we get here, retry succeeded - execution will continue after the catch block
} else {
throw new Error(
`Failed to create cache archive due to insufficient disk space. Error: ${errorMessage}. Cleanup not possible - cache folder missing.`,
);
}
} catch (cleanupError: any) {
OrchestratorLogger.log(`Cleanup attempt failed: ${cleanupError}`);
throw new Error(
`Failed to create cache archive due to insufficient disk space. Error: ${errorMessage}. Cleanup failed: ${
cleanupError?.message || cleanupError
}`,
);
}
} else {
throw error;
}
}
await OrchestratorSystem.Run(`du ${cacheArtifactName}.tar${compressionSuffix}`);
assert(await fileExists(`${cacheArtifactName}.tar${compressionSuffix}`), 'cache archive exists');
assert(await fileExists(path.basename(sourceFolder)), 'source folder exists');
// Ensure the cache folder directory exists before moving the file
// (it might have been deleted by cleanup if it was empty)
if (!(await fileExists(cacheFolder))) {
await OrchestratorSystem.Run(`mkdir -p ${cacheFolder}`);
}
await OrchestratorSystem.Run(`mv ${cacheArtifactName}.tar${compressionSuffix} ${cacheFolder}`);
RemoteClientLogger.log(`moved cache entry ${cacheArtifactName} to ${cacheFolder}`);
assert(
await fileExists(`${path.join(cacheFolder, cacheArtifactName)}.tar${compressionSuffix}`),
'cache archive exists inside cache folder',
);
} catch (error) {
process.chdir(`${startPath}`);
throw error;
}
process.chdir(`${startPath}`);
}
public static async PullFromCache(cacheFolder: string, destinationFolder: string, cacheArtifactName: string = ``) {
OrchestratorLogger.log(`Pulling from cache ${destinationFolder} ${Orchestrator.buildParameters.skipCache}`);
if (`${Orchestrator.buildParameters.skipCache}` === `true`) {
OrchestratorLogger.log(`Skipping cache debugSkipCache is true`);
return;
}
cacheArtifactName = cacheArtifactName.replace(' ', '');
let compressionSuffix = '';
if (Orchestrator.buildParameters.useCompressionStrategy === true) {
compressionSuffix = `.lz4`;
}
const startPath = process.cwd();
RemoteClientLogger.log(`Caching for (lz4 ${compressionSuffix}) ${path.basename(destinationFolder)}`);
try {
if (!(await fileExists(cacheFolder))) {
await fs.promises.mkdir(cacheFolder);
}
if (!(await fileExists(destinationFolder))) {
await fs.promises.mkdir(destinationFolder);
}
const latestInBranch = await (
await OrchestratorSystem.Run(`ls -t "${cacheFolder}" | grep .tar${compressionSuffix}$ | head -1`)
)
.replace(/\n/g, ``)
.replace(`.tar${compressionSuffix}`, '');
process.chdir(cacheFolder);
const cacheSelection =
cacheArtifactName !== `` && (await fileExists(`${cacheArtifactName}.tar${compressionSuffix}`))
? cacheArtifactName
: latestInBranch;
await OrchestratorLogger.log(`cache key ${cacheArtifactName} selection ${cacheSelection}`);
if (await fileExists(`${cacheSelection}.tar${compressionSuffix}`)) {
// Check disk space before extraction to prevent hangs
let diskUsagePercent = 0;
try {
const diskCheckOutput = await OrchestratorSystem.Run(`df . 2>/dev/null || df /data 2>/dev/null || true`);
const usageMatch = diskCheckOutput.match(/(\d+)%/);
if (usageMatch) {
diskUsagePercent = Number.parseInt(usageMatch[1], 10);
}
} catch {
// Ignore disk check errors
}
// If disk is at 100%, skip cache extraction to prevent hangs
if (diskUsagePercent >= 100) {
const message = `Disk is at ${diskUsagePercent}% - skipping cache extraction to prevent hang. Cache may be incomplete or corrupted.`;
OrchestratorLogger.logWarning(message);
RemoteClientLogger.logWarning(message);
// Continue without cache - build will proceed without cached Library
process.chdir(startPath);
return;
}
// Validate tar file integrity before extraction
try {
// Use tar -t to test the archive without extracting (fast check)
// This will fail if the archive is corrupted
await OrchestratorSystem.Run(
`tar -tf ${cacheSelection}.tar${compressionSuffix} > /dev/null 2>&1 || (echo "Tar file validation failed" && exit 1)`,
);
} catch {
const message = `Cache archive ${cacheSelection}.tar${compressionSuffix} appears to be corrupted or incomplete. Skipping cache extraction.`;
OrchestratorLogger.logWarning(message);
RemoteClientLogger.logWarning(message);
// Continue without cache - build will proceed without cached Library
process.chdir(startPath);
return;
}
const resultsFolder = `results${Orchestrator.buildParameters.buildGuid}`;
await OrchestratorSystem.Run(`mkdir -p ${resultsFolder}`);
RemoteClientLogger.log(`cache item exists ${cacheFolder}/${cacheSelection}.tar${compressionSuffix}`);
const fullResultsFolder = path.join(cacheFolder, resultsFolder);
// Extract with timeout to prevent infinite hangs
try {
let tarExtractCommand = `tar -xf ${cacheSelection}.tar${compressionSuffix} -C ${fullResultsFolder}`;
// Add timeout if available (600 seconds = 10 minutes)
try {
await OrchestratorSystem.Run(`which timeout > /dev/null 2>&1`, true, true);
tarExtractCommand = `timeout 600 ${tarExtractCommand}`;
} catch {
// timeout command not available, use regular tar
}
await OrchestratorSystem.Run(tarExtractCommand);
} catch (extractError: any) {
const errorMessage = extractError?.message || extractError?.toString() || '';
// Check for common tar errors that indicate corruption or disk issues
if (
errorMessage.includes('Unexpected EOF') ||
errorMessage.includes('rmtlseek') ||
errorMessage.includes('No space left') ||
errorMessage.includes('timeout') ||
errorMessage.includes('Terminated')
) {
const message = `Cache extraction failed (likely due to corrupted archive or disk space): ${errorMessage}. Continuing without cache.`;
OrchestratorLogger.logWarning(message);
RemoteClientLogger.logWarning(message);
// Continue without cache - build will proceed without cached Library
process.chdir(startPath);
return;
}
// Re-throw other errors
throw extractError;
}
RemoteClientLogger.log(`cache item extracted to ${fullResultsFolder}`);
assert(await fileExists(fullResultsFolder), `cache extraction results folder exists`);
const destinationParentFolder = path.resolve(destinationFolder, '..');
if (await fileExists(destinationFolder)) {
await fs.promises.rmdir(destinationFolder, { recursive: true });
}
await OrchestratorSystem.Run(
`mv "${path.join(fullResultsFolder, path.basename(destinationFolder))}" "${destinationParentFolder}"`,
);
const contents = await fs.promises.readdir(
path.join(destinationParentFolder, path.basename(destinationFolder)),
);
OrchestratorLogger.log(
`There is ${contents.length} files/dir in the cache pulled contents for ${path.basename(destinationFolder)}`,
);
} else {
RemoteClientLogger.logWarning(`cache item ${cacheArtifactName} doesn't exist ${destinationFolder}`);
if (cacheSelection !== ``) {
RemoteClientLogger.logWarning(
`cache item ${cacheArtifactName}.tar${compressionSuffix} doesn't exist ${destinationFolder}`,
);
throw new Error(`Failed to get cache item, but cache hit was found: ${cacheSelection}`);
}
}
} catch (error) {
process.chdir(startPath);
throw error;
}
process.chdir(startPath);
}
public static async handleCachePurging() {
if (process.env.PURGE_REMOTE_BUILDER_CACHE !== undefined) {
RemoteClientLogger.log(`purging ${OrchestratorFolders.purgeRemoteCaching}`);
fs.promises.rmdir(OrchestratorFolders.cacheFolder, { recursive: true });
}
}
}

View File

@@ -1,504 +0,0 @@
import fs from 'node:fs';
import Orchestrator from '../orchestrator';
import { OrchestratorFolders } from '../options/orchestrator-folders';
import { Caching } from './caching';
import { LfsHashing } from '../services/utility/lfs-hashing';
import { RemoteClientLogger } from './remote-client-logger';
import path from 'node:path';
import { assert } from 'node:console';
import OrchestratorLogger from '../services/core/orchestrator-logger';
import { CliFunction } from '../../cli/cli-functions-repository';
import { OrchestratorSystem } from '../services/core/orchestrator-system';
import YAML from 'yaml';
import GitHub from '../../github';
import BuildParameters from '../../build-parameters';
import { Cli } from '../../cli/cli';
import OrchestratorOptions from '../options/orchestrator-options';
import ResourceTracking from '../services/core/resource-tracking';
export class RemoteClient {
@CliFunction(`remote-cli-pre-build`, `sets up a repository, usually before a game-ci build`)
static async setupRemoteClient() {
OrchestratorLogger.log(`bootstrap game ci orchestrator...`);
await ResourceTracking.logDiskUsageSnapshot('remote-cli-pre-build (start)');
if (!(await RemoteClient.handleRetainedWorkspace())) {
await RemoteClient.bootstrapRepository();
}
await RemoteClient.replaceLargePackageReferencesWithSharedReferences();
await RemoteClient.runCustomHookFiles(`before-build`);
}
@CliFunction('remote-cli-log-stream', `log stream from standard input`)
public static async remoteClientLogStream() {
const logFile = Cli.options!['logFile'];
process.stdin.resume();
process.stdin.setEncoding('utf8');
// For K8s, ensure stdout is unbuffered so messages are captured immediately
if (OrchestratorOptions.providerStrategy === 'k8s') {
process.stdout.setDefaultEncoding('utf8');
}
let lingeringLine = '';
process.stdin.on('data', (chunk) => {
const lines = chunk.toString().split('\n');
lines[0] = lingeringLine + lines[0];
lingeringLine = lines.pop() || '';
for (const element of lines) {
// Always write to log file so output can be collected by providers
if (element.trim()) {
fs.appendFileSync(logFile, `${element}\n`);
}
// For K8s, also write to stdout so kubectl logs can capture it
if (OrchestratorOptions.providerStrategy === 'k8s') {
// Write to stdout so kubectl logs can capture it - ensure newline is included
// Stdout flushes automatically on newline, so no explicit flush needed
process.stdout.write(`${element}\n`);
}
OrchestratorLogger.log(element);
}
});
process.stdin.on('end', () => {
if (lingeringLine) {
// Always write to log file so output can be collected by providers
fs.appendFileSync(logFile, `${lingeringLine}\n`);
// For K8s, also write to stdout so kubectl logs can capture it
if (OrchestratorOptions.providerStrategy === 'k8s') {
// Stdout flushes automatically on newline
process.stdout.write(`${lingeringLine}\n`);
}
}
OrchestratorLogger.log(lingeringLine);
});
}
@CliFunction(`remote-cli-post-build`, `runs a orchestrator build`)
public static async remoteClientPostBuild(): Promise<string> {
try {
RemoteClientLogger.log(`Running POST build tasks`);
// Ensure cache key is present in logs for assertions
RemoteClientLogger.log(`CACHE_KEY=${Orchestrator.buildParameters.cacheKey}`);
OrchestratorLogger.log(`${Orchestrator.buildParameters.cacheKey}`);
// Guard: only push Library cache if the folder exists and has contents
try {
const libraryFolderHost = OrchestratorFolders.libraryFolderAbsolute;
if (fs.existsSync(libraryFolderHost)) {
let libraryEntries: string[] = [];
try {
libraryEntries = await fs.promises.readdir(libraryFolderHost);
} catch {
libraryEntries = [];
}
if (libraryEntries.length > 0) {
await Caching.PushToCache(
OrchestratorFolders.ToLinuxFolder(`${OrchestratorFolders.cacheFolderForCacheKeyFull}/Library`),
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.libraryFolderAbsolute),
`lib-${Orchestrator.buildParameters.buildGuid}`,
);
} else {
RemoteClientLogger.log(`Skipping Library cache push (folder is empty)`);
}
} else {
RemoteClientLogger.log(`Skipping Library cache push (folder missing)`);
}
} catch (error: any) {
RemoteClientLogger.logWarning(`Library cache push skipped with error: ${error.message}`);
}
// Guard: only push Build cache if the folder exists and has contents
try {
const buildFolderHost = OrchestratorFolders.projectBuildFolderAbsolute;
if (fs.existsSync(buildFolderHost)) {
let buildEntries: string[] = [];
try {
buildEntries = await fs.promises.readdir(buildFolderHost);
} catch {
buildEntries = [];
}
if (buildEntries.length > 0) {
await Caching.PushToCache(
OrchestratorFolders.ToLinuxFolder(`${OrchestratorFolders.cacheFolderForCacheKeyFull}/build`),
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.projectBuildFolderAbsolute),
`build-${Orchestrator.buildParameters.buildGuid}`,
);
} else {
RemoteClientLogger.log(`Skipping Build cache push (folder is empty)`);
}
} else {
RemoteClientLogger.log(`Skipping Build cache push (folder missing)`);
}
} catch (error: any) {
RemoteClientLogger.logWarning(`Build cache push skipped with error: ${error.message}`);
}
if (!BuildParameters.shouldUseRetainedWorkspaceMode(Orchestrator.buildParameters)) {
const uniqueJobFolderLinux = OrchestratorFolders.ToLinuxFolder(
OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute,
);
if (
fs.existsSync(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute) ||
fs.existsSync(uniqueJobFolderLinux)
) {
await OrchestratorSystem.Run(`rm -r ${uniqueJobFolderLinux} || true`);
} else {
RemoteClientLogger.log(`Skipping cleanup; unique job folder missing`);
}
}
await RemoteClient.runCustomHookFiles(`after-build`);
// WIP - need to give the pod permissions to create config map
await RemoteClientLogger.handleLogManagementPostJob();
} catch (error: any) {
// Log error but don't fail - post-build tasks are best-effort
RemoteClientLogger.logWarning(`Post-build task error: ${error.message}`);
OrchestratorLogger.log(`Post-build task error: ${error.message}`);
}
// Ensure success marker is always present in logs for tests, even if post-build tasks failed
// For K8s, kubectl logs reads from stdout/stderr, so we must write to stdout
// For all providers, we write to stdout so it gets piped through the log stream
// The log stream will capture it and add it to BuildResults
const successMessage = `Activation successful`;
// Write directly to log file first to ensure it's captured even if pipe fails
// This is critical for all providers, especially K8s where timing matters
try {
const logFilePath = Orchestrator.isOrchestratorEnvironment
? `/home/job-log.txt`
: path.join(process.cwd(), 'temp', 'job-log.txt');
if (fs.existsSync(path.dirname(logFilePath))) {
fs.appendFileSync(logFilePath, `${successMessage}\n`);
}
} catch {
// If direct file write fails, continue with other methods
}
// Write to stdout so it gets piped through remote-cli-log-stream when invoked via pipe
// This ensures the message is captured in BuildResults for all providers
// Use synchronous write and ensure newline is included for proper flushing
process.stdout.write(`${successMessage}\n`, 'utf8');
// For K8s, also write to stderr as a backup since kubectl logs reads from both stdout and stderr
// This ensures the message is captured even if stdout pipe has issues
if (OrchestratorOptions.providerStrategy === 'k8s') {
process.stderr.write(`${successMessage}\n`, 'utf8');
}
// Ensure stdout is flushed before process exits (critical for K8s where process might exit quickly)
// For non-TTY streams, we need to explicitly ensure the write completes
if (!process.stdout.isTTY) {
// Give the pipe a moment to process the write
await new Promise((resolve) => setTimeout(resolve, 100));
}
// Also log via OrchestratorLogger and RemoteClientLogger for GitHub Actions and log file
// This ensures the message appears in log files for providers that read from log files
// RemoteClientLogger.log writes directly to the log file, which is important for providers
// that read from the log file rather than stdout
RemoteClientLogger.log(successMessage);
OrchestratorLogger.log(successMessage);
await ResourceTracking.logDiskUsageSnapshot('remote-cli-post-build (end)');
return new Promise((result) => result(``));
}
static async runCustomHookFiles(hookLifecycle: string) {
RemoteClientLogger.log(`RunCustomHookFiles: ${hookLifecycle}`);
const gameCiCustomHooksPath = path.join(OrchestratorFolders.repoPathAbsolute, `game-ci`, `hooks`);
try {
const files = fs.readdirSync(gameCiCustomHooksPath);
for (const file of files) {
const fileContents = fs.readFileSync(path.join(gameCiCustomHooksPath, file), `utf8`);
const fileContentsObject = YAML.parse(fileContents.toString());
if (fileContentsObject.hook === hookLifecycle) {
RemoteClientLogger.log(`Active Hook File ${file} \n \n file contents: \n ${fileContents}`);
await OrchestratorSystem.Run(fileContentsObject.commands);
}
}
} catch (error) {
RemoteClientLogger.log(JSON.stringify(error, undefined, 4));
}
}
public static async bootstrapRepository() {
await OrchestratorSystem.Run(
`mkdir -p ${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute)}`,
);
await OrchestratorSystem.Run(
`mkdir -p ${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.cacheFolderForCacheKeyFull)}`,
);
await RemoteClient.cloneRepoWithoutLFSFiles();
await RemoteClient.sizeOfFolder(
'repo before lfs cache pull',
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.repoPathAbsolute),
);
const lfsHashes = await LfsHashing.createLFSHashFiles();
if (fs.existsSync(OrchestratorFolders.libraryFolderAbsolute)) {
RemoteClientLogger.logWarning(`!Warning!: The Unity library was included in the git repository`);
}
await Caching.PullFromCache(
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.lfsCacheFolderFull),
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.lfsFolderAbsolute),
`${lfsHashes.lfsGuidSum}`,
);
await RemoteClient.sizeOfFolder('repo after lfs cache pull', OrchestratorFolders.repoPathAbsolute);
await RemoteClient.pullLatestLFS();
await RemoteClient.sizeOfFolder('repo before lfs git pull', OrchestratorFolders.repoPathAbsolute);
await Caching.PushToCache(
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.lfsCacheFolderFull),
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.lfsFolderAbsolute),
`${lfsHashes.lfsGuidSum}`,
);
await Caching.PullFromCache(
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.libraryCacheFolderFull),
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.libraryFolderAbsolute),
);
await RemoteClient.sizeOfFolder('repo after library cache pull', OrchestratorFolders.repoPathAbsolute);
await Caching.handleCachePurging();
}
private static async sizeOfFolder(message: string, folder: string) {
if (Orchestrator.buildParameters.orchestratorDebug) {
OrchestratorLogger.log(`Size of ${message}`);
await OrchestratorSystem.Run(`du -sh ${folder}`);
}
}
private static async cloneRepoWithoutLFSFiles() {
process.chdir(`${OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute}`);
if (
fs.existsSync(OrchestratorFolders.repoPathAbsolute) &&
!fs.existsSync(path.join(OrchestratorFolders.repoPathAbsolute, `.git`))
) {
await OrchestratorSystem.Run(`rm -r ${OrchestratorFolders.repoPathAbsolute}`);
OrchestratorLogger.log(`${OrchestratorFolders.repoPathAbsolute} repo exists, but no git folder, cleaning up`);
}
if (
BuildParameters.shouldUseRetainedWorkspaceMode(Orchestrator.buildParameters) &&
fs.existsSync(path.join(OrchestratorFolders.repoPathAbsolute, `.git`))
) {
process.chdir(OrchestratorFolders.repoPathAbsolute);
RemoteClientLogger.log(
`${
OrchestratorFolders.repoPathAbsolute
} repo exists - skipping clone - retained workspace mode ${BuildParameters.shouldUseRetainedWorkspaceMode(
Orchestrator.buildParameters,
)}`,
);
await OrchestratorSystem.Run(`git fetch && git reset --hard ${Orchestrator.buildParameters.gitSha}`);
return;
}
RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
await OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
RemoteClientLogger.log(`Cloning the repository being built:`);
await OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
await OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
try {
const depthArgument = OrchestratorOptions.cloneDepth !== '0' ? `--depth ${OrchestratorOptions.cloneDepth}` : '';
await OrchestratorSystem.Run(
`git clone ${depthArgument} ${OrchestratorFolders.targetBuildRepoUrl} ${path.basename(
OrchestratorFolders.repoPathAbsolute,
)}`.trim(),
);
} catch (error: any) {
throw error;
}
process.chdir(OrchestratorFolders.repoPathAbsolute);
await OrchestratorSystem.Run(`git lfs install`);
assert(fs.existsSync(`.git`), 'git folder exists');
RemoteClientLogger.log(`${Orchestrator.buildParameters.branch}`);
// Ensure refs exist (tags and PR refs)
await OrchestratorSystem.Run(`git fetch --all --tags || true`);
const branchForPrFetch = Orchestrator.buildParameters.branch || '';
if (branchForPrFetch.startsWith('pull/')) {
// Extract PR number and fetch only that specific ref (e.g., pull/731/merge -> 731)
const prNumber = branchForPrFetch.split('/')[1];
if (prNumber) {
await OrchestratorSystem.Run(
`git fetch origin +refs/pull/${prNumber}/merge:refs/remotes/origin/pull/${prNumber}/merge +refs/pull/${prNumber}/head:refs/remotes/origin/pull/${prNumber}/head || true`,
);
}
}
const targetSha = Orchestrator.buildParameters.gitSha;
const targetBranch = Orchestrator.buildParameters.branch;
if (targetSha) {
try {
await OrchestratorSystem.Run(`git checkout ${targetSha}`);
} catch {
try {
await OrchestratorSystem.Run(`git fetch origin ${targetSha} || true`);
await OrchestratorSystem.Run(`git checkout ${targetSha}`);
} catch (error) {
RemoteClientLogger.logWarning(`Falling back to branch checkout; SHA not found: ${targetSha}`);
try {
await OrchestratorSystem.Run(`git checkout ${targetBranch}`);
} catch {
if ((targetBranch || '').startsWith('pull/')) {
await OrchestratorSystem.Run(`git checkout origin/${targetBranch}`);
} else {
throw error;
}
}
}
}
} else {
try {
await OrchestratorSystem.Run(`git checkout ${targetBranch}`);
} catch (_error) {
if ((targetBranch || '').startsWith('pull/')) {
await OrchestratorSystem.Run(`git checkout origin/${targetBranch}`);
} else {
throw _error;
}
}
RemoteClientLogger.log(`buildParameter Git Sha is empty`);
}
assert(fs.existsSync(path.join(`.git`, `lfs`)), 'LFS folder should not exist before caching');
RemoteClientLogger.log(`Checked out ${Orchestrator.buildParameters.branch}`);
}
static async replaceLargePackageReferencesWithSharedReferences() {
OrchestratorLogger.log(`Use Shared Pkgs ${Orchestrator.buildParameters.useLargePackages}`);
GitHub.updateGitHubCheck(`Use Shared Pkgs ${Orchestrator.buildParameters.useLargePackages}`, ``);
if (Orchestrator.buildParameters.useLargePackages) {
const filePath = path.join(OrchestratorFolders.projectPathAbsolute, `Packages/manifest.json`);
let manifest = fs.readFileSync(filePath, 'utf8');
manifest = manifest.replace(/LargeContent/g, '../../../LargeContent');
fs.writeFileSync(filePath, manifest);
OrchestratorLogger.log(`Package Manifest \n ${manifest}`);
GitHub.updateGitHubCheck(`Package Manifest \n ${manifest}`, ``);
}
}
private static async pullLatestLFS() {
process.chdir(OrchestratorFolders.repoPathAbsolute);
await OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge -- %f"`);
await OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process"`);
if (Orchestrator.buildParameters.skipLfs) {
RemoteClientLogger.log(`Skipping LFS pull (skipLfs=true)`);
return;
}
// Best effort: try plain pull first (works for public repos or pre-configured auth)
try {
await OrchestratorSystem.Run(`git lfs pull`, true);
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
RemoteClientLogger.log(`Pulled LFS files without explicit token configuration`);
return;
} catch {
/* no-op: best-effort git lfs pull without tokens may fail */
void 0;
}
// Try with GIT_PRIVATE_TOKEN
try {
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
if (gitPrivateToken) {
RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await OrchestratorSystem.Run(
`git config --global url."https://${gitPrivateToken}@github.com/".insteadOf "https://github.com/"`,
);
await OrchestratorSystem.Run(`git lfs pull`, true);
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
return;
}
} catch (error: any) {
RemoteClientLogger.logCliError(`Failed with GIT_PRIVATE_TOKEN: ${error.message}`);
}
// Try with GITHUB_TOKEN
try {
const githubToken = process.env.GITHUB_TOKEN;
if (githubToken) {
RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
await OrchestratorSystem.Run(
`git config --global url."https://${githubToken}@github.com/".insteadOf "https://github.com/"`,
);
await OrchestratorSystem.Run(`git lfs pull`, true);
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
return;
}
} catch (error: any) {
RemoteClientLogger.logCliError(`Failed with GITHUB_TOKEN: ${error.message}`);
}
// If we get here, all strategies failed; continue without failing the build
RemoteClientLogger.logWarning(`Proceeding without LFS files (no tokens or pull failed)`);
}
static async handleRetainedWorkspace() {
RemoteClientLogger.log(
`Retained Workspace: ${BuildParameters.shouldUseRetainedWorkspaceMode(Orchestrator.buildParameters)}`,
);
// Log cache key explicitly to aid debugging and assertions
OrchestratorLogger.log(`Cache Key: ${Orchestrator.buildParameters.cacheKey}`);
if (
BuildParameters.shouldUseRetainedWorkspaceMode(Orchestrator.buildParameters) &&
fs.existsSync(OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute)) &&
fs.existsSync(OrchestratorFolders.ToLinuxFolder(path.join(OrchestratorFolders.repoPathAbsolute, `.git`)))
) {
OrchestratorLogger.log(`Retained Workspace Already Exists!`);
process.chdir(OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.repoPathAbsolute));
await OrchestratorSystem.Run(`git fetch --all --tags || true`);
const retainedBranchForPrFetch = Orchestrator.buildParameters.branch || '';
if (retainedBranchForPrFetch.startsWith('pull/')) {
// Extract PR number and fetch only that specific ref (e.g., pull/731/merge -> 731)
const prNumber = retainedBranchForPrFetch.split('/')[1];
if (prNumber) {
await OrchestratorSystem.Run(
`git fetch origin +refs/pull/${prNumber}/merge:refs/remotes/origin/pull/${prNumber}/merge +refs/pull/${prNumber}/head:refs/remotes/origin/pull/${prNumber}/head || true`,
);
}
}
await OrchestratorSystem.Run(`git lfs pull`);
await OrchestratorSystem.Run(`git lfs checkout || true`);
const sha = Orchestrator.buildParameters.gitSha;
const branch = Orchestrator.buildParameters.branch;
try {
await OrchestratorSystem.Run(`git reset --hard "${sha}"`);
await OrchestratorSystem.Run(`git checkout ${sha}`);
} catch {
RemoteClientLogger.logWarning(`Retained workspace: SHA not found, falling back to branch ${branch}`);
try {
await OrchestratorSystem.Run(`git checkout ${branch}`);
} catch (error) {
if ((branch || '').startsWith('pull/')) {
await OrchestratorSystem.Run(`git checkout origin/${branch}`);
} else {
throw error;
}
}
}
return true;
}
return false;
}
}

View File

@@ -1,128 +0,0 @@
import OrchestratorLogger from '../services/core/orchestrator-logger';
import fs from 'node:fs';
import path from 'node:path';
import Orchestrator from '../orchestrator';
import OrchestratorOptions from '../options/orchestrator-options';
export class RemoteClientLogger {
private static get LogFilePath() {
// Use a cross-platform temporary directory for local development
if (process.platform === 'win32') {
return path.join(process.cwd(), 'temp', 'job-log.txt');
}
return path.join(`/home`, `job-log.txt`);
}
public static log(message: string) {
const finalMessage = `[Client] ${message}`;
this.appendToFile(finalMessage);
OrchestratorLogger.log(finalMessage);
}
public static logCliError(message: string) {
OrchestratorLogger.log(`[Client][Error] ${message}`);
}
public static logCliDiagnostic(message: string) {
OrchestratorLogger.log(`[Client][Diagnostic] ${message}`);
}
public static logWarning(message: string) {
OrchestratorLogger.logWarning(message);
}
public static appendToFile(message: string) {
if (Orchestrator.isOrchestratorEnvironment) {
// Ensure the directory exists before writing
const logDirectory = path.dirname(RemoteClientLogger.LogFilePath);
if (!fs.existsSync(logDirectory)) {
fs.mkdirSync(logDirectory, { recursive: true });
}
fs.appendFileSync(RemoteClientLogger.LogFilePath, `${message}\n`);
}
}
public static async handleLogManagementPostJob() {
if (OrchestratorOptions.providerStrategy !== 'k8s') {
return;
}
const collectedLogsMessage = `Collected Logs`;
// Write to log file first so it's captured even if kubectl has issues
// This ensures the message is available in BuildResults when logs are read from the file
RemoteClientLogger.appendToFile(collectedLogsMessage);
// For K8s, write to stdout/stderr so kubectl logs can capture it
// This is critical because kubectl logs reads from stdout/stderr, not from GitHub Actions logs
// Write multiple times to increase chance of capture if kubectl is having issues
if (OrchestratorOptions.providerStrategy === 'k8s') {
// Write to stdout multiple times to increase chance of capture
for (let index = 0; index < 3; index++) {
process.stdout.write(`${collectedLogsMessage}\n`, 'utf8');
process.stderr.write(`${collectedLogsMessage}\n`, 'utf8');
}
// Ensure stdout/stderr are flushed
if (!process.stdout.isTTY) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
// Also log via OrchestratorLogger for GitHub Actions
OrchestratorLogger.log(collectedLogsMessage);
// check for log file not existing
if (!fs.existsSync(RemoteClientLogger.LogFilePath)) {
const logFileMissingMessage = `Log file does not exist`;
if (OrchestratorOptions.providerStrategy === 'k8s') {
process.stdout.write(`${logFileMissingMessage}\n`, 'utf8');
}
OrchestratorLogger.log(logFileMissingMessage);
// check if Orchestrator.isOrchestratorEnvironment is true, log
if (!Orchestrator.isOrchestratorEnvironment) {
const notCloudEnvironmentMessage = `Orchestrator is not running in a cloud environment, not collecting logs`;
if (OrchestratorOptions.providerStrategy === 'k8s') {
process.stdout.write(`${notCloudEnvironmentMessage}\n`, 'utf8');
}
OrchestratorLogger.log(notCloudEnvironmentMessage);
}
return;
}
const logFileExistsMessage = `Log file exist`;
if (OrchestratorOptions.providerStrategy === 'k8s') {
process.stdout.write(`${logFileExistsMessage}\n`, 'utf8');
}
OrchestratorLogger.log(logFileExistsMessage);
await new Promise((resolve) => setTimeout(resolve, 1));
// let hashedLogs = fs.readFileSync(RemoteClientLogger.LogFilePath).toString();
//
// hashedLogs = md5(hashedLogs);
//
// for (let index = 0; index < 3; index++) {
// OrchestratorLogger.log(`LOGHASH: ${hashedLogs}`);
// const logs = fs.readFileSync(RemoteClientLogger.LogFilePath).toString();
// OrchestratorLogger.log(`LOGS: ${Buffer.from(logs).toString('base64')}`);
// OrchestratorLogger.log(
// `Game CI's "Orchestrator System" will cancel the log when it has successfully received the log data to verify all logs have been received.`,
// );
//
// // wait for 15 seconds to allow the log to be sent
// await new Promise((resolve) => setTimeout(resolve, 15000));
// }
}
public static HandleLog(message: string): boolean {
if (RemoteClientLogger.value !== '') {
RemoteClientLogger.value += `\n`;
}
RemoteClientLogger.value += message;
return false;
}
static value: string = '';
}

View File

@@ -1,57 +0,0 @@
import GitHub from '../../../github';
import Orchestrator from '../../orchestrator';
import { OrchestratorStatics } from '../../options/orchestrator-statics';
import OrchestratorLogger from './orchestrator-logger';
import * as core from '@actions/core';
export class FollowLogStreamService {
static Reset() {
FollowLogStreamService.DidReceiveEndOfTransmission = false;
}
static errors = ``;
public static DidReceiveEndOfTransmission = false;
public static handleIteration(message: string, shouldReadLogs: boolean, shouldCleanup: boolean, output: string) {
if (message.includes(`---${Orchestrator.buildParameters.logId}`)) {
OrchestratorLogger.log('End of log transmission received');
FollowLogStreamService.DidReceiveEndOfTransmission = true;
shouldReadLogs = false;
} else if (message.includes('Rebuilding Library because the asset database could not be found!')) {
GitHub.updateGitHubCheck(`Library was not found, importing new Library`, ``);
core.warning('LIBRARY NOT FOUND!');
core.setOutput('library-found', 'false');
} else if (message.includes('Build succeeded')) {
GitHub.updateGitHubCheck(`Build succeeded`, `Build succeeded`);
core.setOutput('build-result', 'success');
} else if (message.includes('Build fail')) {
GitHub.updateGitHubCheck(
`Build failed\n${FollowLogStreamService.errors}`,
`Build failed`,
`failure`,
`completed`,
);
core.setOutput('build-result', 'failed');
core.setFailed('unity build failed');
core.error('BUILD FAILED!');
} else if (message.toLowerCase().includes('error ')) {
core.error(message);
FollowLogStreamService.errors += `\n${message}`;
} else if (message.toLowerCase().includes('error: ')) {
core.error(message);
FollowLogStreamService.errors += `\n${message}`;
} else if (message.toLowerCase().includes('command failed: ')) {
FollowLogStreamService.errors += `\n${message}`;
} else if (message.toLowerCase().includes('invalid ')) {
FollowLogStreamService.errors += `\n${message}`;
} else if (message.toLowerCase().includes('incompatible ')) {
FollowLogStreamService.errors += `\n${message}`;
} else if (message.toLowerCase().includes('cannot be found')) {
FollowLogStreamService.errors += `\n${message}`;
}
// Always append log lines to output so tests can assert on BuildResults
output += `${message}\n`;
OrchestratorLogger.log(`[${OrchestratorStatics.logPrefix}] ${message}`);
return { shouldReadLogs, shouldCleanup, output };
}
}

View File

@@ -1,47 +0,0 @@
import * as core from '@actions/core';
class OrchestratorLogger {
private static timestamp: number;
private static globalTimestamp: number;
public static setup() {
this.timestamp = this.createTimestamp();
this.globalTimestamp = this.timestamp;
}
public static log(message: string) {
core.info(message);
}
public static logWarning(message: string) {
core.warning(message);
}
public static logLine(message: string) {
core.info(`${message}\n`);
}
public static error(message: string) {
core.error(message);
}
public static logWithTime(message: string) {
const newTimestamp = this.createTimestamp();
core.info(
`${message} (Since previous: ${this.calculateTimeDiff(
newTimestamp,
this.timestamp,
)}, Total time: ${this.calculateTimeDiff(newTimestamp, this.globalTimestamp)})`,
);
this.timestamp = newTimestamp;
}
private static calculateTimeDiff(x: number, y: number) {
return Math.floor((x - y) / 1000);
}
private static createTimestamp() {
return Date.now();
}
}
export default OrchestratorLogger;

View File

@@ -1,24 +0,0 @@
import BuildParameters from '../../../build-parameters';
class OrchestratorResult {
public BuildParameters: BuildParameters;
public BuildResults: string;
public BuildSucceeded: boolean;
public BuildFinished: boolean;
public LibraryCacheUsed: boolean;
public constructor(
buildParameters: BuildParameters,
buildResults: string,
buildSucceeded: boolean,
buildFinished: boolean,
libraryCacheUsed: boolean,
) {
this.BuildParameters = buildParameters;
this.BuildResults = buildResults;
this.BuildSucceeded = buildSucceeded;
this.BuildFinished = buildFinished;
this.LibraryCacheUsed = libraryCacheUsed;
}
}
export default OrchestratorResult;

View File

@@ -1,69 +0,0 @@
import { exec } from 'child_process';
import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
export class OrchestratorSystem {
public static async RunAndReadLines(command: string): Promise<string[]> {
const result = await OrchestratorSystem.Run(command, false, true);
return result
.split(`\n`)
.map((x) => x.replace(`\r`, ``))
.filter((x) => x !== ``)
.map((x) => {
const lineValues = x.split(` `);
return lineValues[lineValues.length - 1];
});
}
public static async Run(
command: string,
suppressError = false,
suppressLogs = false,
// eslint-disable-next-line no-unused-vars
outputCallback?: (output: string) => void,
) {
for (const element of command.split(`\n`)) {
if (!suppressLogs) {
RemoteClientLogger.log(element);
}
}
return await new Promise<string>((promise, throwError) => {
let output = '';
const child = exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout, stderr) => {
if (!suppressError && error) {
RemoteClientLogger.log(error.toString());
throwError(error);
}
if (stderr) {
const diagnosticOutput = `${stderr.toString()}`;
if (!suppressLogs) {
RemoteClientLogger.logCliDiagnostic(diagnosticOutput);
}
output += diagnosticOutput;
}
const outputChunk = `${stdout}`;
if (outputCallback) {
outputCallback(outputChunk);
}
output += outputChunk;
});
child.on('close', (code) => {
if (!suppressLogs) {
RemoteClientLogger.log(`[${code}]`);
}
if (code !== 0 && !suppressError) {
throwError(output);
}
const outputLines = output.split(`\n`);
for (const element of outputLines) {
if (!suppressLogs) {
RemoteClientLogger.log(element);
}
}
promise(output);
});
});
}
}

View File

@@ -1,84 +0,0 @@
import OrchestratorLogger from './orchestrator-logger';
import OrchestratorOptions from '../../options/orchestrator-options';
import Orchestrator from '../../orchestrator';
import { OrchestratorSystem } from './orchestrator-system';
class ResourceTracking {
static isEnabled(): boolean {
return (
OrchestratorOptions.resourceTracking ||
OrchestratorOptions.orchestratorDebug ||
process.env['orchestratorTests'] === 'true'
);
}
static logAllocationSummary(context: string) {
if (!ResourceTracking.isEnabled()) {
return;
}
const buildParameters = Orchestrator.buildParameters;
const allocations = {
providerStrategy: buildParameters.providerStrategy,
containerCpu: buildParameters.containerCpu,
containerMemory: buildParameters.containerMemory,
dockerCpuLimit: buildParameters.dockerCpuLimit,
dockerMemoryLimit: buildParameters.dockerMemoryLimit,
kubeVolumeSize: buildParameters.kubeVolumeSize,
kubeStorageClass: buildParameters.kubeStorageClass,
kubeVolume: buildParameters.kubeVolume,
containerNamespace: buildParameters.containerNamespace,
storageProvider: buildParameters.storageProvider,
rcloneRemote: buildParameters.rcloneRemote,
dockerWorkspacePath: buildParameters.dockerWorkspacePath,
cacheKey: buildParameters.cacheKey,
maxRetainedWorkspaces: buildParameters.maxRetainedWorkspaces,
useCompressionStrategy: buildParameters.useCompressionStrategy,
useLargePackages: buildParameters.useLargePackages,
ephemeralStorageRequest: process.env['orchestratorTests'] === 'true' ? 'not set' : '2Gi',
};
OrchestratorLogger.log(`[ResourceTracking] Allocation summary (${context}):`);
OrchestratorLogger.log(JSON.stringify(allocations, undefined, 2));
}
static async logDiskUsageSnapshot(context: string) {
if (!ResourceTracking.isEnabled()) {
return;
}
OrchestratorLogger.log(`[ResourceTracking] Disk usage snapshot (${context})`);
await ResourceTracking.runAndLog('df -h', 'df -h');
await ResourceTracking.runAndLog('du -sh .', 'du -sh .');
await ResourceTracking.runAndLog('du -sh ./orchestrator-cache', 'du -sh ./orchestrator-cache');
await ResourceTracking.runAndLog('du -sh ./temp', 'du -sh ./temp');
await ResourceTracking.runAndLog('du -sh ./logs', 'du -sh ./logs');
}
static async logK3dNodeDiskUsage(context: string) {
if (!ResourceTracking.isEnabled()) {
return;
}
const nodes = ['k3d-unity-builder-agent-0', 'k3d-unity-builder-server-0'];
OrchestratorLogger.log(`[ResourceTracking] K3d node disk usage (${context})`);
for (const node of nodes) {
await ResourceTracking.runAndLog(
`k3d node ${node}`,
`docker exec ${node} sh -c "df -h /var/lib/rancher/k3s 2>/dev/null || df -h / 2>/dev/null || true" || true`,
);
}
}
private static async runAndLog(label: string, command: string) {
try {
const output = await OrchestratorSystem.Run(command, true, true);
const trimmed = output.trim();
OrchestratorLogger.log(`[ResourceTracking] ${label}:\n${trimmed || 'no output'}`);
} catch (error: any) {
OrchestratorLogger.log(`[ResourceTracking] ${label} failed: ${error?.message || error}`);
}
}
}
export default ResourceTracking;

View File

@@ -1,402 +0,0 @@
import OrchestratorLogger from './orchestrator-logger';
import BuildParameters from '../../../build-parameters';
import Orchestrator from '../../orchestrator';
import Input from '../../../input';
import {
CreateBucketCommand,
DeleteObjectCommand,
HeadBucketCommand,
ListObjectsV2Command,
PutObjectCommand,
S3,
} from '@aws-sdk/client-s3';
import { AwsClientFactory } from '../../providers/aws/aws-client-factory';
import { promisify } from 'node:util';
import { exec as execCallback } from 'node:child_process';
const exec = promisify(execCallback);
export class SharedWorkspaceLocking {
private static _s3: S3;
private static get s3(): S3 {
if (!SharedWorkspaceLocking._s3) {
// Use factory so LocalStack endpoint/path-style settings are honored
SharedWorkspaceLocking._s3 = AwsClientFactory.getS3();
}
return SharedWorkspaceLocking._s3;
}
private static get useRclone() {
return Orchestrator.buildParameters.storageProvider === 'rclone';
}
private static async rclone(command: string): Promise<string> {
const { stdout } = await exec(`rclone ${command}`);
return stdout.toString();
}
private static get bucket() {
return SharedWorkspaceLocking.useRclone
? Orchestrator.buildParameters.rcloneRemote
: Orchestrator.buildParameters.awsStackName;
}
public static get workspaceBucketRoot() {
return SharedWorkspaceLocking.useRclone
? `${SharedWorkspaceLocking.bucket}/`
: `s3://${SharedWorkspaceLocking.bucket}/`;
}
public static get workspaceRoot() {
return `${SharedWorkspaceLocking.workspaceBucketRoot}locks/`;
}
private static get workspacePrefix() {
return `locks/`;
}
private static async ensureBucketExists(): Promise<void> {
const bucket = SharedWorkspaceLocking.bucket;
if (SharedWorkspaceLocking.useRclone) {
try {
await SharedWorkspaceLocking.rclone(`lsf ${bucket}`);
} catch {
await SharedWorkspaceLocking.rclone(`mkdir ${bucket}`);
}
return;
}
try {
await SharedWorkspaceLocking.s3.send(new HeadBucketCommand({ Bucket: bucket }));
} catch {
const region = Input.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
const createParameters: any = { Bucket: bucket };
if (region && region !== 'us-east-1') {
createParameters.CreateBucketConfiguration = { LocationConstraint: region };
}
await SharedWorkspaceLocking.s3.send(new CreateBucketCommand(createParameters));
}
}
private static async listObjects(prefix: string, bucket = SharedWorkspaceLocking.bucket): Promise<string[]> {
await SharedWorkspaceLocking.ensureBucketExists();
if (prefix !== '' && !prefix.endsWith('/')) {
prefix += '/';
}
if (SharedWorkspaceLocking.useRclone) {
const path = `${bucket}/${prefix}`;
try {
const output = await SharedWorkspaceLocking.rclone(`lsjson ${path}`);
const json = JSON.parse(output) as { Name: string; IsDir: boolean }[];
return json.map((entry) => (entry.IsDir ? `${entry.Name}/` : entry.Name));
} catch {
return [];
}
}
const result = await SharedWorkspaceLocking.s3.send(
new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, Delimiter: '/' }),
);
const entries: string[] = [];
for (const p of result.CommonPrefixes || []) {
if (p.Prefix) entries.push(p.Prefix.slice(prefix.length));
}
for (const c of result.Contents || []) {
if (c.Key && c.Key !== prefix) entries.push(c.Key.slice(prefix.length));
}
return entries;
}
public static async GetAllWorkspaces(buildParametersContext: BuildParameters): Promise<string[]> {
if (!(await SharedWorkspaceLocking.DoesCacheKeyTopLevelExist(buildParametersContext))) {
return [];
}
return (
await SharedWorkspaceLocking.listObjects(
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
)
)
.map((x) => x.replace(`/`, ``))
.filter((x) => x.endsWith(`_workspace`))
.map((x) => x.split(`_`)[1]);
}
public static async DoesCacheKeyTopLevelExist(buildParametersContext: BuildParameters) {
try {
const rootLines = await SharedWorkspaceLocking.listObjects('');
const lockFolderExists = rootLines.map((x) => x.replace(`/`, ``)).includes(`locks`);
if (lockFolderExists) {
const lines = await SharedWorkspaceLocking.listObjects(SharedWorkspaceLocking.workspacePrefix);
return lines.map((x) => x.replace(`/`, ``)).includes(buildParametersContext.cacheKey);
} else {
return false;
}
} catch {
return false;
}
}
public static NewWorkspaceName() {
return `${Orchestrator.retainedWorkspacePrefix}-${Orchestrator.buildParameters.buildGuid}`;
}
public static async GetAllLocksForWorkspace(
workspace: string,
buildParametersContext: BuildParameters,
): Promise<string[]> {
if (!(await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext))) {
return [];
}
return (
await SharedWorkspaceLocking.listObjects(
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
)
)
.map((x) => x.replace(`/`, ``))
.filter((x) => x.includes(workspace) && x.endsWith(`_lock`));
}
public static async GetLockedWorkspace(workspace: string, runId: string, buildParametersContext: BuildParameters) {
if (buildParametersContext.maxRetainedWorkspaces === 0) {
return false;
}
if (await SharedWorkspaceLocking.DoesCacheKeyTopLevelExist(buildParametersContext)) {
const workspaces = await SharedWorkspaceLocking.GetFreeWorkspaces(buildParametersContext);
OrchestratorLogger.log(`run agent ${runId} is trying to access a workspace, free: ${JSON.stringify(workspaces)}`);
for (const element of workspaces) {
const lockResult = await SharedWorkspaceLocking.LockWorkspace(element, runId, buildParametersContext);
OrchestratorLogger.log(
`run agent: ${runId} try lock workspace: ${element} locking attempt result: ${lockResult}`,
);
if (lockResult) {
return true;
}
}
}
if (await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext)) {
workspace = SharedWorkspaceLocking.NewWorkspaceName();
Orchestrator.lockedWorkspace = workspace;
}
const createResult = await SharedWorkspaceLocking.CreateWorkspace(workspace, buildParametersContext);
const lockResult = await SharedWorkspaceLocking.LockWorkspace(workspace, runId, buildParametersContext);
OrchestratorLogger.log(
`run agent ${runId} didn't find a free workspace so created: ${workspace} createWorkspaceSuccess: ${createResult} Lock:${lockResult}`,
);
return createResult && lockResult;
}
public static async DoesWorkspaceExist(workspace: string, buildParametersContext: BuildParameters) {
return (
(await SharedWorkspaceLocking.GetAllWorkspaces(buildParametersContext)).filter((x) => x.includes(workspace))
.length > 0
);
}
public static async HasWorkspaceLock(
workspace: string,
runId: string,
buildParametersContext: BuildParameters,
): Promise<boolean> {
const locks = (await SharedWorkspaceLocking.GetAllLocksForWorkspace(workspace, buildParametersContext))
.map((x) => {
return {
name: x,
timestamp: Number(x.split(`_`)[0]),
};
})
.sort((x) => x.timestamp);
const lockMatches = locks.filter((x) => x.name.includes(runId));
const includesRunLock = lockMatches.length > 0 && locks.indexOf(lockMatches[0]) === 0;
OrchestratorLogger.log(
`Checking has workspace lock, runId: ${runId}, workspace: ${workspace}, success: ${includesRunLock} \n- Num of locks created by Run Agent: ${
lockMatches.length
} Num of Locks: ${locks.length}, Time ordered index for Run Agent: ${locks.indexOf(lockMatches[0])} \n \n`,
);
return includesRunLock;
}
public static async GetFreeWorkspaces(buildParametersContext: BuildParameters): Promise<string[]> {
const result: string[] = [];
const workspaces = await SharedWorkspaceLocking.GetAllWorkspaces(buildParametersContext);
for (const element of workspaces) {
const isLocked = await SharedWorkspaceLocking.IsWorkspaceLocked(element, buildParametersContext);
const isBelowMax = await SharedWorkspaceLocking.IsWorkspaceBelowMax(element, buildParametersContext);
OrchestratorLogger.log(`workspace ${element} locked:${isLocked} below max:${isBelowMax}`);
if (!isLocked && isBelowMax) {
result.push(element);
}
}
return result;
}
public static async IsWorkspaceBelowMax(
workspace: string,
buildParametersContext: BuildParameters,
): Promise<boolean> {
const workspaces = await SharedWorkspaceLocking.GetAllWorkspaces(buildParametersContext);
if (workspace === ``) {
return (
workspaces.length < buildParametersContext.maxRetainedWorkspaces ||
buildParametersContext.maxRetainedWorkspaces === 0
);
}
const ordered: any[] = [];
for (const ws of workspaces) {
ordered.push({
name: ws,
timestamp: await SharedWorkspaceLocking.GetWorkspaceTimestamp(ws, buildParametersContext),
});
}
ordered.sort((x) => x.timestamp);
const matches = ordered.filter((x) => x.name.includes(workspace));
const isWorkspaceBelowMax =
matches.length > 0 &&
(ordered.indexOf(matches[0]) < buildParametersContext.maxRetainedWorkspaces ||
buildParametersContext.maxRetainedWorkspaces === 0);
return isWorkspaceBelowMax;
}
public static async GetWorkspaceTimestamp(
workspace: string,
buildParametersContext: BuildParameters,
): Promise<Number> {
if (workspace.split(`_`).length > 0) {
return Number(workspace.split(`_`)[1]);
}
if (!(await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext))) {
throw new Error("Workspace doesn't exist, can't call get all locks");
}
return (
await SharedWorkspaceLocking.listObjects(
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
)
)
.map((x) => x.replace(`/`, ``))
.filter((x) => x.includes(workspace) && x.endsWith(`_workspace`))
.map((x) => Number(x))[0];
}
public static async IsWorkspaceLocked(workspace: string, buildParametersContext: BuildParameters): Promise<boolean> {
if (!(await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext))) {
throw new Error(`workspace doesn't exist ${workspace}`);
}
const files = await SharedWorkspaceLocking.listObjects(
`${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`,
);
const lockFilesExist =
files.filter((x) => {
return x.includes(workspace) && x.endsWith(`_lock`);
}).length > 0;
return lockFilesExist;
}
public static async CreateWorkspace(workspace: string, buildParametersContext: BuildParameters): Promise<boolean> {
if (await SharedWorkspaceLocking.DoesWorkspaceExist(workspace, buildParametersContext)) {
throw new Error(`${workspace} already exists`);
}
const timestamp = Date.now();
const key = `${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/${timestamp}_${workspace}_workspace`;
await SharedWorkspaceLocking.ensureBucketExists();
await (SharedWorkspaceLocking.useRclone
? SharedWorkspaceLocking.rclone(`touch ${SharedWorkspaceLocking.bucket}/${key}`)
: SharedWorkspaceLocking.s3.send(
new PutObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: key, Body: new Uint8Array(0) }),
));
const workspaces = await SharedWorkspaceLocking.GetAllWorkspaces(buildParametersContext);
OrchestratorLogger.log(`All workspaces ${workspaces}`);
if (!(await SharedWorkspaceLocking.IsWorkspaceBelowMax(workspace, buildParametersContext))) {
OrchestratorLogger.log(`Workspace is above max ${workspaces} ${buildParametersContext.maxRetainedWorkspaces}`);
await SharedWorkspaceLocking.CleanupWorkspace(workspace, buildParametersContext);
return false;
}
return true;
}
public static async LockWorkspace(
workspace: string,
runId: string,
buildParametersContext: BuildParameters,
): Promise<boolean> {
const existingWorkspace = workspace.endsWith(`_workspace`);
const ending = existingWorkspace ? workspace : `${workspace}_workspace`;
const key = `${SharedWorkspaceLocking.workspacePrefix}${
buildParametersContext.cacheKey
}/${Date.now()}_${runId}_${ending}_lock`;
await SharedWorkspaceLocking.ensureBucketExists();
await (SharedWorkspaceLocking.useRclone
? SharedWorkspaceLocking.rclone(`touch ${SharedWorkspaceLocking.bucket}/${key}`)
: SharedWorkspaceLocking.s3.send(
new PutObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: key, Body: new Uint8Array(0) }),
));
const hasLock = await SharedWorkspaceLocking.HasWorkspaceLock(workspace, runId, buildParametersContext);
if (hasLock) {
Orchestrator.lockedWorkspace = workspace;
} else {
await (SharedWorkspaceLocking.useRclone
? SharedWorkspaceLocking.rclone(`delete ${SharedWorkspaceLocking.bucket}/${key}`)
: SharedWorkspaceLocking.s3.send(new DeleteObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: key })));
}
return hasLock;
}
public static async ReleaseWorkspace(
workspace: string,
runId: string,
buildParametersContext: BuildParameters,
): Promise<boolean> {
await SharedWorkspaceLocking.ensureBucketExists();
const files = await SharedWorkspaceLocking.GetAllLocksForWorkspace(workspace, buildParametersContext);
const file = files.find((x) => x.includes(workspace) && x.endsWith(`_lock`) && x.includes(runId));
OrchestratorLogger.log(`All Locks ${files} ${workspace} ${runId}`);
OrchestratorLogger.log(`Deleting lock ${workspace}/${file}`);
OrchestratorLogger.log(`rm ${SharedWorkspaceLocking.workspaceRoot}${buildParametersContext.cacheKey}/${file}`);
if (file) {
await (SharedWorkspaceLocking.useRclone
? SharedWorkspaceLocking.rclone(
`delete ${SharedWorkspaceLocking.bucket}/${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/${file}`,
)
: SharedWorkspaceLocking.s3.send(
new DeleteObjectCommand({
Bucket: SharedWorkspaceLocking.bucket,
Key: `${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/${file}`,
}),
));
}
return !(await SharedWorkspaceLocking.HasWorkspaceLock(workspace, runId, buildParametersContext));
}
public static async CleanupWorkspace(workspace: string, buildParametersContext: BuildParameters) {
const prefix = `${SharedWorkspaceLocking.workspacePrefix}${buildParametersContext.cacheKey}/`;
const files = await SharedWorkspaceLocking.listObjects(prefix);
for (const file of files.filter((x) => x.includes(`_${workspace}_`))) {
await (SharedWorkspaceLocking.useRclone
? SharedWorkspaceLocking.rclone(`delete ${SharedWorkspaceLocking.bucket}/${prefix}${file}`)
: SharedWorkspaceLocking.s3.send(
new DeleteObjectCommand({ Bucket: SharedWorkspaceLocking.bucket, Key: `${prefix}${file}` }),
));
}
}
public static async ReadLines(command: string): Promise<string[]> {
const path = command.replace('aws s3 ls', '').replace('rclone lsf', '').trim();
const withoutScheme = path.replace('s3://', '');
const [bucket, ...rest] = withoutScheme.split('/');
const prefix = rest.join('/');
return SharedWorkspaceLocking.listObjects(prefix, bucket);
}
}
export default SharedWorkspaceLocking;

View File

@@ -1,200 +0,0 @@
import BuildParameters from '../../../build-parameters';
import Input from '../../../input';
import OrchestratorOptions from '../../options/orchestrator-options';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorOptionsReader from '../../options/orchestrator-options-reader';
import OrchestratorQueryOverride from '../../options/orchestrator-query-override';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { CommandHookService } from '../hooks/command-hook-service';
export class TaskParameterSerializer {
static readonly blockedParameterNames: Set<string> = new Set([
'0',
'length',
'prototype',
'',
'unityVersion',
'CACHE_UNITY_INSTALLATION_ON_MAC',
'RUNNER_TEMP_PATH',
'NAME',
'CUSTOM_JOB',
]);
public static createOrchestratorEnvironmentVariables(
buildParameters: BuildParameters,
): OrchestratorEnvironmentVariable[] {
const result: OrchestratorEnvironmentVariable[] = this.uniqBy(
[
...[
{ name: 'BUILD_TARGET', value: buildParameters.targetPlatform },
{ name: 'UNITY_VERSION', value: buildParameters.editorVersion },
{ name: 'GITHUB_TOKEN', value: process.env.GITHUB_TOKEN },
],
...TaskParameterSerializer.serializeFromObject(buildParameters),
...TaskParameterSerializer.serializeInput(),
...TaskParameterSerializer.serializeOrchestratorOptions(),
...CommandHookService.getSecrets(CommandHookService.getHooks(buildParameters.commandHooks)),
// Include AWS environment variables for LocalStack compatibility
...TaskParameterSerializer.serializeAwsEnvironmentVariables(),
]
.filter(
(x) =>
!TaskParameterSerializer.blockedParameterNames.has(x.name) &&
x.value !== '' &&
x.value !== undefined &&
x.value !== `undefined`,
)
.map((x) => {
x.name = `${TaskParameterSerializer.ToEnvVarFormat(x.name)}`;
x.value = `${x.value}`;
return x;
}),
(item: OrchestratorEnvironmentVariable) => item.name,
);
return result;
}
// eslint-disable-next-line no-unused-vars
static uniqBy(a: OrchestratorEnvironmentVariable[], key: (parameters: OrchestratorEnvironmentVariable) => string) {
const seen: { [key: string]: boolean } = {};
return a.filter(function (item) {
const k = key(item);
return seen.hasOwnProperty(k) ? false : (seen[k] = true);
});
}
public static readBuildParameterFromEnvironment(): BuildParameters {
const buildParameters = new BuildParameters();
const keys = [
...new Set(
Object.getOwnPropertyNames(process.env)
.filter((x) => !this.blockedParameterNames.has(x) && x.startsWith(''))
.map((x) => TaskParameterSerializer.UndoEnvVarFormat(x)),
),
];
for (const element of keys) {
if (element !== `customJob`) {
buildParameters[element] = process.env[`${TaskParameterSerializer.ToEnvVarFormat(element)}`];
}
}
return buildParameters;
}
private static serializeInput() {
return TaskParameterSerializer.serializeFromType(Input);
}
private static serializeOrchestratorOptions() {
return TaskParameterSerializer.serializeFromType(OrchestratorOptions);
}
private static serializeAwsEnvironmentVariables() {
const awsEnvironmentVariables = [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_DEFAULT_REGION',
'AWS_REGION',
'AWS_S3_ENDPOINT',
'AWS_ENDPOINT',
'AWS_CLOUD_FORMATION_ENDPOINT',
'AWS_ECS_ENDPOINT',
'AWS_KINESIS_ENDPOINT',
'AWS_CLOUD_WATCH_LOGS_ENDPOINT',
];
return awsEnvironmentVariables
.filter((key) => process.env[key] !== undefined)
.map((key) => ({
name: key,
value: process.env[key] || '',
}));
}
public static ToEnvVarFormat(input: string): string {
return OrchestratorOptions.ToEnvVarFormat(input);
}
public static UndoEnvVarFormat(element: string): string {
return this.camelize(element.toLowerCase().replace(/_+/g, ' '));
}
private static camelize(string: string) {
return TaskParameterSerializer.uncapitalizeFirstLetter(
string
.replace(/(^\w)|([A-Z])|(\b\w)/g, function (word: string, index: number) {
return index === 0 ? word.toLowerCase() : word.toUpperCase();
})
.replace(/\s+/g, ''),
);
}
private static uncapitalizeFirstLetter(string: string) {
return string.charAt(0).toLowerCase() + string.slice(1);
}
private static serializeFromObject(buildParameters: any) {
const array: any[] = [];
const keys = Object.getOwnPropertyNames(buildParameters).filter((x) => !this.blockedParameterNames.has(x));
for (const element of keys) {
array.push({
name: TaskParameterSerializer.ToEnvVarFormat(element),
value: buildParameters[element],
});
}
return array;
}
private static serializeFromType(type: any) {
const array: any[] = [];
const input = OrchestratorOptionsReader.GetProperties();
for (const element of input) {
if (typeof type[element] !== 'function' && array.filter((x) => x.name === element).length === 0) {
array.push({
name: element,
value: `${type[element]}`,
});
}
}
return array;
}
public static readDefaultSecrets(): OrchestratorSecret[] {
let array = new Array();
array = TaskParameterSerializer.tryAddInput(array, 'UNITY_SERIAL');
array = TaskParameterSerializer.tryAddInput(array, 'UNITY_EMAIL');
array = TaskParameterSerializer.tryAddInput(array, 'UNITY_PASSWORD');
// array = TaskParameterSerializer.tryAddInput(array, 'UNITY_LICENSE');
array = TaskParameterSerializer.tryAddInput(array, 'GIT_PRIVATE_TOKEN');
return array;
}
private static getValue(key: string) {
return OrchestratorQueryOverride.queryOverrides !== undefined &&
OrchestratorQueryOverride.queryOverrides[key] !== undefined
? OrchestratorQueryOverride.queryOverrides[key]
: process.env[key];
}
private static tryAddInput(array: OrchestratorSecret[], key: string): OrchestratorSecret[] {
const value = TaskParameterSerializer.getValue(key);
if (value !== undefined && value !== '' && value !== 'null') {
array.push({
ParameterKey: key,
EnvironmentVariable: key,
ParameterValue: value,
});
}
return array;
}
}

View File

@@ -1,118 +0,0 @@
import { BuildParameters, Input } from '../../..';
import YAML from 'yaml';
import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
import path from 'node:path';
import OrchestratorOptions from '../../options/orchestrator-options';
import * as fs from 'node:fs';
import OrchestratorLogger from '../core/orchestrator-logger';
import { CommandHook } from './command-hook';
// import OrchestratorLogger from './orchestrator-logger';
export class CommandHookService {
public static ApplyHooksToCommands(commands: string, buildParameters: BuildParameters): string {
const hooks = CommandHookService.getHooks(buildParameters.commandHooks);
OrchestratorLogger.log(`Applying hooks ${hooks.length}`);
return `echo "---"
echo "start orchestrator init"
${OrchestratorOptions.orchestratorDebug ? `printenv` : `#`}
echo "start of orchestrator job"
${hooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
${commands}
${hooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
echo "end of orchestrator job"
echo "---${buildParameters.logId}"`;
}
public static getHooks(customCommandHooks: string): CommandHook[] {
const experimentHooks = customCommandHooks;
let output = new Array<CommandHook>();
if (experimentHooks && experimentHooks !== '') {
try {
output = YAML.parse(experimentHooks);
} catch (error) {
throw error;
}
}
return [
...output.filter((x) => x.hook !== undefined && x.hook.length > 0),
...CommandHookService.GetCustomHooksFromFiles(`before`),
...CommandHookService.GetCustomHooksFromFiles(`after`),
];
}
static GetCustomHooksFromFiles(hookLifecycle: string): CommandHook[] {
const results: CommandHook[] = [];
// RemoteClientLogger.log(`GetCustomHookFiles: ${hookLifecycle}`);
try {
const gameCiCustomHooksPath = path.join(process.cwd(), `game-ci`, `command-hooks`);
const files = fs.readdirSync(gameCiCustomHooksPath);
for (const file of files) {
if (!OrchestratorOptions.commandHookFiles.includes(file.replace(`.yaml`, ``))) {
continue;
}
const fileContents = fs.readFileSync(path.join(gameCiCustomHooksPath, file), `utf8`);
const fileContentsObject = CommandHookService.ParseHooks(fileContents)[0];
if (fileContentsObject.hook.includes(hookLifecycle)) {
results.push(fileContentsObject);
}
}
} catch (error) {
RemoteClientLogger.log(`Failed Getting: ${hookLifecycle} \n ${JSON.stringify(error, undefined, 4)}`);
}
// RemoteClientLogger.log(`Active Steps From Hooks: \n ${JSON.stringify(results, undefined, 4)}`);
return results;
}
private static ConvertYamlSecrets(object: CommandHook) {
if (object.secrets === undefined) {
object.secrets = [];
return;
}
object.secrets = object.secrets.map((x: any) => {
return {
ParameterKey: x.name,
EnvironmentVariable: Input.ToEnvVarFormat(x.name),
ParameterValue: x.value,
};
});
}
public static ParseHooks(hooks: string): CommandHook[] {
if (hooks === '') {
return [];
}
// if (Orchestrator.buildParameters?.orchestratorIntegrationTests) {
// OrchestratorLogger.log(`Parsing build hooks: ${steps}`);
// }
const isArray = hooks.replace(/\s/g, ``)[0] === `-`;
const object: CommandHook[] = isArray ? YAML.parse(hooks) : [YAML.parse(hooks)];
for (const hook of object) {
CommandHookService.ConvertYamlSecrets(hook);
if (hook.secrets === undefined) {
hook.secrets = [];
}
}
if (object === undefined) {
throw new Error(`Failed to parse ${hooks}`);
}
return object;
}
public static getSecrets(hooks: any) {
const secrets = hooks.map((x: any) => x.secrets).filter((x: any) => x !== undefined && x.length > 0);
// eslint-disable-next-line unicorn/no-array-reduce
return secrets.length > 0 ? secrets.reduce((x: any, y: any) => [...x, ...y]) : [];
}
}

View File

@@ -1,9 +0,0 @@
import OrchestratorSecret from '../../options/orchestrator-secret';
export class CommandHook {
public commands: string[] = new Array<string>();
public secrets: OrchestratorSecret[] = new Array<OrchestratorSecret>();
public name!: string;
public hook!: string[];
public step!: string[];
}

View File

@@ -1,420 +0,0 @@
import YAML from 'yaml';
import Orchestrator from '../../orchestrator';
import { CustomWorkflow } from '../../workflows/custom-workflow';
import { RemoteClientLogger } from '../../remote-client/remote-client-logger';
import path from 'node:path';
import fs from 'node:fs';
import Input from '../../../input';
import OrchestratorOptions from '../../options/orchestrator-options';
import { ContainerHook as ContainerHook } from './container-hook';
import { OrchestratorStepParameters } from '../../options/orchestrator-step-parameters';
export class ContainerHookService {
static GetContainerHooksFromFiles(hookLifecycle: string): ContainerHook[] {
const results: ContainerHook[] = [];
try {
const gameCiCustomStepsPath = path.join(process.cwd(), `game-ci`, `container-hooks`);
const files = fs.readdirSync(gameCiCustomStepsPath);
for (const file of files) {
if (!OrchestratorOptions.containerHookFiles.includes(file.replace(`.yaml`, ``))) {
// RemoteClientLogger.log(`Skipping CustomStepFile: ${file}`);
continue;
}
const fileContents = fs.readFileSync(path.join(gameCiCustomStepsPath, file), `utf8`);
const fileContentsObject = ContainerHookService.ParseContainerHooks(fileContents)[0];
if (fileContentsObject.hook === hookLifecycle) {
results.push(fileContentsObject);
}
}
} catch (error) {
RemoteClientLogger.log(`Failed Getting: ${hookLifecycle} \n ${JSON.stringify(error, undefined, 4)}`);
}
// RemoteClientLogger.log(`Active Steps From Files: \n ${JSON.stringify(results, undefined, 4)}`);
const builtInContainerHooks: ContainerHook[] = ContainerHookService.ParseContainerHooks(
`- name: aws-s3-upload-build
image: amazon/aws-cli
hook: after
commands: |
if command -v aws > /dev/null 2>&1; then
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile default || true
fi
if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile default || true
fi
if [ -n "$AWS_DEFAULT_REGION" ]; then
aws configure set region "$AWS_DEFAULT_REGION" --profile default || true
fi
ENDPOINT_ARGS=""
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
aws $ENDPOINT_ARGS s3 cp /data/cache/$CACHE_KEY/build/build-${Orchestrator.buildParameters.buildGuid}.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
} s3://${Orchestrator.buildParameters.awsStackName}/orchestrator-cache/$CACHE_KEY/build/build-$BUILD_GUID.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
} || true
rm /data/cache/$CACHE_KEY/build/build-${Orchestrator.buildParameters.buildGuid}.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
} || true
else
echo "AWS CLI not available, skipping aws-s3-upload-build"
fi
secrets:
- name: awsAccessKeyId
value: ${process.env.AWS_ACCESS_KEY_ID || ``}
- name: awsSecretAccessKey
value: ${process.env.AWS_SECRET_ACCESS_KEY || ``}
- name: awsDefaultRegion
value: ${process.env.AWS_REGION || ``}
- name: AWS_S3_ENDPOINT
value: ${OrchestratorOptions.awsS3Endpoint || process.env.AWS_S3_ENDPOINT || ``}
- name: aws-s3-pull-build
image: amazon/aws-cli
commands: |
mkdir -p /data/cache/$CACHE_KEY/build/
if command -v aws > /dev/null 2>&1; then
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile default || true
fi
if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile default || true
fi
if [ -n "$AWS_DEFAULT_REGION" ]; then
aws configure set region "$AWS_DEFAULT_REGION" --profile default || true
fi
ENDPOINT_ARGS=""
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
aws $ENDPOINT_ARGS s3 ls ${Orchestrator.buildParameters.awsStackName}/orchestrator-cache/ || true
aws $ENDPOINT_ARGS s3 ls ${Orchestrator.buildParameters.awsStackName}/orchestrator-cache/$CACHE_KEY/build || true
aws s3 cp s3://${
Orchestrator.buildParameters.awsStackName
}/orchestrator-cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
} /data/cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
} || true
else
echo "AWS CLI not available, skipping aws-s3-pull-build"
fi
secrets:
- name: AWS_ACCESS_KEY_ID
- name: AWS_SECRET_ACCESS_KEY
- name: AWS_DEFAULT_REGION
- name: BUILD_GUID_TARGET
- name: AWS_S3_ENDPOINT
- name: steam-deploy-client
image: steamcmd/steamcmd
commands: |
apt-get update
apt-get install -y curl tar coreutils git tree > /dev/null
curl -s https://gist.githubusercontent.com/frostebite/1d56f5505b36b403b64193b7a6e54cdc/raw/fa6639ed4ef750c4268ea319d63aa80f52712ffb/deploy-client-steam.sh | bash
secrets:
- name: STEAM_USERNAME
- name: STEAM_PASSWORD
- name: STEAM_APPID
- name: STEAM_SSFN_FILE_NAME
- name: STEAM_SSFN_FILE_CONTENTS
- name: STEAM_CONFIG_VDF_1
- name: STEAM_CONFIG_VDF_2
- name: STEAM_CONFIG_VDF_3
- name: STEAM_CONFIG_VDF_4
- name: BUILD_GUID_TARGET
- name: RELEASE_BRANCH
- name: steam-deploy-project
image: steamcmd/steamcmd
commands: |
apt-get update
apt-get install -y curl tar coreutils git tree > /dev/null
curl -s https://gist.githubusercontent.com/frostebite/969da6a41002a0e901174124b643709f/raw/02403e53fb292026cba81ddcf4ff35fc1eba111d/steam-deploy-project.sh | bash
secrets:
- name: STEAM_USERNAME
- name: STEAM_PASSWORD
- name: STEAM_APPID
- name: STEAM_SSFN_FILE_NAME
- name: STEAM_SSFN_FILE_CONTENTS
- name: STEAM_CONFIG_VDF_1
- name: STEAM_CONFIG_VDF_2
- name: STEAM_CONFIG_VDF_3
- name: STEAM_CONFIG_VDF_4
- name: BUILD_GUID_2
- name: RELEASE_BRANCH
- name: aws-s3-upload-cache
image: amazon/aws-cli
hook: after
commands: |
if command -v aws > /dev/null 2>&1; then
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile default || true
fi
if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile default || true
fi
if [ -n "$AWS_DEFAULT_REGION" ]; then
aws configure set region "$AWS_DEFAULT_REGION" --profile default || true
fi
ENDPOINT_ARGS=""
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
aws $ENDPOINT_ARGS s3 cp --recursive /data/cache/$CACHE_KEY/lfs s3://${
Orchestrator.buildParameters.awsStackName
}/orchestrator-cache/$CACHE_KEY/lfs || true
rm -r /data/cache/$CACHE_KEY/lfs || true
aws $ENDPOINT_ARGS s3 cp --recursive /data/cache/$CACHE_KEY/Library s3://${
Orchestrator.buildParameters.awsStackName
}/orchestrator-cache/$CACHE_KEY/Library || true
rm -r /data/cache/$CACHE_KEY/Library || true
else
echo "AWS CLI not available, skipping aws-s3-upload-cache"
fi
secrets:
- name: AWS_ACCESS_KEY_ID
value: ${process.env.AWS_ACCESS_KEY_ID || ``}
- name: AWS_SECRET_ACCESS_KEY
value: ${process.env.AWS_SECRET_ACCESS_KEY || ``}
- name: AWS_DEFAULT_REGION
value: ${process.env.AWS_REGION || ``}
- name: AWS_S3_ENDPOINT
value: ${OrchestratorOptions.awsS3Endpoint || process.env.AWS_S3_ENDPOINT || ``}
- name: aws-s3-pull-cache
image: amazon/aws-cli
hook: before
commands: |
mkdir -p /data/cache/$CACHE_KEY/Library/
mkdir -p /data/cache/$CACHE_KEY/lfs/
if command -v aws > /dev/null 2>&1; then
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile default || true
fi
if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile default || true
fi
if [ -n "$AWS_DEFAULT_REGION" ]; then
aws configure set region "$AWS_DEFAULT_REGION" --profile default || true
fi
ENDPOINT_ARGS=""
if [ -n "$AWS_S3_ENDPOINT" ]; then ENDPOINT_ARGS="--endpoint-url $AWS_S3_ENDPOINT"; fi
aws $ENDPOINT_ARGS s3 ls ${Orchestrator.buildParameters.awsStackName}/orchestrator-cache/ 2>/dev/null || true
aws $ENDPOINT_ARGS s3 ls ${
Orchestrator.buildParameters.awsStackName
}/orchestrator-cache/$CACHE_KEY/ 2>/dev/null || true
BUCKET1="${Orchestrator.buildParameters.awsStackName}/orchestrator-cache/$CACHE_KEY/Library/"
OBJECT1=""
LS_OUTPUT1="$(aws $ENDPOINT_ARGS s3 ls $BUCKET1 2>/dev/null || echo '')"
if [ -n "$LS_OUTPUT1" ] && [ "$LS_OUTPUT1" != "" ]; then
OBJECT1="$(echo "$LS_OUTPUT1" | sort | tail -n 1 | awk '{print $4}' || '')"
if [ -n "$OBJECT1" ] && [ "$OBJECT1" != "" ]; then
aws $ENDPOINT_ARGS s3 cp s3://$BUCKET1$OBJECT1 /data/cache/$CACHE_KEY/Library/ 2>/dev/null || true
fi
fi
BUCKET2="${Orchestrator.buildParameters.awsStackName}/orchestrator-cache/$CACHE_KEY/lfs/"
OBJECT2=""
LS_OUTPUT2="$(aws $ENDPOINT_ARGS s3 ls $BUCKET2 2>/dev/null || echo '')"
if [ -n "$LS_OUTPUT2" ] && [ "$LS_OUTPUT2" != "" ]; then
OBJECT2="$(echo "$LS_OUTPUT2" | sort | tail -n 1 | awk '{print $4}' || '')"
if [ -n "$OBJECT2" ] && [ "$OBJECT2" != "" ]; then
aws $ENDPOINT_ARGS s3 cp s3://$BUCKET2$OBJECT2 /data/cache/$CACHE_KEY/lfs/ 2>/dev/null || true
fi
fi
else
echo "AWS CLI not available, skipping aws-s3-pull-cache"
fi
- name: rclone-upload-build
image: rclone/rclone
hook: after
commands: |
if command -v rclone > /dev/null 2>&1; then
rclone copy /data/cache/$CACHE_KEY/build/build-${Orchestrator.buildParameters.buildGuid}.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
} ${Orchestrator.buildParameters.rcloneRemote}/orchestrator-cache/$CACHE_KEY/build/ || true
rm /data/cache/$CACHE_KEY/build/build-${Orchestrator.buildParameters.buildGuid}.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
} || true
else
echo "rclone not available, skipping rclone-upload-build"
fi
secrets:
- name: RCLONE_REMOTE
value: ${Orchestrator.buildParameters.rcloneRemote || ``}
- name: rclone-pull-build
image: rclone/rclone
commands: |
mkdir -p /data/cache/$CACHE_KEY/build/
if command -v rclone > /dev/null 2>&1; then
rclone copy ${
Orchestrator.buildParameters.rcloneRemote
}/orchestrator-cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
} /data/cache/$CACHE_KEY/build/build-$BUILD_GUID_TARGET.tar${
Orchestrator.buildParameters.useCompressionStrategy ? '.lz4' : ''
} || true
else
echo "rclone not available, skipping rclone-pull-build"
fi
secrets:
- name: BUILD_GUID_TARGET
- name: RCLONE_REMOTE
value: ${Orchestrator.buildParameters.rcloneRemote || ``}
- name: rclone-upload-cache
image: rclone/rclone
hook: after
commands: |
if command -v rclone > /dev/null 2>&1; then
rclone copy /data/cache/$CACHE_KEY/lfs ${
Orchestrator.buildParameters.rcloneRemote
}/orchestrator-cache/$CACHE_KEY/lfs || true
rm -r /data/cache/$CACHE_KEY/lfs || true
rclone copy /data/cache/$CACHE_KEY/Library ${
Orchestrator.buildParameters.rcloneRemote
}/orchestrator-cache/$CACHE_KEY/Library || true
rm -r /data/cache/$CACHE_KEY/Library || true
else
echo "rclone not available, skipping rclone-upload-cache"
fi
secrets:
- name: RCLONE_REMOTE
value: ${Orchestrator.buildParameters.rcloneRemote || ``}
- name: rclone-pull-cache
image: rclone/rclone
hook: before
commands: |
mkdir -p /data/cache/$CACHE_KEY/Library/
mkdir -p /data/cache/$CACHE_KEY/lfs/
if command -v rclone > /dev/null 2>&1; then
rclone copy ${
Orchestrator.buildParameters.rcloneRemote
}/orchestrator-cache/$CACHE_KEY/Library /data/cache/$CACHE_KEY/Library/ || true
rclone copy ${
Orchestrator.buildParameters.rcloneRemote
}/orchestrator-cache/$CACHE_KEY/lfs /data/cache/$CACHE_KEY/lfs/ || true
else
echo "rclone not available, skipping rclone-pull-cache"
fi
secrets:
- name: RCLONE_REMOTE
value: ${Orchestrator.buildParameters.rcloneRemote || ``}
- name: debug-cache
image: ubuntu
hook: after
commands: |
apt-get update > /dev/null || true
${OrchestratorOptions.orchestratorDebug ? `apt-get install -y tree > /dev/null || true` : `#`}
${OrchestratorOptions.orchestratorDebug ? `tree -L 3 /data/cache || true` : `#`}
secrets:
- name: awsAccessKeyId
value: ${process.env.AWS_ACCESS_KEY_ID || ``}
- name: awsSecretAccessKey
value: ${process.env.AWS_SECRET_ACCESS_KEY || ``}
- name: awsDefaultRegion
value: ${process.env.AWS_REGION || ``}
- name: AWS_S3_ENDPOINT
value: ${OrchestratorOptions.awsS3Endpoint || process.env.AWS_S3_ENDPOINT || ``}`,
).filter((x) => OrchestratorOptions.containerHookFiles.includes(x.name) && x.hook === hookLifecycle);
// In local provider mode (non-container) or when AWS credentials are not present, skip AWS S3 hooks
const provider = Orchestrator.buildParameters?.providerStrategy;
const isContainerized = provider === 'aws' || provider === 'k8s' || provider === 'local-docker';
const hasAwsCreds =
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
(process.env.awsAccessKeyId && process.env.awsSecretAccessKey);
// Always include AWS hooks on the AWS provider (task role provides creds),
// otherwise require explicit creds for other containerized providers.
const shouldIncludeAwsHooks =
isContainerized && !Orchestrator.buildParameters?.skipCache && (provider === 'aws' || Boolean(hasAwsCreds));
const filteredBuiltIns = shouldIncludeAwsHooks
? builtInContainerHooks
: builtInContainerHooks.filter((x) => x.image !== 'amazon/aws-cli');
if (filteredBuiltIns.length > 0) {
results.push(...filteredBuiltIns);
}
return results;
}
private static ConvertYamlSecrets(object: ContainerHook) {
if (object.secrets === undefined) {
object.secrets = [];
return;
}
object.secrets = object.secrets.map((x: { [key: string]: any }) => {
return {
ParameterKey: x.name,
EnvironmentVariable: Input.ToEnvVarFormat(x.name),
ParameterValue: x.value,
};
});
}
public static ParseContainerHooks(steps: string): ContainerHook[] {
if (steps === '') {
return [];
}
const isArray = steps.replace(/\s/g, ``)[0] === `-`;
const object: ContainerHook[] = isArray ? YAML.parse(steps) : [YAML.parse(steps)];
for (const step of object) {
ContainerHookService.ConvertYamlSecrets(step);
if (step.secrets === undefined) {
step.secrets = [];
} else {
for (const secret of step.secrets) {
if (secret.ParameterValue === undefined && process.env[secret.EnvironmentVariable] !== undefined) {
if (Orchestrator.buildParameters?.orchestratorDebug) {
// OrchestratorLogger.log(`Injecting custom step ${step.name} from env var ${secret.ParameterKey}`);
}
secret.ParameterValue = process.env[secret.ParameterKey] || ``;
}
}
}
if (step.image === undefined) {
step.image = `ubuntu`;
}
// Ensure allowFailure defaults to false if not explicitly set
if (step.allowFailure === undefined) {
step.allowFailure = false;
}
}
if (object === undefined) {
throw new Error(`Failed to parse ${steps}`);
}
return object;
}
static async RunPostBuildSteps(orchestratorStepState: OrchestratorStepParameters) {
let output = ``;
const steps: ContainerHook[] = [
...ContainerHookService.ParseContainerHooks(Orchestrator.buildParameters.postBuildContainerHooks),
...ContainerHookService.GetContainerHooksFromFiles(`after`),
];
if (steps.length > 0) {
output += await CustomWorkflow.runContainerJob(
steps,
orchestratorStepState.environment,
orchestratorStepState.secrets,
);
}
return output;
}
static async RunPreBuildSteps(orchestratorStepState: OrchestratorStepParameters) {
let output = ``;
const steps: ContainerHook[] = [
...ContainerHookService.ParseContainerHooks(Orchestrator.buildParameters.preBuildContainerHooks),
...ContainerHookService.GetContainerHooksFromFiles(`before`),
];
if (steps.length > 0) {
output += await CustomWorkflow.runContainerJob(
steps,
orchestratorStepState.environment,
orchestratorStepState.secrets,
);
}
return output;
}
}

View File

@@ -1,10 +0,0 @@
import OrchestratorSecret from '../../options/orchestrator-secret';
export class ContainerHook {
public commands!: string;
public secrets: OrchestratorSecret[] = new Array<OrchestratorSecret>();
public name!: string;
public image: string = `ubuntu`;
public hook!: string;
public allowFailure: boolean = false; // If true, hook failures won't stop the build
}

View File

@@ -1,43 +0,0 @@
import path from 'node:path';
import { OrchestratorFolders } from '../../options/orchestrator-folders';
import { OrchestratorSystem } from '../core/orchestrator-system';
import fs from 'node:fs';
import { Cli } from '../../../cli/cli';
import { CliFunction } from '../../../cli/cli-functions-repository';
export class LfsHashing {
public static async createLFSHashFiles() {
await OrchestratorSystem.Run(`git lfs ls-files -l | cut -d ' ' -f1 | sort > .lfs-assets-guid`);
await OrchestratorSystem.Run(`md5sum .lfs-assets-guid > .lfs-assets-guid-sum`);
const lfsHashes = {
lfsGuid: fs
.readFileSync(`${path.join(OrchestratorFolders.repoPathAbsolute, `.lfs-assets-guid`)}`, 'utf8')
.replace(/\n/g, ``),
lfsGuidSum: fs
.readFileSync(`${path.join(OrchestratorFolders.repoPathAbsolute, `.lfs-assets-guid-sum`)}`, 'utf8')
.replace(' .lfs-assets-guid', '')
.replace(/\n/g, ``),
};
return lfsHashes;
}
public static async hashAllFiles(folder: string) {
const startPath = process.cwd();
process.chdir(folder);
const result = await (await OrchestratorSystem.Run(`find -type f -exec md5sum "{}" + | sort | md5sum`))
.replace(/\n/g, '')
.split(` `)[0];
process.chdir(startPath);
return result;
}
@CliFunction(`hash`, `hash all folder contents`)
static async hash() {
if (!Cli.options) {
return;
}
const folder = Cli.options['cachePushFrom'];
LfsHashing.hashAllFiles(folder);
}
}

View File

@@ -1,8 +0,0 @@
import BuildParameters from '../../build-parameters';
import { Cli } from '../../cli/cli';
export async function CreateParameters(overrides: any) {
if (overrides) Cli.options = overrides;
return BuildParameters.create();
}

View File

@@ -1,138 +0,0 @@
import Orchestrator from '../../orchestrator';
import { BuildParameters, ImageTag } from '../../..';
import UnityVersioning from '../../../unity-versioning';
import { Cli } from '../../../cli/cli';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { v4 as uuidv4 } from 'uuid';
import OrchestratorOptions from '../../options/orchestrator-options';
import setups from '../orchestrator-suite.test';
import * as fs from 'node:fs';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
async function CreateParameters(overrides: any) {
if (overrides) {
Cli.options = overrides;
}
return await BuildParameters.create();
}
describe('Orchestrator Caching', () => {
it('Responds', () => {});
setups();
if (OrchestratorOptions.orchestratorDebug) {
it('Run one build it should not use cache, run subsequent build which should use cache', async () => {
const overrides: any = {
versioning: 'None',
image: 'ubuntu',
projectPath: 'test-project',
unityVersion: UnityVersioning.determineUnityVersion('test-project', UnityVersioning.read('test-project')),
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
containerHookFiles: `debug-cache`,
orchestratorBranch: `orchestrator-develop`,
orchestratorDebug: true,
};
// For AWS LocalStack tests, explicitly set provider strategy to 'aws'
// This ensures we use AWS LocalStack instead of defaulting to local-docker
// But don't override if k8s provider is already set
if (
process.env.AWS_S3_ENDPOINT &&
process.env.AWS_S3_ENDPOINT.includes('localhost') &&
OrchestratorOptions.providerStrategy !== 'k8s'
) {
overrides.providerStrategy = 'aws';
overrides.containerHookFiles += `,aws-s3-pull-cache,aws-s3-upload-cache`;
}
if (OrchestratorOptions.providerStrategy === `k8s`) {
overrides.containerHookFiles += `,aws-s3-pull-cache,aws-s3-upload-cache`;
}
const buildParameter = await CreateParameters(overrides);
expect(buildParameter.projectPath).toEqual(overrides.projectPath);
const baseImage = new ImageTag(buildParameter);
const resultsObject = await Orchestrator.run(buildParameter, baseImage.toString());
const results = resultsObject.BuildResults;
const libraryString = 'Rebuilding Library because the asset database could not be found!';
const cachePushFail = 'Did not push source folder to cache because it was empty Library';
expect(resultsObject.BuildSucceeded).toBe(true);
// Keep minimal assertions to reduce brittleness
expect(results).not.toContain(cachePushFail);
OrchestratorLogger.log(`run 1 succeeded`);
if (OrchestratorOptions.providerStrategy === `local-docker`) {
await OrchestratorSystem.Run(`tree ./orchestrator-cache/cache`);
await OrchestratorSystem.Run(
`cp ./orchestrator-cache/cache/${buildParameter.cacheKey}/Library/lib-${buildParameter.buildGuid}.tar ./`,
);
await OrchestratorSystem.Run(`mkdir results`);
await OrchestratorSystem.Run(`tar -xf lib-${buildParameter.buildGuid}.tar -C ./results`);
await OrchestratorSystem.Run(`tree -d ./results`);
const cacheFolderExists = fs.existsSync(`orchestrator-cache/cache/${overrides.cacheKey}`);
expect(cacheFolderExists).toBeTruthy();
}
const buildParameter2 = await CreateParameters(overrides);
buildParameter2.cacheKey = buildParameter.cacheKey;
const baseImage2 = new ImageTag(buildParameter2);
const results2Object = await Orchestrator.run(buildParameter2, baseImage2.toString());
const results2 = results2Object.BuildResults;
OrchestratorLogger.log(`run 2 succeeded`);
const build2ContainsCacheKey = results2.includes(buildParameter.cacheKey);
const build2NotContainsZeroLibraryCacheFilesMessage = !results2.includes(
'There is 0 files/dir in the cache pulled contents for Library',
);
const build2NotContainsZeroLFSCacheFilesMessage = !results2.includes(
'There is 0 files/dir in the cache pulled contents for LFS',
);
expect(build2ContainsCacheKey).toBeTruthy();
expect(results2).toContain('Activation successful');
expect(results2Object.BuildSucceeded).toBe(true);
const splitResults = results2.split('Activation successful');
expect(splitResults[splitResults.length - 1]).not.toContain(libraryString);
expect(build2NotContainsZeroLibraryCacheFilesMessage).toBeTruthy();
expect(build2NotContainsZeroLFSCacheFilesMessage).toBeTruthy();
}, 1_000_000_000);
afterAll(async () => {
// Clean up cache files to prevent disk space issues
if (OrchestratorOptions.providerStrategy === `local-docker` || OrchestratorOptions.providerStrategy === `aws`) {
const cachePath = `./orchestrator-cache`;
if (fs.existsSync(cachePath)) {
try {
OrchestratorLogger.log(`Cleaning up cache directory: ${cachePath}`);
// Try to change ownership first (if running as root or with sudo)
// Then try multiple cleanup methods to handle permission issues
await OrchestratorSystem.Run(
`chmod -R u+w ${cachePath} 2>/dev/null || chown -R $(whoami) ${cachePath} 2>/dev/null || true`,
);
// Try regular rm first
await OrchestratorSystem.Run(`rm -rf ${cachePath}/* 2>/dev/null || true`);
// If that fails, try with sudo if available
await OrchestratorSystem.Run(`sudo rm -rf ${cachePath}/* 2>/dev/null || true`);
// As last resort, try to remove files one by one, ignoring permission errors
await OrchestratorSystem.Run(
`find ${cachePath} -type f -exec rm -f {} + 2>/dev/null || find ${cachePath} -type f -delete 2>/dev/null || true`,
);
// Remove empty directories
await OrchestratorSystem.Run(`find ${cachePath} -type d -empty -delete 2>/dev/null || true`);
} catch (error: any) {
OrchestratorLogger.log(`Failed to cleanup cache: ${error.message}`);
// Don't throw - cleanup failures shouldn't fail the test suite
}
}
}
});
}
});

View File

@@ -1,92 +0,0 @@
import Orchestrator from '../../orchestrator';
import { BuildParameters } from '../../..';
import UnityVersioning from '../../../unity-versioning';
import { Cli } from '../../../cli/cli';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { v4 as uuidv4 } from 'uuid';
import OrchestratorOptions from '../../options/orchestrator-options';
import setups from '../orchestrator-suite.test';
import SharedWorkspaceLocking from '../../services/core/shared-workspace-locking';
async function CreateParameters(overrides: any) {
if (overrides) {
Cli.options = overrides;
}
return await BuildParameters.create();
}
describe('Orchestrator Locking', () => {
setups();
it('Responds', () => {});
if (OrchestratorOptions.orchestratorDebug) {
it(`Simple Locking End2End Flow`, async () => {
const overrides: any = {
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.determineUnityVersion('test-project', UnityVersioning.read('test-project')),
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
maxRetainedWorkspaces: 3,
};
const buildParameters = await CreateParameters(overrides);
const newWorkspaceName = `test-workspace-${uuidv4()}`;
const runId = uuidv4();
Orchestrator.buildParameters = buildParameters;
await SharedWorkspaceLocking.CreateWorkspace(newWorkspaceName, buildParameters);
expect(await SharedWorkspaceLocking.DoesCacheKeyTopLevelExist(buildParameters)).toBeTruthy();
expect(await SharedWorkspaceLocking.DoesWorkspaceExist(newWorkspaceName, buildParameters)).toBeTruthy();
const isExpectedUnlockedBeforeLocking =
(await SharedWorkspaceLocking.IsWorkspaceLocked(newWorkspaceName, buildParameters)) === false;
expect(isExpectedUnlockedBeforeLocking).toBeTruthy();
const result = await SharedWorkspaceLocking.LockWorkspace(newWorkspaceName, runId, buildParameters);
expect(result).toBeTruthy();
const lines = await SharedWorkspaceLocking.ReadLines(`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}`);
expect(lines.map((x) => x.replace(`/`, ``)).includes(buildParameters.cacheKey));
expect(await SharedWorkspaceLocking.DoesCacheKeyTopLevelExist(buildParameters)).toBeTruthy();
expect(await SharedWorkspaceLocking.DoesWorkspaceExist(newWorkspaceName, buildParameters)).toBeTruthy();
const allLocks = await SharedWorkspaceLocking.GetAllLocksForWorkspace(newWorkspaceName, buildParameters);
expect(
(
await SharedWorkspaceLocking.ReadLines(
`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}${buildParameters.cacheKey}/`,
)
).filter((x) => x.endsWith(`${newWorkspaceName}_workspace_lock`)),
).toHaveLength(1);
expect(
(
await SharedWorkspaceLocking.ReadLines(
`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}${buildParameters.cacheKey}/`,
)
).filter((x) => x.endsWith(`${newWorkspaceName}_workspace`)),
).toHaveLength(1);
expect(allLocks.filter((x) => x.endsWith(`${newWorkspaceName}_workspace_lock`)).length).toBeGreaterThan(0);
const isExpectedLockedAfterLocking =
(await SharedWorkspaceLocking.IsWorkspaceLocked(newWorkspaceName, buildParameters)) === true;
expect(isExpectedLockedAfterLocking).toBeTruthy();
const locksBeforeRelease = await SharedWorkspaceLocking.GetAllLocksForWorkspace(
newWorkspaceName,
buildParameters,
);
OrchestratorLogger.log(JSON.stringify(locksBeforeRelease, undefined, 4));
expect(locksBeforeRelease.length).toBe(1);
await SharedWorkspaceLocking.ReleaseWorkspace(newWorkspaceName, runId, buildParameters);
const locks = await SharedWorkspaceLocking.GetAllLocksForWorkspace(newWorkspaceName, buildParameters);
expect(locks.length).toBe(0);
const isExpectedNotLockedAfterReleasing =
(await SharedWorkspaceLocking.IsWorkspaceLocked(newWorkspaceName, buildParameters)) === false;
expect(isExpectedNotLockedAfterReleasing).toBeTruthy();
const lockingResult2 = await SharedWorkspaceLocking.LockWorkspace(newWorkspaceName, runId, buildParameters);
expect(lockingResult2).toBeTruthy();
expect((await SharedWorkspaceLocking.IsWorkspaceLocked(newWorkspaceName, buildParameters)) === true).toBeTruthy();
await SharedWorkspaceLocking.ReleaseWorkspace(newWorkspaceName, runId, buildParameters);
expect(
(await SharedWorkspaceLocking.IsWorkspaceLocked(newWorkspaceName, buildParameters)) === false,
).toBeTruthy();
await SharedWorkspaceLocking.CleanupWorkspace(newWorkspaceName, buildParameters);
OrchestratorLogger.log(`Starting get or create`);
expect(await SharedWorkspaceLocking.GetLockedWorkspace(newWorkspaceName, runId, buildParameters)).toBeTruthy();
}, 350000);
}
});

View File

@@ -1,174 +0,0 @@
import Orchestrator from '../../orchestrator';
import { ImageTag } from '../../..';
import UnityVersioning from '../../../unity-versioning';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { v4 as uuidv4 } from 'uuid';
import OrchestratorOptions from '../../options/orchestrator-options';
import setups from './../orchestrator-suite.test';
import * as fs from 'node:fs';
import path from 'node:path';
import { OrchestratorFolders } from '../../options/orchestrator-folders';
import SharedWorkspaceLocking from '../../services/core/shared-workspace-locking';
import { CreateParameters } from '../create-test-parameter';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
describe('Orchestrator Retain Workspace', () => {
it('Responds', () => {});
setups();
if (OrchestratorOptions.orchestratorDebug) {
it('Run one build it should not already be retained, run subsequent build which should use retained workspace', async () => {
const overrides = {
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.determineUnityVersion('test-project', UnityVersioning.read('test-project')),
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
maxRetainedWorkspaces: 1,
orchestratorDebug: true,
};
const buildParameter = await CreateParameters(overrides);
expect(buildParameter.projectPath).toEqual(overrides.projectPath);
const baseImage = new ImageTag(buildParameter);
const resultsObject = await Orchestrator.run(buildParameter, baseImage.toString());
const results = resultsObject.BuildResults;
const libraryString = 'Rebuilding Library because the asset database could not be found!';
const cachePushFail = 'Did not push source folder to cache because it was empty Library';
expect(resultsObject.BuildSucceeded).toBe(true);
// Keep minimal assertions to reduce brittleness
expect(results).not.toContain(cachePushFail);
if (OrchestratorOptions.providerStrategy === `local-docker`) {
const cacheFolderExists = fs.existsSync(`orchestrator-cache/cache/${overrides.cacheKey}`);
expect(cacheFolderExists).toBeTruthy();
await OrchestratorSystem.Run(`tree -d ./orchestrator-cache`);
}
OrchestratorLogger.log(`run 1 succeeded`);
// Clean up k3d node between builds to free space, but preserve Unity image
if (OrchestratorOptions.providerStrategy === 'k8s') {
try {
OrchestratorLogger.log('Cleaning up k3d node between builds (preserving Unity image)...');
const K3D_NODE_CONTAINERS = ['k3d-unity-builder-agent-0', 'k3d-unity-builder-server-0'];
for (const NODE of K3D_NODE_CONTAINERS) {
// Remove stopped containers only - DO NOT touch images
// Removing images risks removing the Unity image which causes "no space left" errors
await OrchestratorSystem.Run(
`docker exec ${NODE} sh -c "crictl rm --all 2>/dev/null || true" || true`,
true,
true,
);
}
OrchestratorLogger.log('Cleanup between builds completed (containers removed, images preserved)');
} catch (cleanupError) {
OrchestratorLogger.logWarning(`Failed to cleanup between builds: ${cleanupError}`);
// Continue anyway
}
}
// await OrchestratorSystem.Run(`tree -d ./orchestrator-cache/${}`);
const buildParameter2 = await CreateParameters(overrides);
buildParameter2.cacheKey = buildParameter.cacheKey;
const baseImage2 = new ImageTag(buildParameter2);
const results2Object = await Orchestrator.run(buildParameter2, baseImage2.toString());
const results2 = results2Object.BuildResults;
OrchestratorLogger.log(`run 2 succeeded`);
const build2ContainsCacheKey = results2.includes(buildParameter.cacheKey);
const build2ContainsBuildGuid1FromRetainedWorkspace = results2.includes(buildParameter.buildGuid);
const build2ContainsRetainedWorkspacePhrase = results2.includes(`Retained Workspace:`);
const build2ContainsWorkspaceExistsAlreadyPhrase = results2.includes(`Retained Workspace Already Exists!`);
const build2NotContainsZeroLibraryCacheFilesMessage = !results2.includes(
'There is 0 files/dir in the cache pulled contents for Library',
);
const build2NotContainsZeroLFSCacheFilesMessage = !results2.includes(
'There is 0 files/dir in the cache pulled contents for LFS',
);
expect(build2ContainsCacheKey).toBeTruthy();
expect(build2ContainsRetainedWorkspacePhrase).toBeTruthy();
expect(build2ContainsWorkspaceExistsAlreadyPhrase).toBeTruthy();
expect(build2ContainsBuildGuid1FromRetainedWorkspace).toBeTruthy();
expect(results2Object.BuildSucceeded).toBe(true);
expect(build2NotContainsZeroLibraryCacheFilesMessage).toBeTruthy();
expect(build2NotContainsZeroLFSCacheFilesMessage).toBeTruthy();
const splitResults = results2.split('Activation successful');
expect(splitResults[splitResults.length - 1]).not.toContain(libraryString);
}, 1_000_000_000);
afterAll(async () => {
await SharedWorkspaceLocking.CleanupWorkspace(Orchestrator.lockedWorkspace || ``, Orchestrator.buildParameters);
if (
fs.existsSync(`./orchestrator-cache/${path.basename(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute)}`)
) {
OrchestratorLogger.log(
`Cleaning up ./orchestrator-cache/${path.basename(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute)}`,
);
try {
const workspaceCachePath = `./orchestrator-cache/${path.basename(
OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute,
)}`;
// Try to fix permissions first to avoid permission denied errors
await OrchestratorSystem.Run(
`chmod -R u+w ${workspaceCachePath} 2>/dev/null || chown -R $(whoami) ${workspaceCachePath} 2>/dev/null || true`,
);
// Try regular rm first
await OrchestratorSystem.Run(`rm -rf ${workspaceCachePath} 2>/dev/null || true`);
// If that fails, try with sudo if available
await OrchestratorSystem.Run(`sudo rm -rf ${workspaceCachePath} 2>/dev/null || true`);
// As last resort, try to remove files one by one, ignoring permission errors
await OrchestratorSystem.Run(
`find ${workspaceCachePath} -type f -exec rm -f {} + 2>/dev/null || find ${workspaceCachePath} -type f -delete 2>/dev/null || true`,
);
// Remove empty directories
await OrchestratorSystem.Run(`find ${workspaceCachePath} -type d -empty -delete 2>/dev/null || true`);
} catch (error: any) {
OrchestratorLogger.log(`Failed to cleanup workspace: ${error.message}`);
// Don't throw - cleanup failures shouldn't fail the test suite
}
}
// Clean up cache files to prevent disk space issues
const cachePath = `./orchestrator-cache`;
if (fs.existsSync(cachePath)) {
try {
OrchestratorLogger.log(`Cleaning up cache directory: ${cachePath}`);
// Try to change ownership first (if running as root or with sudo)
// Then try multiple cleanup methods to handle permission issues
await OrchestratorSystem.Run(
`chmod -R u+w ${cachePath} 2>/dev/null || chown -R $(whoami) ${cachePath} 2>/dev/null || true`,
);
// Try regular rm first
await OrchestratorSystem.Run(`rm -rf ${cachePath}/* 2>/dev/null || true`);
// If that fails, try with sudo if available
await OrchestratorSystem.Run(`sudo rm -rf ${cachePath}/* 2>/dev/null || true`);
// As last resort, try to remove files one by one, ignoring permission errors
await OrchestratorSystem.Run(
`find ${cachePath} -type f -exec rm -f {} + 2>/dev/null || find ${cachePath} -type f -delete 2>/dev/null || true`,
);
// Remove empty directories
await OrchestratorSystem.Run(`find ${cachePath} -type d -empty -delete 2>/dev/null || true`);
} catch (error: any) {
OrchestratorLogger.log(`Failed to cleanup cache: ${error.message}`);
// Don't throw - cleanup failures shouldn't fail the test suite
}
}
});
}
});

View File

@@ -1,107 +0,0 @@
import Orchestrator from '../../orchestrator';
import UnityVersioning from '../../../unity-versioning';
import { Cli } from '../../../cli/cli';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { v4 as uuidv4 } from 'uuid';
import OrchestratorOptions from '../../options/orchestrator-options';
import setups from '../orchestrator-suite.test';
import BuildParameters from '../../../build-parameters';
import ImageTag from '../../../image-tag';
async function CreateParameters(overrides: any) {
if (overrides) {
Cli.options = overrides;
}
return await BuildParameters.create();
}
describe('Orchestrator Kubernetes', () => {
it('Responds', () => {});
setups();
if (OrchestratorOptions.orchestratorDebug) {
const enableK8sE2E = process.env.ENABLE_K8S_E2E === 'true';
const testBody = async () => {
if (OrchestratorOptions.providerStrategy !== `k8s`) {
return;
}
process.env.USE_IL2CPP = 'false';
const overrides = {
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.determineUnityVersion('test-project', UnityVersioning.read('test-project')),
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
providerStrategy: 'k8s',
buildPlatform: 'linux',
orchestratorDebug: true,
};
const buildParameter = await CreateParameters(overrides);
expect(buildParameter.projectPath).toEqual(overrides.projectPath);
const baseImage = new ImageTag(buildParameter);
const resultsObject = await Orchestrator.run(buildParameter, baseImage.toString());
const results = resultsObject.BuildResults;
const libraryString = 'Rebuilding Library because the asset database could not be found!';
const cachePushFail = 'Did not push source folder to cache because it was empty Library';
const buildSucceededString = 'Build succeeded';
const fallbackLogsUnavailableMessage =
'Pod logs unavailable - pod may have been terminated before logs could be collected.';
const incompleteLogsMessage =
'Pod logs incomplete - "Collected Logs" marker not found. Pod may have been terminated before post-build completed.';
// Check if pod was evicted due to resource constraints - this is a test infrastructure failure
// Evictions indicate the cluster doesn't have enough resources, which is a test environment issue
if (
results.includes('The node was low on resource: ephemeral-storage') ||
results.includes('TerminationByKubelet') ||
results.includes('Evicted')
) {
throw new Error(
`Test failed: Pod was evicted due to resource constraints (ephemeral-storage). ` +
`This indicates the test environment doesn't have enough disk space. ` +
`Results: ${results.slice(0, 500)}`,
);
}
// If we hit the aggressive fallback path and couldn't retrieve any logs from the pod,
// don't assert on specific Unity log contents just assert that we got the fallback message.
// This makes the test resilient to cluster-level evictions / PreStop hook failures while still
// ensuring Orchestrator surfaces a useful message in BuildResults.
// However, if we got logs but they're incomplete (missing "Collected Logs"), the test should fail
// as this indicates the build didn't complete successfully (pod was evicted/killed).
if (results.includes(fallbackLogsUnavailableMessage)) {
// Complete failure - no logs at all (acceptable for eviction scenarios)
expect(results).toContain(fallbackLogsUnavailableMessage);
OrchestratorLogger.log('Test passed with fallback message (pod was evicted before any logs were written)');
} else if (results.includes(incompleteLogsMessage)) {
// Incomplete logs - we got some output but missing "Collected Logs" (build didn't complete)
// This should fail the test as the build didn't succeed
throw new Error(
`Build did not complete successfully: ${incompleteLogsMessage}\n` +
`This indicates the pod was evicted or killed before post-build completed.\n` +
`Build results:\n${results.slice(0, 500)}`,
);
} else {
// Normal case - logs are complete
expect(results).toContain('Collected Logs');
expect(results).toContain(libraryString);
expect(results).toContain(buildSucceededString);
expect(results).not.toContain(cachePushFail);
}
OrchestratorLogger.log(`run 1 succeeded`);
};
if (enableK8sE2E) {
it('Run one build it using K8s without error', testBody, 1_000_000_000);
} else {
it.skip('Run one build it using K8s without error - disabled (no outbound network)', () => {
OrchestratorLogger.log('Skipping K8s e2e (ENABLE_K8S_E2E not true)');
});
}
}
});

View File

@@ -1 +0,0 @@
export default class InvalidProvider {}

View File

@@ -1,40 +0,0 @@
import { BuildParameters, ImageTag } from '../..';
import Orchestrator from '../orchestrator';
import UnityVersioning from '../../unity-versioning';
import { Cli } from '../../cli/cli';
import OrchestratorOptions from '../options/orchestrator-options';
import setups from './orchestrator-suite.test';
import { OptionValues } from 'commander';
async function CreateParameters(overrides: OptionValues | undefined) {
if (overrides) Cli.options = overrides;
return BuildParameters.create();
}
describe('Orchestrator Async Workflows', () => {
setups();
it('Responds', () => {});
if (OrchestratorOptions.orchestratorDebug && OrchestratorOptions.providerStrategy !== `local-docker`) {
it('Async Workflows', async () => {
// Setup parameters
const buildParameter = await CreateParameters({
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.read('test-project'),
asyncOrchestrator: `true`,
githubChecks: `true`,
providerStrategy: 'k8s',
buildPlatform: 'linux',
targetPlatform: 'StandaloneLinux64',
});
const baseImage = new ImageTag(buildParameter);
// Run the job
await Orchestrator.run(buildParameter, baseImage.toString());
// wait for 15 seconds
await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 12));
}, 1_000_000_000);
}
});

View File

@@ -1,59 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import BuildParameters from '../../build-parameters';
import { Cli } from '../../cli/cli';
import UnityVersioning from '../../unity-versioning';
import Orchestrator from '../orchestrator';
import { OrchestratorSystem } from '../services/core/orchestrator-system';
import { Caching } from '../remote-client/caching';
import { v4 as uuidv4 } from 'uuid';
import GitHub from '../../github';
import OrchestratorOptions from '../options/orchestrator-options';
describe('Orchestrator (Remote Client) Caching', () => {
it('responds', () => {});
if (OrchestratorOptions.providerStrategy === `local-docker`) {
it('Simple caching works', async () => {
Cli.options = {
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.read('test-project'),
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
};
GitHub.githubInputEnabled = false;
const buildParameter = await BuildParameters.create();
Orchestrator.buildParameters = buildParameter;
// Create test folder
const testFolder = path.resolve(__dirname, Cli.options.cacheKey);
fs.mkdirSync(testFolder);
// Create cache folder
const cacheFolder = path.resolve(__dirname, `cache-${Cli.options.cacheKey}`);
fs.mkdirSync(cacheFolder);
// Add test file to test folders
fs.writeFileSync(path.resolve(testFolder, 'test.txt'), Cli.options.cacheKey);
await Caching.PushToCache(cacheFolder, testFolder, `${Cli.options.cacheKey}`);
// Delete test folder
fs.rmdirSync(testFolder, { recursive: true });
await Caching.PullFromCache(
cacheFolder.replace(/\\/g, `/`),
testFolder.replace(/\\/g, `/`),
`${Cli.options.cacheKey}`,
);
await OrchestratorSystem.Run(`du -h ${__dirname}`);
// Compare validity to original hash
expect(fs.readFileSync(path.resolve(testFolder, 'test.txt'), { encoding: 'utf8' }).toString()).toContain(
Cli.options.cacheKey,
);
fs.rmdirSync(testFolder, { recursive: true });
fs.rmdirSync(cacheFolder, { recursive: true });
GitHub.githubInputEnabled = true;
delete Cli.options;
}, 1000000);
}
});

View File

@@ -1,142 +0,0 @@
import { BuildParameters, Orchestrator, ImageTag, Input } from '../..';
import { TaskParameterSerializer } from '../services/core/task-parameter-serializer';
import UnityVersioning from '../../unity-versioning';
import { Cli } from '../../cli/cli';
import GitHub from '../../github';
import setups from './orchestrator-suite.test';
import { OrchestratorStatics } from '../options/orchestrator-statics';
import OrchestratorOptions from '../options/orchestrator-options';
import OrchestratorLogger from '../services/core/orchestrator-logger';
async function CreateParameters(overrides: any) {
if (overrides) {
Cli.options = overrides;
}
const originalValue = GitHub.githubInputEnabled;
GitHub.githubInputEnabled = false;
const results = await BuildParameters.create();
GitHub.githubInputEnabled = originalValue;
delete Cli.options;
return results;
}
describe('Orchestrator Sync Environments', () => {
setups();
const testSecretName = 'testSecretName';
const testSecretValue = 'testSecretValue';
it('Responds', () => {});
if (OrchestratorOptions.orchestratorDebug) {
it('All build parameters sent to orchestrator as env vars', async () => {
// Setup parameters
const buildParameter = await CreateParameters({
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.read('test-project'),
targetPlatform: 'StandaloneWindows64',
customJob: `
- name: 'step 1'
image: 'ubuntu'
commands: 'printenv'
secrets:
- name: '${testSecretName}'
value: '${testSecretValue}'
`,
orchestratorDebug: true,
});
const baseImage = new ImageTag(buildParameter);
if (baseImage.toString().includes('undefined')) {
throw new Error(`Base image is undefined`);
}
// Run the job
const file = (await Orchestrator.run(buildParameter, baseImage.toString())).BuildResults;
// Assert results
// expect(file).toContain(JSON.stringify(buildParameter));
expect(file).toContain(`${Input.ToEnvVarFormat(testSecretName)}=${testSecretValue}`);
const environmentVariables = TaskParameterSerializer.createOrchestratorEnvironmentVariables(buildParameter);
const secrets = TaskParameterSerializer.readDefaultSecrets().map((x) => {
return {
name: x.EnvironmentVariable,
value: x.ParameterValue,
};
});
// Apply the same localhost -> host.docker.internal replacement that the Docker provider does
// This ensures the test expectations match what's actually in the output
const endpointEnvironmentNames = new Set([
'AWS_S3_ENDPOINT',
'AWS_ENDPOINT',
'AWS_CLOUD_FORMATION_ENDPOINT',
'AWS_ECS_ENDPOINT',
'AWS_KINESIS_ENDPOINT',
'AWS_CLOUD_WATCH_LOGS_ENDPOINT',
'INPUT_AWSS3ENDPOINT',
'INPUT_AWSENDPOINT',
]);
const combined = [...environmentVariables, ...secrets]
.filter((element) => element.value !== undefined && element.value !== '' && typeof element.value !== 'function')
.map((x) => {
if (typeof x.value === `string`) {
x.value = x.value.replace(/\s+/g, '');
// Apply localhost -> host.docker.internal replacement for LocalStack endpoints
// when using local-docker or aws provider (which uses Docker)
if (
endpointEnvironmentNames.has(x.name) &&
(x.value.startsWith('http://localhost') || x.value.startsWith('http://127.0.0.1')) &&
(OrchestratorOptions.providerStrategy === 'local-docker' ||
OrchestratorOptions.providerStrategy === 'aws')
) {
x.value = x.value
.replace('http://localhost', 'http://host.docker.internal')
.replace('http://127.0.0.1', 'http://host.docker.internal');
}
}
return x;
})
.filter((element) => {
return !['UNITY_LICENSE', 'UNITY_LICENSE', 'CUSTOM_JOB', 'CUSTOM_JOB'].includes(element.name);
});
const newLinePurgedFile = file
.replace(/\s+/g, '')
.replace(new RegExp(`\\[${OrchestratorStatics.logPrefix}\\]`, 'g'), '');
for (const element of combined) {
expect(newLinePurgedFile).toContain(`${element.name}`);
OrchestratorLogger.log(`Contains ${element.name}`);
const fullNameEqualValue = `${element.name}=${element.value}`;
expect(newLinePurgedFile).toContain(fullNameEqualValue);
}
}, 1_000_000_000);
}
});
describe('Orchestrator Environment Serializer', () => {
setups();
const testSecretName = 'testSecretName';
const testSecretValue = 'testSecretValue';
it('Orchestrator Parameter Serialization', async () => {
// Setup parameters
const buildParameter = await CreateParameters({
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.read('test-project'),
customJob: `
- name: 'step 1'
image: 'alpine'
commands: 'printenv'
secrets:
- name: '${testSecretName}'
value: '${testSecretValue}'
`,
});
const result = TaskParameterSerializer.createOrchestratorEnvironmentVariables(buildParameter);
expect(result.find((x) => Number.parseInt(x.name)) !== undefined).toBeFalsy();
const result2 = TaskParameterSerializer.createOrchestratorEnvironmentVariables(buildParameter);
expect(result2.find((x) => Number.parseInt(x.name)) !== undefined).toBeFalsy();
});
});

View File

@@ -1,66 +0,0 @@
import Orchestrator from '../orchestrator';
import UnityVersioning from '../../unity-versioning';
import setups from './orchestrator-suite.test';
import GitHub from '../../github';
import { TIMEOUT_INFINITE, createParameters } from '../../../test-utils/orchestrator-test-helpers';
describe('Orchestrator Github Checks', () => {
setups();
it('Responds', () => {});
beforeEach(() => {
// Mock GitHub API requests to avoid real network calls
jest.spyOn(GitHub as any, 'createGitHubCheckRequest').mockResolvedValue({
status: 201,
data: { id: '1' },
});
jest.spyOn(GitHub as any, 'updateGitHubCheckRequest').mockResolvedValue({
status: 200,
data: {},
});
// eslint-disable-next-line unicorn/no-useless-undefined
jest.spyOn(GitHub as any, 'runUpdateAsyncChecksWorkflow').mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it(
'Check Handling Direct',
async () => {
// Setup parameters
const buildParameter = await createParameters({
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.read('test-project'),
asyncOrchestrator: `true`,
githubChecks: `true`,
});
await Orchestrator.setup(buildParameter);
Orchestrator.buildParameters.githubCheckId = await GitHub.createGitHubCheck(`direct create`);
await GitHub.updateGitHubCheck(`1 ${new Date().toISOString()}`, `direct`);
await GitHub.updateGitHubCheck(`2 ${new Date().toISOString()}`, `direct`, `success`, `completed`);
},
TIMEOUT_INFINITE,
);
it(
'Check Handling Via Async Workflow',
async () => {
// Setup parameters
const buildParameter = await createParameters({
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.read('test-project'),
asyncOrchestrator: `true`,
githubChecks: `true`,
});
GitHub.forceAsyncTest = true;
await Orchestrator.setup(buildParameter);
Orchestrator.buildParameters.githubCheckId = await GitHub.createGitHubCheck(`async create`);
await GitHub.updateGitHubCheck(`1 ${new Date().toISOString()}`, `async`);
await GitHub.updateGitHubCheck(`2 ${new Date().toISOString()}`, `async`, `success`, `completed`);
GitHub.forceAsyncTest = false;
},
TIMEOUT_INFINITE,
);
});

View File

@@ -1,126 +0,0 @@
import Orchestrator from '../orchestrator';
import { BuildParameters, ImageTag } from '../..';
import UnityVersioning from '../../unity-versioning';
import { Cli } from '../../cli/cli';
import OrchestratorLogger from '../services/core/orchestrator-logger';
import { v4 as uuidv4 } from 'uuid';
import OrchestratorOptions from '../options/orchestrator-options';
import setups from './orchestrator-suite.test';
import { ContainerHookService } from '../services/hooks/container-hook-service';
import { CommandHookService } from '../services/hooks/command-hook-service';
async function CreateParameters(overrides: any) {
if (overrides) {
Cli.options = overrides;
}
return await BuildParameters.create();
}
describe('Orchestrator Custom Hooks And Steps', () => {
it('Responds', () => {});
setups();
it('Check parsing and reading of steps', async () => {
const yamlString = `hook: before
commands: echo "test"`;
const yamlString2 = `- hook: before
commands: echo "test"`;
const overrides = {
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.determineUnityVersion('test-project', UnityVersioning.read('test-project')),
targetPlatform: 'StandaloneLinux64',
image: 'ubuntu',
cacheKey: `test-case-${uuidv4()}`,
};
Orchestrator.setup(await CreateParameters(overrides));
const stringObject = ContainerHookService.ParseContainerHooks(yamlString);
const stringObject2 = ContainerHookService.ParseContainerHooks(yamlString2);
OrchestratorLogger.log(yamlString);
OrchestratorLogger.log(JSON.stringify(stringObject, undefined, 4));
expect(stringObject.length).toBe(1);
expect(stringObject[0].hook).toBe(`before`);
expect(stringObject2.length).toBe(1);
expect(stringObject2[0].hook).toBe(`before`);
const getCustomStepsFromFiles = ContainerHookService.GetContainerHooksFromFiles(`before`);
OrchestratorLogger.log(JSON.stringify(getCustomStepsFromFiles, undefined, 4));
});
if (OrchestratorOptions.orchestratorDebug) {
it('Should be 1 before and 1 after hook', async () => {
const overrides = {
versioning: 'None',
image: 'ubuntu',
projectPath: 'test-project',
unityVersion: UnityVersioning.determineUnityVersion('test-project', UnityVersioning.read('test-project')),
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
containerHookFiles: `my-test-step-pre-build,my-test-step-post-build`,
commandHookFiles: `my-test-hook-pre-build,my-test-hook-post-build`,
};
const buildParameter2 = await CreateParameters(overrides);
await Orchestrator.setup(buildParameter2);
const beforeHooks = CommandHookService.GetCustomHooksFromFiles(`before`);
const afterHooks = CommandHookService.GetCustomHooksFromFiles(`after`);
expect(beforeHooks).toHaveLength(1);
expect(afterHooks).toHaveLength(1);
});
it('Should be 1 before and 1 after step', async () => {
const overrides = {
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.determineUnityVersion('test-project', UnityVersioning.read('test-project')),
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
image: 'ubuntu',
containerHookFiles: `my-test-step-pre-build,my-test-step-post-build`,
commandHookFiles: `my-test-hook-pre-build,my-test-hook-post-build`,
};
const buildParameter2 = await CreateParameters(overrides);
await Orchestrator.setup(buildParameter2);
const beforeSteps = ContainerHookService.GetContainerHooksFromFiles(`before`);
const afterSteps = ContainerHookService.GetContainerHooksFromFiles(`after`);
expect(beforeSteps).toHaveLength(1);
expect(afterSteps).toHaveLength(1);
});
it('Run build once - check for pre and post custom hooks run contents', async () => {
const overrides = {
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.determineUnityVersion('test-project', UnityVersioning.read('test-project')),
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
containerHookFiles: `my-test-step-pre-build,my-test-step-post-build`,
commandHookFiles: `my-test-hook-pre-build,my-test-hook-post-build`,
orchestratorDebug: true,
};
const buildParameter2 = await CreateParameters(overrides);
const baseImage2 = new ImageTag(buildParameter2);
const results2Object = await Orchestrator.run(buildParameter2, baseImage2.toString());
const results2 = results2Object.BuildResults;
OrchestratorLogger.log(`run 2 succeeded`);
const buildContainsBuildSucceeded = results2.includes('Build succeeded');
const buildContainsPreBuildHookRunMessage = results2.includes('before-build hook test!!');
const buildContainsPostBuildHookRunMessage = results2.includes('after-build hook test!');
const buildContainsPreBuildStepMessage = results2.includes('before-build step test!');
const buildContainsPostBuildStepMessage = results2.includes('after-build step test!');
// Skip "Build succeeded" check for local-docker and aws when using ubuntu image (Unity doesn't run)
if (
OrchestratorOptions.providerStrategy !== 'local' &&
OrchestratorOptions.providerStrategy !== 'local-docker' &&
OrchestratorOptions.providerStrategy !== 'aws'
) {
expect(buildContainsBuildSucceeded).toBeTruthy();
}
expect(buildContainsPreBuildHookRunMessage).toBeTruthy();
expect(buildContainsPostBuildHookRunMessage).toBeTruthy();
expect(buildContainsPreBuildStepMessage).toBeTruthy();
expect(buildContainsPostBuildStepMessage).toBeTruthy();
}, 1_000_000_000);
}
});

View File

@@ -1,51 +0,0 @@
import { BuildParameters, ImageTag } from '../..';
import UnityVersioning from '../../unity-versioning';
import { Cli } from '../../cli/cli';
import GitHub from '../../github';
import setups from './orchestrator-suite.test';
async function CreateParameters(overrides: any) {
if (overrides) {
Cli.options = overrides;
}
const originalValue = GitHub.githubInputEnabled;
GitHub.githubInputEnabled = false;
const results = await BuildParameters.create();
GitHub.githubInputEnabled = originalValue;
delete Cli.options;
return results;
}
describe('Orchestrator Image', () => {
setups();
const testSecretName = 'testSecretName';
const testSecretValue = 'testSecretValue';
it('Can create valid image from normal config', async () => {
// Setup parameters
const buildParameter = await CreateParameters({
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.read('test-project'),
targetPlatform: 'StandaloneWindows64',
customJob: `
- name: 'step 1'
image: 'ubuntu'
commands: 'printenv'
secrets:
- name: '${testSecretName}'
value: '${testSecretValue}'
`,
});
const baseImage = new ImageTag(buildParameter);
if (buildParameter.targetPlatform === undefined) {
throw new Error(`target platform includes undefined`);
}
if (baseImage.toString().includes('undefined')) {
throw new Error(`Base image ${baseImage.toString()} includes undefined`);
}
if (baseImage.toString().includes('NaN')) {
throw new Error(`Base image ${baseImage.toString()} includes nan`);
}
}, 1_000_000_000);
});

View File

@@ -1,53 +0,0 @@
import { ImageTag } from '../..';
import Orchestrator from '../orchestrator';
import UnityVersioning from '../../unity-versioning';
import OrchestratorOptions from '../options/orchestrator-options';
import setups from './orchestrator-suite.test';
import fs from 'node:fs';
import { CreateParameters } from './create-test-parameter';
import OrchestratorLogger from '../services/core/orchestrator-logger';
describe('Orchestrator Local Docker Workflows', () => {
setups();
it('Responds', () => {});
if (OrchestratorOptions.providerStrategy === `local-docker`) {
it('inspect stateful folder of workflows', async () => {
const testValue = `the state in a job exits in the expected local-docker folder`;
// Setup parameters
const buildParameter = await CreateParameters({
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.read('test-project'),
customJob: `
- name: 'step 1'
image: 'ubuntu'
commands: 'echo "${testValue}" >> /data/test-out-state.txt'
`,
});
const buildParameter2 = await CreateParameters({
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.read('test-project'),
customJob: `
- name: 'step 1'
image: 'ubuntu'
commands: 'cat /data/test-out-state.txt >> /data/test-out-state-2.txt'
`,
});
const baseImage = new ImageTag(buildParameter);
// Run the job
await Orchestrator.run(buildParameter, baseImage.toString());
await Orchestrator.run(buildParameter2, baseImage.toString());
const outputFile = fs.readFileSync(`./orchestrator-cache/test-out-state.txt`, `utf-8`);
expect(outputFile).toMatch(testValue);
const outputFile2 = fs.readFileSync(`./orchestrator-cache/test-out-state-2.txt`, `utf-8`);
expect(outputFile2).toMatch(testValue);
OrchestratorLogger.log(outputFile);
}, 1_000_000_000);
}
});

View File

@@ -1,115 +0,0 @@
import SharedWorkspaceLocking from '../services/core/shared-workspace-locking';
import { Cli } from '../../cli/cli';
import setups from './orchestrator-suite.test';
import OrchestratorLogger from '../services/core/orchestrator-logger';
import { v4 as uuidv4 } from 'uuid';
import OrchestratorOptions from '../options/orchestrator-options';
import UnityVersioning from '../../unity-versioning';
import BuildParameters from '../../build-parameters';
import Orchestrator from '../orchestrator';
async function CreateParameters(overrides: any) {
if (overrides) {
Cli.options = overrides;
}
return await BuildParameters.create();
}
describe('Orchestrator Locking Core', () => {
setups();
it('Responds', () => {});
if (OrchestratorOptions.orchestratorDebug) {
it(`Create Workspace`, async () => {
const overrides: any = {
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.determineUnityVersion('test-project', UnityVersioning.read('test-project')),
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
maxRetainedWorkspaces: 3,
};
const buildParameters = await CreateParameters(overrides);
Orchestrator.buildParameters = buildParameters;
const newWorkspaceName = `test-workspace-${uuidv4()}`;
expect(await SharedWorkspaceLocking.CreateWorkspace(newWorkspaceName, buildParameters)).toBeTruthy();
}, 150000);
it(`Create Workspace And Lock Workspace`, async () => {
const overrides: any = {
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.determineUnityVersion('test-project', UnityVersioning.read('test-project')),
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
maxRetainedWorkspaces: 3,
};
const runId = uuidv4();
const buildParameters = await CreateParameters(overrides);
Orchestrator.buildParameters = buildParameters;
const newWorkspaceName = `test-workspace-${uuidv4()}`;
expect(await SharedWorkspaceLocking.CreateWorkspace(newWorkspaceName, buildParameters)).toBeTruthy();
expect(await SharedWorkspaceLocking.LockWorkspace(newWorkspaceName, runId, buildParameters)).toBeTruthy();
}, 150000);
it(`0 free workspaces after locking`, async () => {
const overrides: any = {
versioning: 'None',
projectPath: 'test-project',
unityVersion: UnityVersioning.determineUnityVersion('test-project', UnityVersioning.read('test-project')),
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
maxRetainedWorkspaces: 3,
};
const buildParameters = await CreateParameters(overrides);
const newWorkspaceName = `test-workspace-${uuidv4()}`;
const runId = uuidv4();
Orchestrator.buildParameters = buildParameters;
expect(await SharedWorkspaceLocking.CreateWorkspace(newWorkspaceName, buildParameters)).toBeTruthy();
expect(await SharedWorkspaceLocking.LockWorkspace(newWorkspaceName, runId, buildParameters)).toBeTruthy();
expect(await SharedWorkspaceLocking.HasWorkspaceLock(newWorkspaceName, runId, buildParameters)).toBeTruthy();
expect(await SharedWorkspaceLocking.DoesWorkspaceExist(newWorkspaceName, buildParameters)).toBeTruthy();
expect(await SharedWorkspaceLocking.GetAllWorkspaces(buildParameters)).toHaveLength(1);
expect(await SharedWorkspaceLocking.GetAllLocksForWorkspace(newWorkspaceName, buildParameters)).toHaveLength(1);
expect(await SharedWorkspaceLocking.IsWorkspaceLocked(newWorkspaceName, buildParameters)).toBeTruthy();
const files = await SharedWorkspaceLocking.ReadLines(
`aws s3 ls ${SharedWorkspaceLocking.workspaceRoot}${buildParameters.cacheKey}/`,
);
const lockFilesExist =
files.filter((x) => {
return x.includes(newWorkspaceName) && x.endsWith(`_lock`);
}).length > 0;
expect(files).toHaveLength(2);
expect(
files.filter((x) => {
return x.includes(newWorkspaceName) && x.endsWith(`_lock`);
}),
).toHaveLength(1);
expect(lockFilesExist).toBeTruthy();
const result: string[] = [];
const workspaces = await SharedWorkspaceLocking.GetAllWorkspaces(buildParameters);
for (const element of workspaces) {
expect((await SharedWorkspaceLocking.GetAllWorkspaces(buildParameters)).join()).toContain(element);
expect(await SharedWorkspaceLocking.GetAllWorkspaces(buildParameters)).toHaveLength(1);
expect(await SharedWorkspaceLocking.DoesWorkspaceExist(element, buildParameters)).toBeTruthy();
await new Promise((promise) => setTimeout(promise, 1500));
const isLocked = await SharedWorkspaceLocking.IsWorkspaceLocked(element, buildParameters);
const isBelowMax = await SharedWorkspaceLocking.IsWorkspaceBelowMax(element, buildParameters);
OrchestratorLogger.log(`workspace ${element} locked:${isLocked} below max:${isBelowMax}`);
const lock = files.find((x) => {
return x.endsWith(`_lock`);
});
expect(lock).toContain(element);
expect(isLocked).toBeTruthy();
expect(isBelowMax).toBeTruthy();
if (!isLocked && isBelowMax) {
result.push(element);
}
}
expect(result).toHaveLength(0);
expect(await SharedWorkspaceLocking.GetFreeWorkspaces(buildParameters)).toHaveLength(0);
}, 300000);
}
});

Some files were not shown because too many files have changed in this diff Show More