Compare commits

..

5 Commits

Author SHA1 Message Date
frostebite
3cac1845e3 fix: replace orchestrator-develop branch references with main
The orchestrator-develop branch no longer exists. Update all fallback
clone commands and test fixtures to use main instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:02:17 +00:00
frostebite
41f00bd1f9 ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:33:21 +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
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
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
24 changed files with 2544 additions and 3334 deletions

View File

@@ -279,66 +279,35 @@ inputs:
description:
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.'
remotePowershellHost:
default: ''
hotRunnerEnabled:
description: '[HotRunner] Use persistent hot runner for builds (requires pre-registered runners)'
required: false
description: '[Orchestrator] Remote PowerShell host (hostname or IP) for the remote-powershell provider'
remotePowershellCredential:
default: ''
default: 'false'
hotRunnerTransport:
description: '[HotRunner] Transport protocol for hot runner communication: websocket, grpc, named-pipe'
required: false
description: '[Orchestrator] Remote PowerShell credential (username:password or certificate path)'
remotePowershellTransport:
default: 'wsman'
default: 'websocket'
hotRunnerHost:
description: '[HotRunner] Hot runner host address'
required: false
description: '[Orchestrator] Remote PowerShell transport protocol (wsman or ssh)'
githubActionsRepo:
default: ''
default: 'localhost'
hotRunnerPort:
description: '[HotRunner] Hot runner port number'
required: false
description: '[Orchestrator] Target repository (owner/repo) for the github-actions provider'
githubActionsWorkflow:
default: ''
default: '9090'
hotRunnerHealthInterval:
description: '[HotRunner] Health check interval in seconds'
required: false
description: '[Orchestrator] Workflow filename or ID to dispatch for the github-actions provider'
githubActionsToken:
default: ''
default: '30'
hotRunnerMaxIdle:
description: '[HotRunner] Maximum idle time in seconds before recycling runner'
required: false
description: '[Orchestrator] PAT with actions:write scope for the github-actions provider'
githubActionsRef:
default: 'main'
default: '3600'
hotRunnerFallbackToCold:
description: '[HotRunner] Fall back to cold build if no hot runner available'
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'
default: 'true'
outputs:
volume:

1686
dist/index.js generated vendored

File diff suppressed because it is too large Load Diff

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -3,6 +3,8 @@ import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output
import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup';
import { HotRunnerService } from './model/orchestrator/services/hot-runner';
import { HotRunnerConfig } from './model/orchestrator/services/hot-runner/hot-runner-types';
async function runMain() {
try {
@@ -21,17 +23,46 @@ async function runMain() {
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: 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
};
const hotRunnerService = new HotRunnerService();
try {
await hotRunnerService.initialize(hotRunnerConfig);
const result = await hotRunnerService.submitBuild(buildParameters, (output) => {
core.info(output);
});
exitCode = result.exitCode;
core.info(`[HotRunner] Build completed with exit code ${exitCode}`);
await hotRunnerService.shutdown();
} catch (hotRunnerError) {
await hotRunnerService.shutdown();
if (buildParameters.hotRunnerFallbackToCold) {
core.warning(
`[HotRunner] Hot runner failed: ${(hotRunnerError as Error).message}. Falling back to cold build.`,
);
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
} else {
throw hotRunnerError;
}
}
} else if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
await PlatformSetup.setup(buildParameters, actionFolder);
exitCode =
process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace,
actionFolder,
...buildParameters,
});
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
} else {
await Orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0;
@@ -50,4 +81,28 @@ 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 {
await Orchestrator.run(buildParameters, baseImage.toString());
return 0;
}
}
runMain();

View File

@@ -106,29 +106,13 @@ class BuildParameters {
public cacheUnityInstallationOnMac!: boolean;
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: 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 hotRunnerEnabled!: boolean;
public hotRunnerTransport!: 'websocket' | 'grpc' | 'named-pipe';
public hotRunnerHost!: string;
public hotRunnerPort!: number;
public hotRunnerHealthInterval!: number;
public hotRunnerMaxIdle!: number;
public hotRunnerFallbackToCold!: boolean;
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -265,29 +249,13 @@ class BuildParameters {
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
// 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,
hotRunnerEnabled: Input.hotRunnerEnabled,
hotRunnerTransport: Input.hotRunnerTransport,
hotRunnerHost: Input.hotRunnerHost,
hotRunnerPort: Input.hotRunnerPort,
hotRunnerHealthInterval: Input.hotRunnerHealthInterval,
hotRunnerMaxIdle: Input.hotRunnerMaxIdle,
hotRunnerFallbackToCold: Input.hotRunnerFallbackToCold,
};
}

View File

@@ -282,80 +282,36 @@ class Input {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
// ### ### ###
// Remote PowerShell provider
// ### ### ###
static get hotRunnerEnabled(): boolean {
const input = Input.getInput('hotRunnerEnabled') ?? false;
static get remotePowershellHost(): string {
return Input.getInput('remotePowershellHost') ?? '';
return input === 'true';
}
static get remotePowershellCredential(): string {
return Input.getInput('remotePowershellCredential') ?? '';
static get hotRunnerTransport(): 'websocket' | 'grpc' | 'named-pipe' {
return (Input.getInput('hotRunnerTransport') ?? 'websocket') as 'websocket' | 'grpc' | 'named-pipe';
}
static get remotePowershellTransport(): string {
return Input.getInput('remotePowershellTransport') ?? 'wsman';
static get hotRunnerHost(): string {
return Input.getInput('hotRunnerHost') ?? 'localhost';
}
// ### ### ###
// GitHub Actions provider
// ### ### ###
static get githubActionsRepo(): string {
return Input.getInput('githubActionsRepo') ?? '';
static get hotRunnerPort(): number {
return Number.parseInt(Input.getInput('hotRunnerPort') ?? '9090', 10);
}
static get githubActionsWorkflow(): string {
return Input.getInput('githubActionsWorkflow') ?? '';
static get hotRunnerHealthInterval(): number {
return Number.parseInt(Input.getInput('hotRunnerHealthInterval') ?? '30', 10);
}
static get githubActionsToken(): string {
return Input.getInput('githubActionsToken') ?? '';
static get hotRunnerMaxIdle(): number {
return Number.parseInt(Input.getInput('hotRunnerMaxIdle') ?? '3600', 10);
}
static get githubActionsRef(): string {
return Input.getInput('githubActionsRef') ?? 'main';
}
static get hotRunnerFallbackToCold(): boolean {
const input = Input.getInput('hotRunnerFallbackToCold') ?? 'true';
// ### ### ###
// 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') ?? '';
return input === 'true';
}
public static ToEnvVarFormat(input: string) {

View File

@@ -13,10 +13,6 @@ import OrchestratorEnvironmentVariable from './options/orchestrator-environment-
import TestOrchestrator from './providers/test';
import LocalOrchestrator from './providers/local';
import LocalDockerOrchestrator from './providers/docker';
import RemotePowershellProvider from './providers/remote-powershell';
import GitHubActionsProvider from './providers/github-actions';
import GitLabCIProvider from './providers/gitlab-ci';
import AnsibleProvider from './providers/ansible';
import loadProvider from './providers/provider-loader';
import GitHub from '../github';
import SharedWorkspaceLocking from './services/core/shared-workspace-locking';
@@ -162,18 +158,6 @@ class Orchestrator {
case 'local':
Orchestrator.Provider = new LocalOrchestrator();
break;
case 'remote-powershell':
Orchestrator.Provider = new RemotePowershellProvider(Orchestrator.buildParameters);
break;
case 'github-actions':
Orchestrator.Provider = new GitHubActionsProvider(Orchestrator.buildParameters);
break;
case 'gitlab-ci':
Orchestrator.Provider = new GitLabCIProvider(Orchestrator.buildParameters);
break;
case 'ansible':
Orchestrator.Provider = new AnsibleProvider(Orchestrator.buildParameters);
break;
default:
// Try to load provider using the dynamic loader for unknown providers
try {

View File

@@ -1,291 +0,0 @@
import AnsibleProvider from '.';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import * as core from '@actions/core';
jest.mock('../../services/core/orchestrator-system');
jest.mock('../../services/core/orchestrator-logger');
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(() => ''),
}));
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction<typeof OrchestratorLogger.logWarning>;
function createBuildParameters(overrides: Partial<BuildParameters> = {}): BuildParameters {
return {
ansibleInventory: '/etc/ansible/hosts',
ansiblePlaybook: '/playbooks/unity-build.yml',
ansibleExtraVars: '',
ansibleVaultPassword: '',
...overrides,
} as BuildParameters;
}
describe('AnsibleProvider', () => {
let provider: AnsibleProvider;
beforeEach(() => {
jest.clearAllMocks();
provider = new AnsibleProvider(createBuildParameters());
});
describe('constructor', () => {
it('initializes with all provided parameters', () => {
const params = createBuildParameters({
ansibleInventory: '/custom/inventory',
ansiblePlaybook: '/custom/playbook.yml',
ansibleExtraVars: '{"key":"value"}',
ansibleVaultPassword: '/vault/pass',
});
const p = new AnsibleProvider(params);
expect(p).toBeDefined();
});
it('handles missing optional parameters gracefully', () => {
const params = createBuildParameters({
ansiblePlaybook: undefined,
ansibleExtraVars: undefined,
ansibleVaultPassword: undefined,
});
const p = new AnsibleProvider(params);
expect(p).toBeDefined();
});
});
describe('setupWorkflow', () => {
it('verifies ansible binary, ansible-playbook binary, and inventory exist', async () => {
mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible --version
mockRun.mockResolvedValueOnce('/usr/bin/ansible-playbook'); // ansible-playbook check
mockRun.mockResolvedValueOnce(''); // test -e inventory
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
expect(mockRun).toHaveBeenCalledTimes(3);
expect(mockRun.mock.calls[0][0]).toContain('ansible --version');
expect(mockRun.mock.calls[1][0]).toContain('ansible-playbook');
expect(mockRun.mock.calls[2][0]).toContain('test -e "/etc/ansible/hosts"');
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ansible'));
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ansible-playbook binary verified'));
});
it('throws when inventory is not configured', async () => {
const params = createBuildParameters({ ansibleInventory: '' });
provider = new AnsibleProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'ansibleInventory is required',
);
});
it('throws when ansible binary is not found on PATH', async () => {
mockRun.mockRejectedValueOnce(new Error('command not found: ansible'));
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'Ansible not found on PATH',
);
});
it('throws when ansible-playbook binary is not found', async () => {
mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible version OK
mockRun.mockRejectedValueOnce(new Error('command not found')); // ansible-playbook missing
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'ansible-playbook not found on PATH',
);
expect(core.error).toHaveBeenCalledWith('ansible-playbook not found. Install Ansible or ensure it is in PATH.');
});
it('throws when inventory file does not exist', async () => {
mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible version OK
mockRun.mockResolvedValueOnce('/usr/bin/ansible-playbook'); // ansible-playbook OK
mockRun.mockRejectedValueOnce(new Error('test -e failed')); // inventory missing
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'Inventory not found: /etc/ansible/hosts',
);
});
});
describe('runTaskInWorkflow', () => {
it('constructs ansible-playbook command with correct variables and returns output', async () => {
mockRun.mockResolvedValueOnce('PLAY [build] *****\nok: [server1]\nPLAY RECAP');
const result = await provider.runTaskInWorkflow(
'guid-run1',
'unityci/editor:2021.3',
'echo build',
'/mount',
'/workspace',
[],
[],
);
expect(result).toContain('PLAY [build]');
const command = mockRun.mock.calls[0][0];
expect(command).toContain('ansible-playbook');
expect(command).toContain('-i "/etc/ansible/hosts"');
expect(command).toContain('"/playbooks/unity-build.yml"');
expect(command).toContain('--no-color');
expect(command).toContain('build_guid');
expect(command).toContain('guid-run1');
expect(command).toContain('build_image');
expect(command).toContain('unityci/editor:2021.3');
expect(command).toContain('build_commands');
expect(command).toContain('mount_dir');
expect(command).toContain('working_dir');
});
it('throws when playbook is not configured', async () => {
const params = createBuildParameters({ ansiblePlaybook: '' });
provider = new AnsibleProvider(params);
await expect(provider.runTaskInWorkflow('guid-nopb', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'ansiblePlaybook is required',
);
});
it('passes environment variables as extra-vars in snake_case', async () => {
mockRun.mockResolvedValueOnce('ok');
const env = [
{ name: 'UNITY_LICENSE', value: 'lic-data' },
{ name: 'BUILD_TARGET', value: 'Linux64' },
];
await provider.runTaskInWorkflow('guid-env', 'img', 'cmd', '/m', '/w', env as any, []);
const command = mockRun.mock.calls[0][0];
// Environment variable names are lowercased as Ansible variables
expect(command).toContain('unity_license');
expect(command).toContain('lic-data');
expect(command).toContain('build_target');
expect(command).toContain('Linux64');
});
it('merges user-provided extra vars from JSON string', async () => {
const params = createBuildParameters({
ansibleExtraVars: JSON.stringify({ custom_var: 'custom_value', another: '42' }),
});
provider = new AnsibleProvider(params);
mockRun.mockResolvedValueOnce('ok');
await provider.runTaskInWorkflow('guid-extra', 'img', 'cmd', '/m', '/w', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('custom_var');
expect(command).toContain('custom_value');
expect(command).toContain('another');
});
it('logs warning when extra vars JSON is invalid but continues', async () => {
const params = createBuildParameters({ ansibleExtraVars: 'not-valid-json{{{' });
provider = new AnsibleProvider(params);
mockRun.mockResolvedValueOnce('ok');
await provider.runTaskInWorkflow('guid-badjson', 'img', 'cmd', '/m', '/w', [], []);
expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Failed to parse ansibleExtraVars'));
});
it('includes vault password file flag when configured', async () => {
const params = createBuildParameters({ ansibleVaultPassword: '/secure/vault-pass.txt' });
provider = new AnsibleProvider(params);
mockRun.mockResolvedValueOnce('ok');
await provider.runTaskInWorkflow('guid-vault', 'img', 'cmd', '/m', '/w', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('--vault-password-file "/secure/vault-pass.txt"');
});
it('does not include vault password flag when not configured', async () => {
mockRun.mockResolvedValueOnce('ok');
await provider.runTaskInWorkflow('guid-novault', 'img', 'cmd', '/m', '/w', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).not.toContain('--vault-password-file');
});
it('prefixes secrets as environment variables in the command', async () => {
mockRun.mockResolvedValueOnce('ok');
const secrets = [
{ ParameterKey: 'key1', EnvironmentVariable: 'SECRET_TOKEN', ParameterValue: 'tok-abc' },
{ ParameterKey: 'key2', EnvironmentVariable: 'DEPLOY_KEY', ParameterValue: 'dk-xyz' },
];
await provider.runTaskInWorkflow('guid-secrets', 'img', 'cmd', '/m', '/w', [], secrets as any);
const command = mockRun.mock.calls[0][0];
expect(command).toMatch(/^SECRET_TOKEN='tok-abc'/);
expect(command).toContain("DEPLOY_KEY='dk-xyz'");
expect(command).toContain('ansible-playbook');
});
it('throws and logs warning when playbook execution fails', async () => {
const execError = new Error('UNREACHABLE! Host unreachable');
mockRun.mockRejectedValueOnce(execError);
await expect(provider.runTaskInWorkflow('guid-hostfail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'UNREACHABLE',
);
expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Playbook failed'));
});
});
describe('cleanupWorkflow', () => {
it('completes without error and logs cleanup message', async () => {
await provider.cleanupWorkflow(createBuildParameters(), 'main', []);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleanup complete'));
});
});
describe('garbageCollect', () => {
it('returns empty string (no-op)', async () => {
const result = await provider.garbageCollect('', false, 0, false, false);
expect(result).toBe('');
});
});
describe('listResources', () => {
it('returns inventory path as a resource when configured', async () => {
const resources = await provider.listResources();
expect(resources).toHaveLength(1);
expect(resources[0].Name).toBe('/etc/ansible/hosts');
});
it('returns empty array when inventory is not configured', async () => {
const params = createBuildParameters({ ansibleInventory: '' });
provider = new AnsibleProvider(params);
const resources = await provider.listResources();
expect(resources).toEqual([]);
});
});
describe('listWorkflow', () => {
it('returns empty array (not implemented)', async () => {
const workflows = await provider.listWorkflow();
expect(workflows).toEqual([]);
});
});
describe('watchWorkflow', () => {
it('returns empty string (not implemented)', async () => {
const result = await provider.watchWorkflow();
expect(result).toBe('');
});
});
});

View File

@@ -1,197 +0,0 @@
import * as core from '@actions/core';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
/**
* Ansible provider — executes Unity builds via Ansible playbooks
* against managed inventory.
*
* Use case: Teams with existing Ansible infrastructure for server
* management who want to leverage their inventory for build distribution.
*/
class AnsibleProvider implements ProviderInterface {
private buildParameters: BuildParameters;
private inventory: string;
private playbook: string;
private extraVariables: string;
private vaultPassword: string;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.inventory = buildParameters.ansibleInventory || '';
this.playbook = buildParameters.ansiblePlaybook || '';
this.extraVariables = buildParameters.ansibleExtraVars || '';
this.vaultPassword = buildParameters.ansibleVaultPassword || '';
}
async setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[Ansible] Setting up playbook execution`);
if (!this.inventory) {
throw new Error('ansibleInventory is required for the ansible provider');
}
// Verify ansible is available
try {
const version = await OrchestratorSystem.Run('ansible --version | head -1');
OrchestratorLogger.log(`[Ansible] ${version.trim()}`);
} catch (error: any) {
throw new Error(`Ansible not found on PATH: ${error.message || error}`);
}
// Verify ansible-playbook binary exists (may be separate from ansible)
try {
await OrchestratorSystem.Run('command -v ansible-playbook || which ansible-playbook || where ansible-playbook');
OrchestratorLogger.log(`[Ansible] ansible-playbook binary verified`);
} catch (error: any) {
core.error('ansible-playbook not found. Install Ansible or ensure it is in PATH.');
throw new Error(`ansible-playbook not found on PATH: ${error.message || error}`);
}
// Verify inventory exists
try {
await OrchestratorSystem.Run(`test -e "${this.inventory}"`);
} catch {
throw new Error(`Inventory not found: ${this.inventory}`);
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[Ansible] Running playbook against inventory ${this.inventory}`);
if (!this.playbook) {
throw new Error(
'ansiblePlaybook is required — no default playbook is provided yet. ' +
'Provide a playbook that accepts build_guid, build_image, build_commands, mount_dir, and working_dir variables.',
);
}
// Build extra-vars JSON
// These use snake_case because they are Ansible variable names passed to playbooks
const playbookVariables: Record<string, string> = {
// eslint-disable-next-line camelcase
build_guid: buildGuid,
// eslint-disable-next-line camelcase
build_image: image,
// eslint-disable-next-line camelcase
build_commands: commands,
// eslint-disable-next-line camelcase
mount_dir: mountdir,
// eslint-disable-next-line camelcase
working_dir: workingdir,
};
for (const element of environment) {
playbookVariables[element.name.toLowerCase()] = element.value;
}
// Merge user-provided extra vars
if (this.extraVariables) {
try {
const userVariables = JSON.parse(this.extraVariables);
Object.assign(playbookVariables, userVariables);
} catch {
OrchestratorLogger.logWarning(`[Ansible] Failed to parse ansibleExtraVars as JSON, using as-is`);
}
}
const extraVariablesJson = JSON.stringify(playbookVariables).replace(/'/g, "'\\''");
// Build ansible-playbook command
const commandParts = [
'ansible-playbook',
`-i "${this.inventory}"`,
`"${this.playbook}"`,
`-e '${extraVariablesJson}'`,
'--no-color',
];
if (this.vaultPassword) {
commandParts.push(`--vault-password-file "${this.vaultPassword}"`);
}
// Add secret variables as extra environment
const environmentPrefix = secrets
.map((secret) => `${secret.EnvironmentVariable}='${secret.ParameterValue}'`)
.join(' ');
const fullCommand = environmentPrefix ? `${environmentPrefix} ${commandParts.join(' ')}` : commandParts.join(' ');
try {
const output = await OrchestratorSystem.Run(fullCommand);
OrchestratorLogger.log(`[Ansible] Playbook completed successfully`);
return output;
} catch (error: any) {
OrchestratorLogger.logWarning(`[Ansible] Playbook failed: ${error.message || error}`);
throw error;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[Ansible] Cleanup complete`);
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
return '';
}
async listResources(): Promise<ProviderResource[]> {
if (!this.inventory) return [];
const resource = new ProviderResource();
resource.Name = this.inventory;
return [resource];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
return [];
}
async watchWorkflow(): Promise<string> {
return '';
}
}
export default AnsibleProvider;

View File

@@ -1,333 +0,0 @@
import GitHubActionsProvider from '.';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import * as core from '@actions/core';
jest.mock('../../services/core/orchestrator-system');
jest.mock('../../services/core/orchestrator-logger');
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(() => ''),
}));
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
function createBuildParameters(overrides: Partial<BuildParameters> = {}): BuildParameters {
return {
githubActionsRepo: 'owner/repo',
githubActionsWorkflow: 'build.yml',
githubActionsToken: 'ghp_test_token_123',
githubActionsRef: 'main',
...overrides,
} as BuildParameters;
}
// Override setTimeout to execute callbacks immediately so polling loops complete fast
const originalSetTimeout = global.setTimeout;
beforeAll(() => {
global.setTimeout = ((fn: (...args: any[]) => void, _ms?: number, ...args: any[]) => {
return originalSetTimeout(fn, 0, ...args);
}) as any;
});
afterAll(() => {
global.setTimeout = originalSetTimeout;
});
describe('GitHubActionsProvider', () => {
let provider: GitHubActionsProvider;
beforeEach(() => {
jest.clearAllMocks();
provider = new GitHubActionsProvider(createBuildParameters());
});
describe('constructor', () => {
it('sets default ref to main when not specified', () => {
const params = createBuildParameters({ githubActionsRef: undefined });
const p = new GitHubActionsProvider(params);
expect(p).toBeDefined();
});
it('uses provided ref when specified', () => {
const params = createBuildParameters({ githubActionsRef: 'develop' });
const p = new GitHubActionsProvider(params);
expect(p).toBeDefined();
});
});
describe('setupWorkflow', () => {
it('verifies workflow exists via gh api and logs success', async () => {
mockRun.mockResolvedValueOnce('12345\n');
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
expect(mockRun).toHaveBeenCalledTimes(1);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('gh api repos/owner/repo/actions/workflows/build.yml');
expect(command).toContain("--jq '.id'");
expect(command).toContain('GH_TOKEN=ghp_test_token_123');
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Workflow verified'));
});
it('throws when repo is not configured', async () => {
const params = createBuildParameters({ githubActionsRepo: '' });
provider = new GitHubActionsProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'githubActionsRepo and githubActionsWorkflow are required',
);
});
it('throws when workflow is not configured', async () => {
const params = createBuildParameters({ githubActionsWorkflow: '' });
provider = new GitHubActionsProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'githubActionsRepo and githubActionsWorkflow are required',
);
});
it('throws when token is missing', async () => {
const params = createBuildParameters({ githubActionsToken: '' });
provider = new GitHubActionsProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'githubActionsToken is required',
);
});
it('throws descriptive error when workflow verification fails', async () => {
mockRun.mockRejectedValueOnce(new Error('Not Found'));
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'Failed to verify workflow build.yml in owner/repo',
);
});
});
describe('runTaskInWorkflow', () => {
it('dispatches workflow with correct inputs and returns logs on success', async () => {
// Dispatch succeeds
mockRun.mockResolvedValueOnce('');
// First poll finds the run
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 99001, status: 'in_progress' }));
// Status poll returns completed
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' }));
// Log fetch succeeds
mockRun.mockResolvedValueOnce('Build output log content here');
const result = await provider.runTaskInWorkflow(
'guid-abc',
'unityci/editor:2021.3',
'echo build',
'/mount',
'/work',
[],
[],
);
expect(result).toBe('Build output log content here');
// Verify dispatch command
const dispatchCommand = mockRun.mock.calls[0][0];
expect(dispatchCommand).toContain('dispatches');
expect(dispatchCommand).toContain('-X POST');
expect(dispatchCommand).toContain("ref='main'");
// Verify log fetch command
const logCommand = mockRun.mock.calls[3][0];
expect(logCommand).toContain('gh run view');
expect(logCommand).toContain('--log');
expect(logCommand).toContain('--repo owner/repo');
});
it('base64 encodes commands in the inputs payload', async () => {
mockRun.mockResolvedValueOnce(''); // dispatch
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 100, status: 'completed' })); // run found
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); // status
mockRun.mockResolvedValueOnce('logs'); // logs
await provider.runTaskInWorkflow('guid-1', 'image:latest', 'echo hello && build', '/mnt', '/w', [], []);
const dispatchCommand = mockRun.mock.calls[0][0];
const expectedB64 = Buffer.from('echo hello && build').toString('base64');
expect(dispatchCommand).toContain(expectedB64);
});
it('includes environment variables as JSON input', async () => {
mockRun.mockResolvedValueOnce(''); // dispatch
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 200, status: 'completed' })); // run found
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); // status
mockRun.mockResolvedValueOnce('logs'); // logs
const env = [
{ name: 'UNITY_LICENSE', value: 'license-data' },
{ name: 'BUILD_TARGET', value: 'StandaloneWindows64' },
];
await provider.runTaskInWorkflow('guid-2', 'img', 'cmd', '/m', '/w', env as any, []);
const dispatchCommand = mockRun.mock.calls[0][0];
expect(dispatchCommand).toContain('UNITY_LICENSE');
expect(dispatchCommand).toContain('BUILD_TARGET');
});
it('throws when workflow dispatch fails', async () => {
mockRun.mockRejectedValueOnce(new Error('403 Forbidden'));
await expect(provider.runTaskInWorkflow('guid-err', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Failed to dispatch workflow',
);
});
it('throws when workflow run does not start within timeout', async () => {
mockRun.mockResolvedValueOnce(''); // dispatch succeeds
// All 30 poll attempts fail
for (let i = 0; i < 30; i++) {
mockRun.mockRejectedValueOnce(new Error('not found'));
}
await expect(provider.runTaskInWorkflow('guid-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Workflow run did not start within',
);
});
it('throws when workflow run fails with non-success conclusion', async () => {
mockRun.mockResolvedValueOnce(''); // dispatch
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 300, status: 'in_progress' })); // run appears
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'failure' })); // fails
await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Workflow run failed with conclusion: failure',
);
});
it('returns fallback message when log fetch fails', async () => {
mockRun.mockResolvedValueOnce(''); // dispatch
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 400, status: 'completed' })); // run appears
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); // completes
mockRun.mockRejectedValueOnce(new Error('logs unavailable')); // log fetch fails
const result = await provider.runTaskInWorkflow('guid-nologs', 'img', 'cmd', '/m', '/w', [], []);
expect(result).toContain('completed successfully');
expect(result).toContain('logs unavailable');
});
it('handles cancelled workflow run conclusion', async () => {
mockRun.mockResolvedValueOnce(''); // dispatch
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 500, status: 'in_progress' })); // run
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'cancelled' })); // cancelled
await expect(provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Workflow run failed with conclusion: cancelled',
);
});
it('throws timeout error when polling exceeds maximum duration', async () => {
// Save real Date.now
const realDateNow = Date.now;
let callCount = 0;
// dispatch succeeds
mockRun.mockResolvedValueOnce('');
// run appears
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 600, status: 'in_progress' }));
// Status always returns in_progress
mockRun.mockImplementation(() => Promise.resolve(JSON.stringify({ status: 'in_progress' })));
// First call returns normal time, subsequent calls simulate 5 hours elapsed
Date.now = () => {
callCount++;
if (callCount <= 2) return realDateNow.call(Date);
return realDateNow.call(Date) + 14_400_001; // 4 hours + 1ms
};
try {
await expect(provider.runTaskInWorkflow('guid-poll-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'did not complete within 4 hours',
);
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('did not complete within 4 hours'));
} finally {
Date.now = realDateNow;
}
});
});
describe('cleanupWorkflow', () => {
it('completes without error and logs cleanup message', async () => {
await provider.cleanupWorkflow(createBuildParameters(), 'main', []);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleanup complete'));
});
});
describe('garbageCollect', () => {
it('returns empty string (no-op)', async () => {
const result = await provider.garbageCollect('', false, 0, false, false);
expect(result).toBe('');
});
});
describe('listResources', () => {
it('returns runner names from the repository', async () => {
mockRun.mockResolvedValueOnce('runner-1\nrunner-2\nrunner-3\n');
const resources = await provider.listResources();
expect(resources).toHaveLength(3);
expect(resources[0].Name).toBe('runner-1');
expect(resources[1].Name).toBe('runner-2');
expect(resources[2].Name).toBe('runner-3');
});
it('returns empty array when repo or token is missing', async () => {
const params = createBuildParameters({ githubActionsRepo: '' });
provider = new GitHubActionsProvider(params);
const resources = await provider.listResources();
expect(resources).toEqual([]);
expect(mockRun).not.toHaveBeenCalled();
});
it('returns empty array when API call fails', async () => {
mockRun.mockRejectedValueOnce(new Error('API error'));
const resources = await provider.listResources();
expect(resources).toEqual([]);
});
});
describe('listWorkflow', () => {
it('returns recent workflow run names', async () => {
mockRun.mockResolvedValueOnce('Build Unity\nRun Tests\n');
const workflows = await provider.listWorkflow();
expect(workflows).toHaveLength(2);
expect(workflows[0].Name).toBe('Build Unity');
expect(workflows[1].Name).toBe('Run Tests');
});
it('returns empty array when credentials missing', async () => {
const params = createBuildParameters({ githubActionsToken: '' });
provider = new GitHubActionsProvider(params);
const workflows = await provider.listWorkflow();
expect(workflows).toEqual([]);
});
});
describe('watchWorkflow', () => {
it('returns message when no active run exists', async () => {
const result = await provider.watchWorkflow();
expect(result).toBe('No active run to watch');
});
});
});

View File

@@ -1,284 +0,0 @@
import * as core from '@actions/core';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
const MAX_POLLING_DURATION_MS = 14_400_000; // 4 hours
/**
* GitHub Actions provider — triggers builds as workflow_dispatch events
* on a target repository via the GitHub API.
*
* Use case: Distribute builds across orgs, use specialized runner pools,
* or trigger builds in repos with Unity licenses.
*/
class GitHubActionsProvider implements ProviderInterface {
private buildParameters: BuildParameters;
private repo: string;
private workflow: string;
private token: string;
private ref: string;
private runId: number = 0;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.repo = buildParameters.githubActionsRepo || '';
this.workflow = buildParameters.githubActionsWorkflow || '';
this.token = buildParameters.githubActionsToken || '';
this.ref = buildParameters.githubActionsRef || 'main';
}
async setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[GitHubActions] Setting up workflow dispatch to ${this.repo}`);
if (!this.repo || !this.workflow) {
throw new Error('githubActionsRepo and githubActionsWorkflow are required for the github-actions provider');
}
if (!this.token) {
throw new Error('githubActionsToken is required (PAT with actions:write scope)');
}
// Verify repository and workflow exist
try {
const result = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/workflows/${this.workflow} --jq '.id'`,
);
OrchestratorLogger.log(`[GitHubActions] Workflow verified: ${this.workflow} (ID: ${result.trim()})`);
} catch (error: any) {
throw new Error(`Failed to verify workflow ${this.workflow} in ${this.repo}: ${error.message || error}`);
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
// eslint-disable-next-line no-unused-vars
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[GitHubActions] Dispatching workflow ${this.workflow} on ${this.repo}@${this.ref}`);
// Build inputs payload
const inputs: Record<string, string> = {
buildGuid,
image,
commands: Buffer.from(commands).toString('base64'),
mountdir,
workingdir,
};
// Add environment variables as a JSON input
if (environment.length > 0) {
inputs.environment = JSON.stringify(environment.map((element) => ({ name: element.name, value: element.value })));
}
// Record the time before dispatch to identify the run
const beforeDispatch = new Date().toISOString();
// Dispatch the workflow
const inputsJson = JSON.stringify(inputs).replace(/'/g, "'\\''");
try {
await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/workflows/${this.workflow}/dispatches -X POST -f ref='${this.ref}' -f "inputs=${inputsJson}"`,
);
OrchestratorLogger.log(`[GitHubActions] Workflow dispatched`);
} catch (error: any) {
throw new Error(`Failed to dispatch workflow: ${error.message || error}`);
}
// Poll for the run to appear
OrchestratorLogger.log(`[GitHubActions] Waiting for workflow run to start...`);
let attempts = 0;
const maxAttempts = 30;
while (attempts < maxAttempts) {
attempts++;
await new Promise((resolve) => setTimeout(resolve, 10_000));
try {
const runsJson = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api "repos/${this.repo}/actions/workflows/${this.workflow}/runs?created=>${beforeDispatch}&per_page=5" --jq '.workflow_runs[0] | {id, status, conclusion}'`,
true,
);
const run = JSON.parse(runsJson.trim());
if (run.id) {
this.runId = run.id;
OrchestratorLogger.log(`[GitHubActions] Run started: ${this.runId} (status: ${run.status})`);
break;
}
} catch {
// Run not yet available
}
}
if (!this.runId) {
throw new Error(`Workflow run did not start within ${maxAttempts * 10}s`);
}
// Poll until completion and stream logs (with maximum duration guard)
let status = 'in_progress';
const pollingStartTime = Date.now();
const runUrl = `https://github.com/${this.repo}/actions/runs/${this.runId}`;
while (status === 'in_progress' || status === 'queued') {
const elapsedMs = Date.now() - pollingStartTime;
if (elapsedMs >= MAX_POLLING_DURATION_MS) {
const hours = Math.round(MAX_POLLING_DURATION_MS / 3_600_000);
const message = `GitHub Actions workflow did not complete within ${hours} hours. Run URL: ${runUrl}`;
core.error(message);
throw new Error(message);
}
await new Promise((resolve) => setTimeout(resolve, 15_000));
try {
const statusJson = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runs/${this.runId} --jq '{status, conclusion}'`,
true,
);
const result = JSON.parse(statusJson.trim());
status = result.status;
if (status === 'completed') {
OrchestratorLogger.log(`[GitHubActions] Run ${this.runId} completed: ${result.conclusion}`);
if (result.conclusion !== 'success') {
throw new Error(`Workflow run failed with conclusion: ${result.conclusion}`);
}
break;
}
OrchestratorLogger.log(`[GitHubActions] Run ${this.runId} status: ${status}`);
} catch (error: any) {
if (error.message && error.message.includes('conclusion')) {
throw error;
}
if (error.message && error.message.includes('did not complete within')) {
throw error;
}
OrchestratorLogger.logWarning(`[GitHubActions] Status check error: ${error.message || error}`);
}
}
// Fetch logs
try {
const logs = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh run view ${this.runId} --repo ${this.repo} --log`,
true,
);
return logs;
} catch {
return `Run ${this.runId} completed successfully (logs unavailable)`;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[GitHubActions] Cleanup complete (no resources to tear down)`);
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
return '';
}
async listResources(): Promise<ProviderResource[]> {
if (!this.repo || !this.token) return [];
try {
const runnersJson = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runners --jq '.runners[] | .name'`,
true,
);
return runnersJson
.trim()
.split('\n')
.filter(Boolean)
.map((name) => {
const resource = new ProviderResource();
resource.Name = name.trim();
return resource;
});
} catch {
return [];
}
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
if (!this.repo || !this.token) return [];
try {
const runsJson = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runs?per_page=10 --jq '.workflow_runs[] | .name'`,
true,
);
return runsJson
.trim()
.split('\n')
.filter(Boolean)
.map((name) => {
const workflow = new ProviderWorkflow();
workflow.Name = name.trim();
return workflow;
});
} catch {
return [];
}
}
async watchWorkflow(): Promise<string> {
if (!this.runId) return 'No active run to watch';
try {
return await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh run watch ${this.runId} --repo ${this.repo}`,
true,
);
} catch {
return '';
}
}
}
export default GitHubActionsProvider;

View File

@@ -1,329 +0,0 @@
import GitLabCIProvider from '.';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import * as core from '@actions/core';
jest.mock('../../services/core/orchestrator-system');
jest.mock('../../services/core/orchestrator-logger');
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(() => ''),
}));
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction<typeof OrchestratorLogger.logWarning>;
function createBuildParameters(overrides: Partial<BuildParameters> = {}): BuildParameters {
return {
gitlabProjectId: 'my-group/my-project',
gitlabTriggerToken: 'glptt-test-token-456',
gitlabApiUrl: 'https://gitlab.example.com',
gitlabRef: 'main',
...overrides,
} as BuildParameters;
}
// Override setTimeout to execute callbacks immediately so polling loops complete fast
const originalSetTimeout = global.setTimeout;
beforeAll(() => {
global.setTimeout = ((fn: (...args: any[]) => void, _ms?: number, ...args: any[]) => {
return originalSetTimeout(fn, 0, ...args);
}) as any;
});
afterAll(() => {
global.setTimeout = originalSetTimeout;
});
describe('GitLabCIProvider', () => {
let provider: GitLabCIProvider;
beforeEach(() => {
jest.clearAllMocks();
provider = new GitLabCIProvider(createBuildParameters());
});
describe('constructor', () => {
it('strips trailing slashes from apiUrl', () => {
const params = createBuildParameters({ gitlabApiUrl: 'https://gitlab.example.com///' });
const p = new GitLabCIProvider(params);
expect(p).toBeDefined();
});
it('defaults apiUrl to https://gitlab.com when not provided', () => {
const params = createBuildParameters({ gitlabApiUrl: undefined });
const p = new GitLabCIProvider(params);
expect(p).toBeDefined();
});
it('defaults ref to main when not provided', () => {
const params = createBuildParameters({ gitlabRef: undefined });
const p = new GitLabCIProvider(params);
expect(p).toBeDefined();
});
});
describe('setupWorkflow', () => {
it('verifies project access via curl and logs success', async () => {
mockRun.mockResolvedValueOnce('');
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
expect(mockRun).toHaveBeenCalledTimes(1);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('curl -sf');
expect(command).toContain('PRIVATE-TOKEN: glptt-test-token-456');
expect(command).toContain('gitlab.example.com/api/v4/projects/');
expect(command).toContain(encodeURIComponent('my-group/my-project'));
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Project access verified'));
});
it('throws when projectId is not configured', async () => {
const params = createBuildParameters({ gitlabProjectId: '' });
provider = new GitLabCIProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'gitlabProjectId and gitlabTriggerToken are required',
);
});
it('throws when triggerToken is not configured', async () => {
const params = createBuildParameters({ gitlabTriggerToken: '' });
provider = new GitLabCIProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'gitlabProjectId and gitlabTriggerToken are required',
);
});
it('throws descriptive error when project access check fails', async () => {
mockRun.mockRejectedValueOnce(new Error('401 Unauthorized'));
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'Failed to access GitLab project my-group/my-project',
);
});
});
describe('runTaskInWorkflow', () => {
it('triggers pipeline and returns job logs on success', async () => {
// Pipeline trigger response
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5001, status: 'pending' }));
// Status poll returns success
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
// Jobs list
mockRun.mockResolvedValueOnce(
JSON.stringify([
{ id: 10001, name: 'build-unity', status: 'success' },
{ id: 10002, name: 'test-unity', status: 'success' },
]),
);
// Job traces
mockRun.mockResolvedValueOnce('Building Unity project...\nDone.');
mockRun.mockResolvedValueOnce('Running tests...\nAll passed.');
const result = await provider.runTaskInWorkflow(
'guid-gl1',
'unityci/editor:2021.3',
'echo build',
'/mount',
'/work',
[],
[],
);
expect(result).toContain('build-unity');
expect(result).toContain('test-unity');
expect(result).toContain('Building Unity project');
expect(result).toContain('Running tests');
// Verify trigger command
const triggerCommand = mockRun.mock.calls[0][0];
expect(triggerCommand).toContain('trigger/pipeline');
expect(triggerCommand).toContain(`token=${createBuildParameters().gitlabTriggerToken}`);
expect(triggerCommand).toContain('ref=main');
});
it('passes build variables including base64-encoded commands', async () => {
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5002, status: 'success' }));
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
mockRun.mockResolvedValueOnce(JSON.stringify([]));
await provider.runTaskInWorkflow(
'guid-vars',
'ubuntu:20.04',
'make build',
'/mnt/data',
'/workspace',
[{ name: 'UNITY_VERSION', value: '2021.3.1f1' } as any],
[],
);
const triggerCommand = mockRun.mock.calls[0][0];
const expectedB64 = Buffer.from('make build').toString('base64');
expect(triggerCommand).toContain(`variables[BUILD_COMMANDS]=${expectedB64}`);
expect(triggerCommand).toContain('variables[BUILD_GUID]=guid-vars');
expect(triggerCommand).toContain('variables[BUILD_IMAGE]=ubuntu:20.04');
expect(triggerCommand).toContain('variables[MOUNT_DIR]=/mnt/data');
expect(triggerCommand).toContain('variables[WORKING_DIR]=/workspace');
expect(triggerCommand).toContain('variables[UNITY_VERSION]=2021.3.1f1');
});
it('throws when pipeline trigger fails', async () => {
mockRun.mockRejectedValueOnce(new Error('404 Not Found'));
await expect(provider.runTaskInWorkflow('guid-err', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Failed to trigger pipeline',
);
});
it('throws when pipeline finishes with failure status', async () => {
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5003, status: 'pending' }));
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'failed' }));
await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Pipeline 5003 finished with status: failed',
);
});
it('throws when pipeline is canceled', async () => {
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5004, status: 'pending' }));
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'canceled' }));
await expect(provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Pipeline 5004 finished with status: canceled',
);
});
it('handles job log fetch failures gracefully', async () => {
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5005, status: 'success' }));
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
mockRun.mockResolvedValueOnce(JSON.stringify([{ id: 20001, name: 'build', status: 'success' }]));
// Job trace fetch fails
mockRun.mockRejectedValueOnce(new Error('trace unavailable'));
const result = await provider.runTaskInWorkflow('guid-nologs', 'img', 'cmd', '/m', '/w', [], []);
expect(result).toContain('build');
expect(result).toContain('logs unavailable');
});
it('returns fallback message when entire job fetch fails', async () => {
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5006, status: 'success' }));
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
// Jobs list fails
mockRun.mockRejectedValueOnce(new Error('API error'));
const result = await provider.runTaskInWorkflow('guid-noapi', 'img', 'cmd', '/m', '/w', [], []);
expect(result).toContain('Pipeline 5006 completed successfully');
expect(result).toContain('logs unavailable');
});
it('continues polling through status check errors until completion', async () => {
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5007, status: 'pending' }));
// First status check fails
mockRun.mockRejectedValueOnce(new Error('network blip'));
// Second status check succeeds
mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' }));
// Jobs/logs
mockRun.mockResolvedValueOnce(JSON.stringify([]));
await provider.runTaskInWorkflow('guid-retry', 'img', 'cmd', '/m', '/w', [], []);
expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Status check error'));
});
it('throws timeout error when polling exceeds maximum duration', async () => {
const realDateNow = Date.now;
let callCount = 0;
// Trigger pipeline succeeds
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5008, status: 'running' }));
// Status always returns running
mockRun.mockImplementation(() => Promise.resolve(JSON.stringify({ status: 'running' })));
// After first call, simulate 5 hours elapsed
Date.now = () => {
callCount++;
if (callCount <= 1) return realDateNow.call(Date);
return realDateNow.call(Date) + 14_400_001; // 4 hours + 1ms
};
try {
await expect(provider.runTaskInWorkflow('guid-poll-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'did not complete within 4 hours',
);
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('did not complete within 4 hours'));
} finally {
Date.now = realDateNow;
}
});
});
describe('cleanupWorkflow', () => {
it('completes without error and logs cleanup message', async () => {
await provider.cleanupWorkflow(createBuildParameters(), 'main', []);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleanup complete'));
});
});
describe('garbageCollect', () => {
it('returns empty string (no-op)', async () => {
const result = await provider.garbageCollect('', false, 0, false, false);
expect(result).toBe('');
});
});
describe('listResources', () => {
it('returns empty array (not implemented)', async () => {
const resources = await provider.listResources();
expect(resources).toEqual([]);
});
});
describe('listWorkflow', () => {
it('returns recent pipeline names when credentials are available', async () => {
mockRun.mockResolvedValueOnce(
JSON.stringify([
{ id: 100, status: 'success' },
{ id: 101, status: 'failed' },
]),
);
const workflows = await provider.listWorkflow();
expect(workflows).toHaveLength(2);
expect(workflows[0].Name).toBe('Pipeline #100 (success)');
expect(workflows[1].Name).toBe('Pipeline #101 (failed)');
});
it('returns empty array when credentials are missing', async () => {
const params = createBuildParameters({ gitlabProjectId: '' });
provider = new GitLabCIProvider(params);
const workflows = await provider.listWorkflow();
expect(workflows).toEqual([]);
expect(mockRun).not.toHaveBeenCalled();
});
it('returns empty array when API call fails', async () => {
mockRun.mockRejectedValueOnce(new Error('API error'));
const workflows = await provider.listWorkflow();
expect(workflows).toEqual([]);
});
});
describe('watchWorkflow', () => {
it('returns empty string (not implemented)', async () => {
const result = await provider.watchWorkflow();
expect(result).toBe('');
});
});
});

View File

@@ -1,224 +0,0 @@
import * as core from '@actions/core';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
const MAX_POLLING_DURATION_MS = 14_400_000; // 4 hours
/**
* GitLab CI provider — triggers builds as GitLab CI pipelines
* via the GitLab API.
*
* Use case: Teams using GitLab CI, hybrid GitHub/GitLab setups,
* or GitLab runners with Unity licenses.
*/
class GitLabCIProvider implements ProviderInterface {
private buildParameters: BuildParameters;
private projectId: string;
private triggerToken: string;
private apiUrl: string;
private ref: string;
private pipelineId: number = 0;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.projectId = buildParameters.gitlabProjectId || '';
this.triggerToken = buildParameters.gitlabTriggerToken || '';
this.apiUrl = (buildParameters.gitlabApiUrl || 'https://gitlab.com').replace(/\/+$/, '');
this.ref = buildParameters.gitlabRef || 'main';
}
async setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[GitLabCI] Setting up pipeline trigger for project ${this.projectId}`);
if (!this.projectId || !this.triggerToken) {
throw new Error('gitlabProjectId and gitlabTriggerToken are required for the gitlab-ci provider');
}
// Verify project access
const encodedProject = encodeURIComponent(this.projectId);
try {
await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}" -o /dev/null`,
);
OrchestratorLogger.log(`[GitLabCI] Project access verified`);
} catch (error: any) {
throw new Error(`Failed to access GitLab project ${this.projectId}: ${error.message || error}`);
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
// eslint-disable-next-line no-unused-vars
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[GitLabCI] Triggering pipeline on project ${this.projectId}@${this.ref}`);
const encodedProject = encodeURIComponent(this.projectId);
// Build variables for the pipeline
const pipelineVariables: string[] = [
`-f "variables[BUILD_GUID]=${buildGuid}"`,
`-f "variables[BUILD_IMAGE]=${image}"`,
`-f "variables[BUILD_COMMANDS]=${Buffer.from(commands).toString('base64')}"`,
`-f "variables[MOUNT_DIR]=${mountdir}"`,
`-f "variables[WORKING_DIR]=${workingdir}"`,
];
for (const element of environment) {
pipelineVariables.push(`-f "variables[${element.name}]=${element.value}"`);
}
// Trigger pipeline
try {
const response = await OrchestratorSystem.Run(
`curl -sf -X POST "${this.apiUrl}/api/v4/projects/${encodedProject}/trigger/pipeline" -f "token=${
this.triggerToken
}" -f "ref=${this.ref}" ${pipelineVariables.join(' ')}`,
);
const pipeline = JSON.parse(response);
this.pipelineId = pipeline.id;
OrchestratorLogger.log(`[GitLabCI] Pipeline triggered: ${this.pipelineId} (status: ${pipeline.status})`);
} catch (error: any) {
throw new Error(`Failed to trigger pipeline: ${error.message || error}`);
}
// Poll until completion (with maximum duration guard)
let status = 'pending';
const terminalStatuses = new Set(['success', 'failed', 'canceled', 'skipped']);
const pollingStartTime = Date.now();
const pipelineUrl = `${this.apiUrl}/${this.projectId}/-/pipelines/${this.pipelineId}`;
while (!terminalStatuses.has(status)) {
const elapsedMs = Date.now() - pollingStartTime;
if (elapsedMs >= MAX_POLLING_DURATION_MS) {
const hours = Math.round(MAX_POLLING_DURATION_MS / 3_600_000);
const message = `GitLab CI pipeline did not complete within ${hours} hours. Pipeline URL: ${pipelineUrl}`;
core.error(message);
throw new Error(message);
}
await new Promise((resolve) => setTimeout(resolve, 15_000));
try {
const statusResponse = await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines/${this.pipelineId}"`,
true,
);
const pipelineStatus = JSON.parse(statusResponse);
status = pipelineStatus.status;
OrchestratorLogger.log(`[GitLabCI] Pipeline ${this.pipelineId} status: ${status}`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[GitLabCI] Status check error: ${error.message || error}`);
}
}
if (status !== 'success') {
throw new Error(`Pipeline ${this.pipelineId} finished with status: ${status}`);
}
// Fetch job logs
try {
const jobsResponse = await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines/${this.pipelineId}/jobs"`,
true,
);
const jobs = JSON.parse(jobsResponse);
const logs: string[] = [];
for (const job of jobs) {
try {
const jobLog = await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/jobs/${job.id}/trace"`,
true,
);
logs.push(`=== Job: ${job.name} (${job.status}) ===\n${jobLog}`);
} catch {
logs.push(`=== Job: ${job.name} (${job.status}) === (logs unavailable)`);
}
}
return logs.join('\n\n');
} catch {
return `Pipeline ${this.pipelineId} completed successfully (logs unavailable)`;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[GitLabCI] Cleanup complete`);
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
return '';
}
async listResources(): Promise<ProviderResource[]> {
return [];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
if (!this.projectId || !this.triggerToken) return [];
try {
const encodedProject = encodeURIComponent(this.projectId);
const response = await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines?per_page=10"`,
true,
);
return JSON.parse(response).map((pipeline: any) => {
const workflow = new ProviderWorkflow();
workflow.Name = `Pipeline #${pipeline.id} (${pipeline.status})`;
return workflow;
});
} catch {
return [];
}
}
async watchWorkflow(): Promise<string> {
return '';
}
}
export default GitLabCIProvider;

View File

@@ -1,164 +0,0 @@
import BuildParameters from '../../build-parameters';
import RemotePowershellProvider from './remote-powershell';
import GitHubActionsProvider from './github-actions';
import GitLabCIProvider from './gitlab-ci';
import AnsibleProvider from './ansible';
/**
* Tests for provider selection logic in Orchestrator.setProvider.
*
* These tests verify that the correct provider class is instantiated based on
* the providerStrategy field in BuildParameters. Rather than invoking the full
* Orchestrator.setProvider (which has heavy dependencies on OrchestratorOptions,
* AWS detection, etc.), we test the provider constructors directly to verify
* they produce the right provider type from the same build parameters the
* orchestrator switch statement uses.
*/
describe('Provider Selection', () => {
describe('remote-powershell provider', () => {
it('creates RemotePowershellProvider from build parameters', () => {
const params = {
providerStrategy: 'remote-powershell',
remotePowershellHost: 'build-server.local',
remotePowershellTransport: 'wsman',
remotePowershellCredential: 'user:pass',
} as BuildParameters;
const provider = new RemotePowershellProvider(params);
expect(provider).toBeInstanceOf(RemotePowershellProvider);
expect(provider.constructor.name).toBe('RemotePowershellProvider');
});
});
describe('github-actions provider', () => {
it('creates GitHubActionsProvider from build parameters', () => {
const params = {
providerStrategy: 'github-actions',
githubActionsRepo: 'org/repo',
githubActionsWorkflow: 'ci.yml',
githubActionsToken: 'ghp_token',
githubActionsRef: 'main',
} as BuildParameters;
const provider = new GitHubActionsProvider(params);
expect(provider).toBeInstanceOf(GitHubActionsProvider);
expect(provider.constructor.name).toBe('GitHubActionsProvider');
});
});
describe('gitlab-ci provider', () => {
it('creates GitLabCIProvider from build parameters', () => {
const params = {
providerStrategy: 'gitlab-ci',
gitlabProjectId: 'group/project',
gitlabTriggerToken: 'glptt-token',
gitlabApiUrl: 'https://gitlab.com',
gitlabRef: 'main',
} as BuildParameters;
const provider = new GitLabCIProvider(params);
expect(provider).toBeInstanceOf(GitLabCIProvider);
expect(provider.constructor.name).toBe('GitLabCIProvider');
});
});
describe('ansible provider', () => {
it('creates AnsibleProvider from build parameters', () => {
const params = {
providerStrategy: 'ansible',
ansibleInventory: '/etc/ansible/hosts',
ansiblePlaybook: '/playbooks/build.yml',
ansibleExtraVars: '',
ansibleVaultPassword: '',
} as BuildParameters;
const provider = new AnsibleProvider(params);
expect(provider).toBeInstanceOf(AnsibleProvider);
expect(provider.constructor.name).toBe('AnsibleProvider');
});
});
describe('provider strategy routing', () => {
it('each provider strategy maps to a distinct provider class', () => {
const strategies: Record<string, new (params: BuildParameters) => any> = {
'remote-powershell': RemotePowershellProvider,
'github-actions': GitHubActionsProvider,
'gitlab-ci': GitLabCIProvider,
ansible: AnsibleProvider,
};
const params = {
remotePowershellHost: 'host',
remotePowershellTransport: 'wsman',
remotePowershellCredential: '',
githubActionsRepo: 'org/repo',
githubActionsWorkflow: 'ci.yml',
githubActionsToken: 'token',
githubActionsRef: 'main',
gitlabProjectId: 'proj',
gitlabTriggerToken: 'tok',
gitlabApiUrl: 'https://gitlab.com',
gitlabRef: 'main',
ansibleInventory: '/inv',
ansiblePlaybook: '/pb.yml',
ansibleExtraVars: '',
ansibleVaultPassword: '',
} as BuildParameters;
const instances = Object.entries(strategies).map(([strategy, ProviderClass]) => {
const provider = new ProviderClass(params);
return { strategy, className: provider.constructor.name };
});
// Verify all four strategies produce different provider classes
const classNames = instances.map((i) => i.className);
const uniqueClassNames = new Set(classNames);
expect(uniqueClassNames.size).toBe(4);
// Verify expected mapping
expect(instances.find((i) => i.strategy === 'remote-powershell')!.className).toBe('RemotePowershellProvider');
expect(instances.find((i) => i.strategy === 'github-actions')!.className).toBe('GitHubActionsProvider');
expect(instances.find((i) => i.strategy === 'gitlab-ci')!.className).toBe('GitLabCIProvider');
expect(instances.find((i) => i.strategy === 'ansible')!.className).toBe('AnsibleProvider');
});
it('all providers implement ProviderInterface methods', () => {
const params = {
remotePowershellHost: 'host',
githubActionsRepo: 'org/repo',
githubActionsWorkflow: 'ci.yml',
githubActionsToken: 'token',
gitlabProjectId: 'proj',
gitlabTriggerToken: 'tok',
ansibleInventory: '/inv',
} as BuildParameters;
const providers = [
new RemotePowershellProvider(params),
new GitHubActionsProvider(params),
new GitLabCIProvider(params),
new AnsibleProvider(params),
];
const requiredMethods = [
'setupWorkflow',
'runTaskInWorkflow',
'cleanupWorkflow',
'garbageCollect',
'listResources',
'listWorkflow',
'watchWorkflow',
];
for (const provider of providers) {
for (const method of requiredMethods) {
expect(typeof (provider as any)[method]).toBe('function');
}
}
});
});
});

View File

@@ -1,166 +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';
/**
* Remote PowerShell provider — executes Unity builds on remote machines
* via PowerShell Remoting (WinRM or SSH).
*
* Use case: Teams with dedicated build machines not part of a CI system.
*/
class RemotePowershellProvider implements ProviderInterface {
private buildParameters: BuildParameters;
private host: string;
private transport: string;
private credential: string;
private sessionId: string = '';
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.host = buildParameters.remotePowershellHost || '';
this.transport = buildParameters.remotePowershellTransport || 'wsman';
this.credential = buildParameters.remotePowershellCredential || '';
}
async setupWorkflow(
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[RemotePowershell] Setting up remote session to ${this.host} via ${this.transport}`);
if (!this.host) {
throw new Error('remotePowershellHost is required for the remote-powershell provider');
}
// Test connectivity
const testCommand = this.buildPwshCommand(`Test-WSMan -ComputerName "${this.host}" -ErrorAction Stop`);
try {
await OrchestratorSystem.Run(testCommand);
OrchestratorLogger.log(`[RemotePowershell] Connection test passed`);
} catch (error: any) {
throw new Error(`Failed to connect to remote host ${this.host}: ${error.message || error}`);
}
this.sessionId = buildGuid;
OrchestratorLogger.log(`[RemotePowershell] Session ${this.sessionId} ready`);
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[RemotePowershell] Executing task on ${this.host}`);
// Build environment variable block for remote session
const environmentBlock = environment.map((element) => `$env:${element.name} = '${element.value}'`).join('; ');
const secretBlock = secrets
.map((secret) => `$env:${secret.EnvironmentVariable} = '${secret.ParameterValue}'`)
.join('; ');
// Wrap commands for remote execution
const remoteScript = [environmentBlock, secretBlock, `Set-Location "${workingdir}"`, commands]
.filter(Boolean)
.join('; ');
const invokeCommand = this.buildInvokeCommand(remoteScript);
try {
const output = await OrchestratorSystem.Run(invokeCommand);
OrchestratorLogger.log(`[RemotePowershell] Task completed successfully`);
return output;
} catch (error: any) {
OrchestratorLogger.logWarning(`[RemotePowershell] Task failed: ${error.message || error}`);
throw error;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[RemotePowershell] Cleaning up session ${this.sessionId}`);
// Remote sessions are stateless per invocation — no cleanup needed
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
OrchestratorLogger.log(`[RemotePowershell] Garbage collection not supported for remote PowerShell provider`);
return '';
}
async listResources(): Promise<ProviderResource[]> {
const resource = new ProviderResource();
resource.Name = this.host;
return [resource];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
return [];
}
async watchWorkflow(): Promise<string> {
return '';
}
private buildPwshCommand(script: string): string {
return `pwsh -NoProfile -NonInteractive -Command "${script.replace(/"/g, '\\"')}"`;
}
private buildInvokeCommand(remoteScript: string): string {
const escapedScript = remoteScript.replace(/"/g, '\\"').replace(/'/g, "''");
if (this.transport === 'ssh') {
return `pwsh -NoProfile -NonInteractive -Command "Invoke-Command -HostName '${this.host}' -ScriptBlock { ${escapedScript} }"`;
}
// WinRM (default)
// Split on the FIRST colon only — passwords may contain colons
let credentialPart = '';
if (this.credential) {
const colonIndex = this.credential.indexOf(':');
if (colonIndex === -1) {
throw new Error('remotePowershellCredential must be in "username:password" format (no colon found)');
}
const user = this.credential.substring(0, colonIndex);
const pass = this.credential.substring(colonIndex + 1);
credentialPart = `-Credential (New-Object PSCredential('${user}', (ConvertTo-SecureString '${pass}' -AsPlainText -Force)))`;
}
return `pwsh -NoProfile -NonInteractive -Command "Invoke-Command -ComputerName '${this.host}' ${credentialPart} -ScriptBlock { ${escapedScript} }"`;
}
}
export default RemotePowershellProvider;

View File

@@ -1,264 +0,0 @@
import RemotePowershellProvider from '.';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
jest.mock('../../services/core/orchestrator-system');
jest.mock('../../services/core/orchestrator-logger');
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction<typeof OrchestratorLogger.logWarning>;
function createBuildParameters(overrides: Partial<BuildParameters> = {}): BuildParameters {
return {
remotePowershellHost: 'build-server-01.internal',
remotePowershellTransport: 'wsman',
remotePowershellCredential: 'admin:P@ssw0rd!',
...overrides,
} as BuildParameters;
}
describe('RemotePowershellProvider', () => {
let provider: RemotePowershellProvider;
beforeEach(() => {
jest.clearAllMocks();
provider = new RemotePowershellProvider(createBuildParameters());
});
describe('constructor', () => {
it('defaults transport to wsman when not specified', () => {
const params = createBuildParameters({ remotePowershellTransport: undefined });
const p = new RemotePowershellProvider(params);
expect(p).toBeDefined();
});
it('accepts ssh transport', () => {
const params = createBuildParameters({ remotePowershellTransport: 'ssh' });
const p = new RemotePowershellProvider(params);
expect(p).toBeDefined();
});
});
describe('setupWorkflow', () => {
it('tests WinRM connectivity via Test-WSMan and logs success', async () => {
mockRun.mockResolvedValueOnce('wsman output');
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
expect(mockRun).toHaveBeenCalledTimes(1);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('pwsh -NoProfile -NonInteractive');
expect(command).toContain('Test-WSMan');
expect(command).toContain('build-server-01.internal');
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Connection test passed'));
});
it('sets session ID to the build GUID', async () => {
mockRun.mockResolvedValueOnce('');
await provider.setupWorkflow('my-build-guid', createBuildParameters(), 'main', []);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('my-build-guid'));
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ready'));
});
it('throws when host is not configured', async () => {
const params = createBuildParameters({ remotePowershellHost: '' });
provider = new RemotePowershellProvider(params);
await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow(
'remotePowershellHost is required',
);
});
it('throws descriptive error when connectivity test fails', async () => {
mockRun.mockRejectedValueOnce(new Error('WinRM service not running'));
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'Failed to connect to remote host build-server-01.internal',
);
});
});
describe('runTaskInWorkflow', () => {
it('constructs WinRM Invoke-Command with credential and returns output', async () => {
mockRun.mockResolvedValueOnce('Build succeeded!');
const result = await provider.runTaskInWorkflow(
'guid-run1',
'unused-image',
'Unity.exe -batchmode -buildTarget Win64',
'/mount',
'C:\\Projects\\MyGame',
[],
[],
);
expect(result).toBe('Build succeeded!');
const command = mockRun.mock.calls[0][0];
expect(command).toContain('pwsh -NoProfile -NonInteractive');
expect(command).toContain("Invoke-Command -ComputerName 'build-server-01.internal'");
expect(command).toContain('-Credential');
expect(command).toContain('New-Object PSCredential');
expect(command).toContain('-ScriptBlock');
expect(command).toContain('Set-Location');
});
it('constructs SSH Invoke-Command when transport is ssh', async () => {
const params = createBuildParameters({ remotePowershellTransport: 'ssh' });
provider = new RemotePowershellProvider(params);
mockRun.mockResolvedValueOnce('SSH build output');
const result = await provider.runTaskInWorkflow('guid-ssh', 'img', 'build', '/m', '/w', [], []);
expect(result).toBe('SSH build output');
const command = mockRun.mock.calls[0][0];
expect(command).toContain("Invoke-Command -HostName 'build-server-01.internal'");
expect(command).not.toContain('-ComputerName');
expect(command).not.toContain('-Credential');
});
it('includes environment variables in the remote script block', async () => {
mockRun.mockResolvedValueOnce('output');
const env = [
{ name: 'UNITY_LICENSE', value: 'license-data-abc' },
{ name: 'BUILD_TARGET', value: 'StandaloneWindows64' },
];
await provider.runTaskInWorkflow('guid-env', 'img', 'build-cmd', '/m', '/w', env as any, []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('$env:UNITY_LICENSE');
expect(command).toContain('$env:BUILD_TARGET');
});
it('includes secrets in the remote script block', async () => {
mockRun.mockResolvedValueOnce('output');
const secrets = [{ ParameterKey: 'key1', EnvironmentVariable: 'SECRET_KEY', ParameterValue: 'secret-val-123' }];
await provider.runTaskInWorkflow('guid-sec', 'img', 'build-cmd', '/m', '/w', [], secrets as any);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('$env:SECRET_KEY');
});
it('does not include credential in plaintext log output when using WinRM', async () => {
mockRun.mockResolvedValueOnce('output');
await provider.runTaskInWorkflow('guid-cred', 'img', 'cmd', '/m', '/w', [], []);
// The credential is used via ConvertTo-SecureString, not logged directly
const command = mockRun.mock.calls[0][0];
expect(command).toContain('ConvertTo-SecureString');
expect(command).toContain('-AsPlainText -Force');
});
it('omits credential part when no credential is configured (WinRM)', async () => {
const params = createBuildParameters({ remotePowershellCredential: '' });
provider = new RemotePowershellProvider(params);
mockRun.mockResolvedValueOnce('output');
await provider.runTaskInWorkflow('guid-nocred', 'img', 'cmd', '/m', '/w', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain("Invoke-Command -ComputerName 'build-server-01.internal'");
expect(command).not.toContain('-Credential');
expect(command).not.toContain('PSCredential');
});
it('throws and logs warning when remote execution fails', async () => {
const execError = new Error('Remote execution failed: access denied');
mockRun.mockRejectedValueOnce(execError);
await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'Remote execution failed',
);
expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Task failed'));
});
it('preserves passwords containing colons when splitting credentials', async () => {
const params = createBuildParameters({
remotePowershellCredential: 'admin:P@ss:w0rd:with:colons!',
});
provider = new RemotePowershellProvider(params);
mockRun.mockResolvedValueOnce('output');
await provider.runTaskInWorkflow('guid-colon', 'img', 'cmd', '/m', '/w', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain("PSCredential('admin'");
expect(command).toContain("ConvertTo-SecureString 'P@ss:w0rd:with:colons!'");
});
it('throws when credential has no colon separator', async () => {
const params = createBuildParameters({
remotePowershellCredential: 'nocolonhere',
});
provider = new RemotePowershellProvider(params);
await expect(provider.runTaskInWorkflow('guid-badcred', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'username:password',
);
});
it('sets working directory in the remote script', async () => {
mockRun.mockResolvedValueOnce('output');
await provider.runTaskInWorkflow('guid-wd', 'img', 'cmd', '/m', 'D:\\Builds\\Project', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain('Set-Location');
expect(command).toContain('D:\\Builds\\Project');
});
});
describe('cleanupWorkflow', () => {
it('completes without error and logs session cleanup', async () => {
// Setup first to set sessionId
mockRun.mockResolvedValueOnce('');
await provider.setupWorkflow('guid-cleanup', createBuildParameters(), 'main', []);
await provider.cleanupWorkflow(createBuildParameters(), 'main', []);
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleaning up session'));
});
});
describe('garbageCollect', () => {
it('returns empty string and logs not-supported message', async () => {
const result = await provider.garbageCollect('', false, 0, false, false);
expect(result).toBe('');
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('not supported'));
});
});
describe('listResources', () => {
it('returns the configured host as a resource', async () => {
const resources = await provider.listResources();
expect(resources).toHaveLength(1);
expect(resources[0].Name).toBe('build-server-01.internal');
});
});
describe('listWorkflow', () => {
it('returns empty array (not implemented)', async () => {
const workflows = await provider.listWorkflow();
expect(workflows).toEqual([]);
});
});
describe('watchWorkflow', () => {
it('returns empty string (not implemented)', async () => {
const result = await provider.watchWorkflow();
expect(result).toBe('');
});
});
});

View File

@@ -0,0 +1,5 @@
# Hot Runner Protocol
Extensible runner registration and persistent Unity editor provider protocol.
See GitHub Issue for full specification.

View File

@@ -0,0 +1,159 @@
import OrchestratorLogger from '../core/orchestrator-logger';
import { HotRunnerRegistry } from './hot-runner-registry';
import { HotRunnerJobRequest, HotRunnerJobResult, HotRunnerStatus, HotRunnerTransport } from './hot-runner-types';
const POLL_INTERVAL_MS = 1000;
// eslint-disable-next-line no-unused-vars
export type OutputCallback = (output: string) => void;
export class HotRunnerDispatcher {
private transports: Map<string, HotRunnerTransport>;
constructor(transports: Map<string, HotRunnerTransport>) {
this.transports = transports;
}
/**
* Dispatch a job to an available hot runner matching the request's build target.
* If no runner is immediately available, waits up to the request timeout.
* Returns the job result, or throws if no runner becomes available in time.
*/
async dispatchJob(
request: HotRunnerJobRequest,
registry: HotRunnerRegistry,
unityVersion: string,
onOutput?: OutputCallback,
): Promise<HotRunnerJobResult> {
OrchestratorLogger.log(`[HotRunner] Dispatching job ${request.jobId} (target: ${request.buildTarget})`);
// Find or wait for an available runner
let runner = registry.findAvailableRunner({
unityVersion,
platform: request.buildTarget,
});
if (!runner) {
OrchestratorLogger.log(
`[HotRunner] No idle runner available for ${unityVersion}/${request.buildTarget}, waiting...`,
);
runner = await this.waitForRunner({ unityVersion, platform: request.buildTarget }, request.timeout, registry);
}
// Mark runner as busy
registry.updateRunner(runner.id, {
state: 'busy',
currentJob: request.jobId,
});
const transport = this.transports.get(runner.id);
if (!transport) {
registry.updateRunner(runner.id, { state: 'idle', currentJob: undefined });
throw new Error(`[HotRunner] No transport available for runner ${runner.id}`);
}
OrchestratorLogger.log(`[HotRunner] Sending job ${request.jobId} to runner ${runner.id}`);
const startTime = Date.now();
try {
const result = await this.executeWithTimeout(transport, request);
const duration = Date.now() - startTime;
OrchestratorLogger.log(
`[HotRunner] Job ${request.jobId} completed on runner ${runner.id} in ${duration}ms (exit: ${result.exitCode})`,
);
if (onOutput && result.output) {
onOutput(result.output);
}
// Mark runner as idle and increment job count
const currentStatus = registry.getRunner(runner.id);
registry.updateRunner(runner.id, {
state: 'idle',
currentJob: undefined,
lastJobCompleted: request.jobId,
jobsCompleted: (currentStatus?.jobsCompleted ?? 0) + 1,
});
return result;
} catch (error: any) {
OrchestratorLogger.logWarning(`[HotRunner] Job ${request.jobId} failed on runner ${runner.id}: ${error.message}`);
// Mark runner as idle despite failure -- the health monitor will recycle if needed
registry.updateRunner(runner.id, {
state: 'idle',
currentJob: undefined,
});
throw error;
}
}
/**
* Wait for an available runner matching the requirements.
* Polls the registry at a fixed interval until one becomes available or timeout expires.
*/
async waitForRunner(
requirements: { unityVersion: string; platform: string },
timeoutMs: number,
registry: HotRunnerRegistry,
): Promise<HotRunnerStatus> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const runner = registry.findAvailableRunner(requirements);
if (runner) {
OrchestratorLogger.log(`[HotRunner] Runner ${runner.id} became available`);
return runner;
}
await this.sleep(Math.min(POLL_INTERVAL_MS, deadline - Date.now()));
}
throw new Error(
`[HotRunner] Timed out waiting for available runner (${requirements.unityVersion}/${requirements.platform}) after ${timeoutMs}ms`,
);
}
/**
* Execute a job on a transport with a timeout guard.
* On timeout, disconnects the transport to release the connection
* and prevent the orphaned sendJob promise from holding resources.
*/
private async executeWithTimeout(
transport: HotRunnerTransport,
request: HotRunnerJobRequest,
): Promise<HotRunnerJobResult> {
const TIMEOUT_SENTINEL = Symbol('timeout');
const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) => {
setTimeout(() => {
resolve(TIMEOUT_SENTINEL);
}, request.timeout);
});
const result = await Promise.race([transport.sendJob(request), timeoutPromise]);
if (result === TIMEOUT_SENTINEL) {
// Disconnect the transport to clean up the orphaned sendJob call
try {
await transport.disconnect();
} catch (disconnectError: any) {
OrchestratorLogger.logWarning(
`[HotRunner] Error disconnecting transport after timeout for job ${request.jobId}: ${disconnectError.message}`,
);
}
throw new Error(`[HotRunner] Job ${request.jobId} timed out after ${request.timeout}ms`);
}
return result;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,186 @@
import OrchestratorLogger from '../core/orchestrator-logger';
import { HotRunnerRegistry } from './hot-runner-registry';
import { HotRunnerTransport } from './hot-runner-types';
export class HotRunnerHealthMonitor {
private intervalHandle: ReturnType<typeof setInterval> | undefined;
private registry: HotRunnerRegistry | undefined;
private transports: Map<string, HotRunnerTransport> = new Map();
/**
* Start periodic health monitoring for all registered runners.
*/
startMonitoring(registry: HotRunnerRegistry, interval: number, transports: Map<string, HotRunnerTransport>): void {
if (this.intervalHandle) {
this.stopMonitoring();
}
this.registry = registry;
this.transports = transports;
OrchestratorLogger.log(`[HotRunner] Starting health monitoring (interval: ${interval}s)`);
this.intervalHandle = setInterval(() => {
this.runHealthChecks().catch((error: any) => {
OrchestratorLogger.logWarning(`[HotRunner] Health check cycle failed: ${error.message}`);
});
}, interval * 1000);
}
/**
* Stop periodic health monitoring.
*/
stopMonitoring(): void {
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = undefined;
OrchestratorLogger.log(`[HotRunner] Health monitoring stopped`);
}
}
/**
* Check health of a specific runner by ID. Returns true if healthy.
*/
async checkHealth(runnerId: string): Promise<boolean> {
if (!this.registry) {
return false;
}
const transport = this.transports.get(runnerId);
if (!transport) {
OrchestratorLogger.logWarning(`[HotRunner] No transport for runner ${runnerId}`);
this.registry.updateRunner(runnerId, {
state: 'unhealthy',
lastHealthCheck: new Date().toISOString(),
});
return false;
}
try {
const healthy = await transport.healthCheck();
if (healthy) {
const status = await transport.getStatus();
this.registry.updateRunner(runnerId, {
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: status.memoryUsageMB,
uptime: status.uptime,
libraryHash: status.libraryHash,
});
return true;
}
OrchestratorLogger.logWarning(`[HotRunner] Runner ${runnerId} health check returned false`);
this.registry.updateRunner(runnerId, {
state: 'unhealthy',
lastHealthCheck: new Date().toISOString(),
});
return false;
} catch (error: any) {
OrchestratorLogger.logWarning(`[HotRunner] Runner ${runnerId} health check failed: ${error.message}`);
this.registry.updateRunner(runnerId, {
state: 'unhealthy',
lastHealthCheck: new Date().toISOString(),
});
return false;
}
}
/**
* Mark an unhealthy runner for cleanup and disconnect its transport.
*/
async recycleUnhealthyRunner(runnerId: string): Promise<void> {
if (!this.registry) {
return;
}
OrchestratorLogger.log(`[HotRunner] Recycling unhealthy runner ${runnerId}`);
this.registry.updateRunner(runnerId, { state: 'stopping' });
const transport = this.transports.get(runnerId);
if (transport) {
try {
await transport.disconnect();
} catch (error: any) {
OrchestratorLogger.logWarning(`[HotRunner] Error disconnecting runner ${runnerId}: ${error.message}`);
}
this.transports.delete(runnerId);
}
this.registry.unregisterRunner(runnerId);
OrchestratorLogger.log(`[HotRunner] Runner ${runnerId} recycled and removed`);
}
/**
* Recycle a runner that has been idle longer than the maximum idle time.
*/
async recycleIdleRunner(runnerId: string, maxIdleTime: number): Promise<void> {
if (!this.registry) {
return;
}
const runner = this.registry.getRunner(runnerId);
if (!runner || runner.state !== 'idle') {
return;
}
const lastCheckTime = new Date(runner.lastHealthCheck).getTime();
const now = Date.now();
const idleSeconds = (now - lastCheckTime) / 1000;
if (idleSeconds >= maxIdleTime) {
OrchestratorLogger.log(
`[HotRunner] Runner ${runnerId} idle for ${Math.floor(idleSeconds)}s (max: ${maxIdleTime}s), recycling`,
);
await this.recycleUnhealthyRunner(runnerId);
}
}
/**
* Run health checks and idle-recycle checks for all registered runners.
*/
private async runHealthChecks(): Promise<void> {
if (!this.registry) {
return;
}
const runners = this.registry.listRunners();
for (const runner of runners) {
if (runner.state === 'stopping') {
continue;
}
const healthy = await this.checkHealth(runner.id);
if (!healthy && runner.state !== 'starting') {
await this.recycleUnhealthyRunner(runner.id);
continue;
}
// Check for idle timeout
const config = this.registry.getConfig(runner.id);
if (config && runner.state === 'idle') {
await this.recycleIdleRunner(runner.id, config.maxIdleTime);
}
// Check for max jobs before recycle
if (config && config.maxJobsBeforeRecycle > 0 && runner.jobsCompleted >= config.maxJobsBeforeRecycle) {
OrchestratorLogger.log(
`[HotRunner] Runner ${runner.id} reached max jobs (${runner.jobsCompleted}/${config.maxJobsBeforeRecycle}), recycling`,
);
await this.recycleUnhealthyRunner(runner.id);
}
}
}
/**
* Whether health monitoring is currently active.
*/
get isMonitoring(): boolean {
return this.intervalHandle !== undefined;
}
}

View File

@@ -0,0 +1,315 @@
import fs from 'node:fs';
import path from 'node:path';
import { customAlphabet } from 'nanoid';
import OrchestratorLogger from '../core/orchestrator-logger';
import { HotRunnerConfig, HotRunnerStatus } from './hot-runner-types';
const generateId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 12);
const PERSISTENCE_FILENAME = 'hot-runners.json';
const VALID_RUNNER_STATES: ReadonlySet<string> = new Set(['idle', 'busy', 'starting', 'stopping', 'unhealthy']);
export interface HotRunnerFilter {
platform?: string;
state?: string;
unityVersion?: string;
}
/**
* Validate that a restored runner entry has all required fields with correct types.
* Returns true if the entry is a valid HotRunnerStatus, false otherwise.
*/
function isValidRunnerStatus(entry: unknown): entry is HotRunnerStatus {
if (typeof entry !== 'object' || entry === null) {
return false;
}
const record = entry as Record<string, unknown>;
return (
typeof record.id === 'string' &&
record.id.length > 0 &&
typeof record.state === 'string' &&
VALID_RUNNER_STATES.has(record.state) &&
typeof record.unityVersion === 'string' &&
typeof record.platform === 'string' &&
typeof record.uptime === 'number' &&
typeof record.jobsCompleted === 'number' &&
typeof record.lastHealthCheck === 'string' &&
typeof record.memoryUsageMB === 'number'
);
}
/**
* Validate that a restored config entry has all required fields with correct types.
* Returns true if the entry is a valid HotRunnerConfig, false otherwise.
*/
function isValidRunnerConfig(entry: unknown): entry is HotRunnerConfig {
if (typeof entry !== 'object' || entry === null) {
return false;
}
const record = entry as Record<string, unknown>;
return (
typeof record.enabled === 'boolean' &&
typeof record.transport === 'string' &&
['websocket', 'grpc', 'named-pipe'].includes(record.transport) &&
typeof record.host === 'string' &&
typeof record.port === 'number' &&
typeof record.healthCheckInterval === 'number' &&
typeof record.maxIdleTime === 'number' &&
typeof record.maxJobsBeforeRecycle === 'number'
);
}
export class HotRunnerRegistry {
private runners: Map<string, HotRunnerStatus> = new Map();
private configs: Map<string, HotRunnerConfig> = new Map();
private persistencePath: string;
constructor(persistenceDirectory?: string) {
this.persistencePath = persistenceDirectory ? path.join(persistenceDirectory, PERSISTENCE_FILENAME) : '';
}
/**
* Register a new hot runner. Returns the generated runner ID.
*/
registerRunner(config: HotRunnerConfig): string {
const id = `hr-${generateId()}`;
const status: HotRunnerStatus = {
id,
state: 'starting',
unityVersion: config.unityVersion ?? 'unknown',
platform: config.platform ?? 'unknown',
uptime: 0,
jobsCompleted: 0,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 0,
};
this.runners.set(id, status);
this.configs.set(id, config);
OrchestratorLogger.log(`[HotRunner] Registered runner ${id} (${status.unityVersion}/${status.platform})`);
this.persist();
return id;
}
/**
* Remove a runner from the registry.
*/
unregisterRunner(id: string): void {
const existed = this.runners.delete(id);
this.configs.delete(id);
if (existed) {
OrchestratorLogger.log(`[HotRunner] Unregistered runner ${id}`);
this.persist();
}
}
/**
* Get a runner's current status by ID.
*/
getRunner(id: string): HotRunnerStatus | undefined {
return this.runners.get(id);
}
/**
* Get a runner's config by ID.
*/
getConfig(id: string): HotRunnerConfig | undefined {
return this.configs.get(id);
}
/**
* List all runners, optionally filtered by platform, state, or Unity version.
*/
listRunners(filter?: HotRunnerFilter): HotRunnerStatus[] {
let results = [...this.runners.values()];
if (filter?.platform) {
results = results.filter((runner) => runner.platform === filter.platform);
}
if (filter?.state) {
results = results.filter((runner) => runner.state === filter.state);
}
if (filter?.unityVersion) {
results = results.filter((runner) => runner.unityVersion === filter.unityVersion);
}
return results;
}
/**
* Find an idle runner matching the given Unity version and platform requirements.
*/
findAvailableRunner(requirements: { unityVersion: string; platform: string }): HotRunnerStatus | undefined {
return this.listRunners({
state: 'idle',
unityVersion: requirements.unityVersion,
platform: requirements.platform,
})[0];
}
/**
* Update a runner's status fields. Merges partial updates into existing status.
*/
updateRunner(id: string, update: Partial<HotRunnerStatus>): void {
const existing = this.runners.get(id);
if (!existing) {
return;
}
this.runners.set(id, { ...existing, ...update, id });
this.persist();
}
/**
* Get the total number of registered runners.
*/
get size(): number {
return this.runners.size;
}
/**
* Validate all runners in the registry and reset invalid ones to 'unhealthy'.
* Returns the number of runners that were repaired.
*/
validateAndRepair(): number {
let repaired = 0;
for (const [id, status] of this.runners) {
// Cast to unknown to bypass the type guard narrowing to 'never',
// since the Map is typed as HotRunnerStatus but entries may have
// been corrupted via direct deserialization or unsafe casts.
const entry = status as unknown as Record<string, unknown>;
if (!isValidRunnerStatus(entry)) {
OrchestratorLogger.logWarning(`[HotRunner] Runner ${id} has invalid state, marking as unhealthy`);
this.runners.set(id, {
id,
state: 'unhealthy',
unityVersion: typeof entry.unityVersion === 'string' ? entry.unityVersion : 'unknown',
platform: typeof entry.platform === 'string' ? entry.platform : 'unknown',
uptime: typeof entry.uptime === 'number' ? entry.uptime : 0,
jobsCompleted: typeof entry.jobsCompleted === 'number' ? entry.jobsCompleted : 0,
lastHealthCheck: typeof entry.lastHealthCheck === 'string' ? entry.lastHealthCheck : new Date().toISOString(),
memoryUsageMB: typeof entry.memoryUsageMB === 'number' ? entry.memoryUsageMB : 0,
});
repaired++;
}
}
if (repaired > 0) {
this.persist();
}
return repaired;
}
/**
* Persist current registry state to disk for crash recovery.
* Validates data before writing to prevent persisting corrupt state.
*/
private persist(): void {
if (!this.persistencePath) {
return;
}
try {
// Validate data before persisting
for (const [id, status] of this.runners) {
if (!isValidRunnerStatus(status)) {
OrchestratorLogger.logWarning(`[HotRunner] Skipping persistence -- runner ${id} has invalid state`);
return;
}
}
const data = {
runners: Object.fromEntries(this.runners),
configs: Object.fromEntries(this.configs),
};
const directory = path.dirname(this.persistencePath);
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true });
}
fs.writeFileSync(this.persistencePath, JSON.stringify(data, undefined, 2));
} catch (error: any) {
OrchestratorLogger.logWarning(`[HotRunner] Failed to persist registry: ${error.message}`);
}
}
/**
* Load registry state from disk. Returns the number of runners restored.
* Validates each restored entry and discards corrupt entries with warnings.
* If the persistence file itself is corrupt (invalid JSON), starts with
* an empty registry.
*/
loadFromDisk(): number {
if (!this.persistencePath || !fs.existsSync(this.persistencePath)) {
return 0;
}
let data: any;
try {
const raw = fs.readFileSync(this.persistencePath, 'utf8');
data = JSON.parse(raw);
} catch (error: any) {
OrchestratorLogger.logWarning(
`[HotRunner] Persistence file is corrupt, starting with empty registry: ${error.message}`,
);
return 0;
}
if (typeof data !== 'object' || data === null) {
OrchestratorLogger.logWarning('[HotRunner] Persistence file has invalid structure, starting with empty registry');
return 0;
}
let discarded = 0;
if (data.runners && typeof data.runners === 'object') {
for (const [id, status] of Object.entries(data.runners)) {
if (isValidRunnerStatus(status)) {
this.runners.set(id, status);
} else {
OrchestratorLogger.logWarning(`[HotRunner] Discarding invalid runner entry '${id}' from persistence file`);
discarded++;
}
}
}
if (data.configs && typeof data.configs === 'object') {
for (const [id, config] of Object.entries(data.configs)) {
// Only restore configs for runners that were successfully restored
if (this.runners.has(id)) {
if (isValidRunnerConfig(config)) {
this.configs.set(id, config);
} else {
OrchestratorLogger.logWarning(`[HotRunner] Discarding invalid config entry '${id}' from persistence file`);
}
}
}
}
if (discarded > 0) {
OrchestratorLogger.logWarning(`[HotRunner] Discarded ${discarded} invalid runner(s) from persistence file`);
}
OrchestratorLogger.log(`[HotRunner] Restored ${this.runners.size} runner(s) from disk`);
return this.runners.size;
}
}

View File

@@ -0,0 +1,166 @@
import OrchestratorLogger from '../core/orchestrator-logger';
import { HotRunnerRegistry } from './hot-runner-registry';
import { HotRunnerHealthMonitor } from './hot-runner-health-monitor';
import { HotRunnerDispatcher, OutputCallback } from './hot-runner-dispatcher';
import {
HotRunnerConfig,
HotRunnerJobRequest,
HotRunnerJobResult,
HotRunnerStatus,
HotRunnerTransport,
} from './hot-runner-types';
import BuildParameters from '../../../build-parameters';
export class HotRunnerService {
private registry: HotRunnerRegistry;
private healthMonitor: HotRunnerHealthMonitor;
private dispatcher: HotRunnerDispatcher;
private transports: Map<string, HotRunnerTransport> = new Map();
private config: HotRunnerConfig | undefined;
constructor(persistenceDirectory?: string) {
this.registry = new HotRunnerRegistry(persistenceDirectory);
this.healthMonitor = new HotRunnerHealthMonitor();
this.dispatcher = new HotRunnerDispatcher(this.transports);
}
/**
* Initialize the hot runner service: load persisted state, start health monitoring.
*/
async initialize(config: HotRunnerConfig): Promise<void> {
this.config = config;
OrchestratorLogger.log(
`[HotRunner] Initializing service (transport: ${config.transport}, ${config.host}:${config.port})`,
);
// Attempt to restore previously registered runners from disk
const restored = this.registry.loadFromDisk();
if (restored > 0) {
OrchestratorLogger.log(`[HotRunner] Restored ${restored} runner(s) from persistence`);
}
// Start health monitoring
this.healthMonitor.startMonitoring(this.registry, config.healthCheckInterval, this.transports);
OrchestratorLogger.log(`[HotRunner] Service initialized`);
}
/**
* Register a runner with a transport implementation.
* Returns the runner ID.
*/
registerRunner(config: HotRunnerConfig, transport: HotRunnerTransport): string {
const id = this.registry.registerRunner(config);
this.transports.set(id, transport);
return id;
}
/**
* Submit a build job to an available hot runner.
* Converts BuildParameters to a HotRunnerJobRequest and dispatches.
*/
async submitBuild(params: BuildParameters, onOutput?: OutputCallback): Promise<HotRunnerJobResult> {
const request: HotRunnerJobRequest = {
jobId: params.buildGuid || `build-${Date.now()}`,
buildMethod: params.buildMethod || undefined,
buildTarget: params.targetPlatform,
buildPath: params.buildPath,
customParameters: params.customParameters ? this.parseCustomParameters(params.customParameters) : undefined,
timeout: 30 * 60 * 1000, // 30 minutes default
};
OrchestratorLogger.log(`[HotRunner] Submitting build: ${request.jobId} (target: ${request.buildTarget})`);
return this.dispatcher.dispatchJob(request, this.registry, params.editorVersion, onOutput);
}
/**
* Submit a test job to an available hot runner.
* Converts BuildParameters and optional suite config to a test-mode HotRunnerJobRequest.
*/
async submitTest(
params: BuildParameters,
suiteConfig?: { testMode?: 'editmode' | 'playmode'; testSuitePath?: string },
onOutput?: OutputCallback,
): Promise<HotRunnerJobResult> {
const request: HotRunnerJobRequest = {
jobId: params.buildGuid || `test-${Date.now()}`,
buildTarget: params.targetPlatform,
customParameters: params.customParameters ? this.parseCustomParameters(params.customParameters) : undefined,
timeout: 30 * 60 * 1000, // 30 minutes default
testMode: suiteConfig?.testMode ?? 'editmode',
testSuitePath: suiteConfig?.testSuitePath,
};
OrchestratorLogger.log(`[HotRunner] Submitting test: ${request.jobId} (mode: ${request.testMode})`);
return this.dispatcher.dispatchJob(request, this.registry, params.editorVersion, onOutput);
}
/**
* Shut down the service: stop health monitoring, disconnect all transports,
* and unregister all runners.
*/
async shutdown(): Promise<void> {
OrchestratorLogger.log(`[HotRunner] Shutting down service`);
this.healthMonitor.stopMonitoring();
const disconnectPromises: Promise<void>[] = [];
for (const [id, transport] of this.transports.entries()) {
disconnectPromises.push(
transport.disconnect().catch((error: any) => {
OrchestratorLogger.logWarning(`[HotRunner] Error disconnecting runner ${id}: ${error.message}`);
}),
);
}
await Promise.all(disconnectPromises);
this.transports.clear();
OrchestratorLogger.log(`[HotRunner] Service shut down`);
}
/**
* Get the status of all registered runners.
*/
getStatus(): HotRunnerStatus[] {
return this.registry.listRunners();
}
/**
* Get the underlying registry (for testing or advanced use).
*/
getRegistry(): HotRunnerRegistry {
return this.registry;
}
/**
* Parse a space-separated custom parameters string into a key-value map.
* Handles `-key value` and `-key=value` formats.
*/
private parseCustomParameters(raw: string): Record<string, string> {
const result: Record<string, string> = {};
const parts = raw.trim().split(/\s+/);
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.startsWith('-')) {
const key = part.replace(/^-+/, '');
if (key.includes('=')) {
const [k, ...v] = key.split('=');
result[k] = v.join('=');
} else if (i + 1 < parts.length && !parts[i + 1].startsWith('-')) {
result[key] = parts[i + 1];
i++;
} else {
result[key] = 'true';
}
}
}
return result;
}
}

View File

@@ -0,0 +1,54 @@
export interface HotRunnerConfig {
enabled: boolean;
transport: 'websocket' | 'grpc' | 'named-pipe';
host: string;
port: number;
healthCheckInterval: number; // seconds
maxIdleTime: number; // seconds before recycling
maxJobsBeforeRecycle: number;
unityVersion?: string;
platform?: string;
}
export interface HotRunnerStatus {
id: string;
state: 'idle' | 'busy' | 'starting' | 'stopping' | 'unhealthy';
unityVersion: string;
platform: string;
currentJob?: string;
lastJobCompleted?: string;
uptime: number;
jobsCompleted: number;
lastHealthCheck: string;
memoryUsageMB: number;
libraryHash?: string;
}
export interface HotRunnerJobRequest {
jobId: string;
buildMethod?: string;
buildTarget: string;
buildPath?: string;
customParameters?: Record<string, string>;
timeout: number;
testMode?: 'editmode' | 'playmode';
testSuitePath?: string;
}
export interface HotRunnerJobResult {
jobId: string;
success: boolean;
exitCode: number;
duration: number;
output: string;
artifacts?: string[];
testResults?: string; // path to test result file
}
export interface HotRunnerTransport {
connect(config: HotRunnerConfig): Promise<void>;
disconnect(): Promise<void>;
sendJob(request: HotRunnerJobRequest): Promise<HotRunnerJobResult>;
getStatus(): Promise<HotRunnerStatus>;
healthCheck(): Promise<boolean>;
}

View File

@@ -0,0 +1,740 @@
import fs from 'node:fs';
import { HotRunnerRegistry } from './hot-runner-registry';
import { HotRunnerHealthMonitor } from './hot-runner-health-monitor';
import { HotRunnerDispatcher } from './hot-runner-dispatcher';
import { HotRunnerService } from './hot-runner-service';
import {
HotRunnerConfig,
HotRunnerJobRequest,
HotRunnerJobResult,
HotRunnerStatus,
HotRunnerTransport,
} from './hot-runner-types';
// Mock dependencies
jest.mock('node:fs');
jest.mock('../core/orchestrator-logger');
const mockFs = fs as jest.Mocked<typeof fs>;
function createMockConfig(overrides?: Partial<HotRunnerConfig>): HotRunnerConfig {
return {
enabled: true,
transport: 'websocket',
host: 'localhost',
port: 9090,
healthCheckInterval: 30,
maxIdleTime: 3600,
maxJobsBeforeRecycle: 100,
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
...overrides,
};
}
function createMockTransport(overrides?: Partial<HotRunnerTransport>): HotRunnerTransport {
return {
// eslint-disable-next-line unicorn/no-useless-undefined
connect: jest.fn().mockResolvedValue(undefined),
// eslint-disable-next-line unicorn/no-useless-undefined
disconnect: jest.fn().mockResolvedValue(undefined),
sendJob: jest.fn().mockResolvedValue({
jobId: 'test-job',
success: true,
exitCode: 0,
duration: 5000,
output: 'Build succeeded',
artifacts: ['build/output.exe'],
} as HotRunnerJobResult),
getStatus: jest.fn().mockResolvedValue({
id: 'mock-runner',
state: 'idle',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 3600,
jobsCompleted: 5,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 1024,
} as HotRunnerStatus),
healthCheck: jest.fn().mockResolvedValue(true),
...overrides,
};
}
function createMockJobRequest(overrides?: Partial<HotRunnerJobRequest>): HotRunnerJobRequest {
return {
jobId: 'job-001',
buildTarget: 'StandaloneWindows64',
timeout: 60000,
...overrides,
};
}
// --- Registry Tests ---
describe('HotRunnerRegistry', () => {
let registry: HotRunnerRegistry;
beforeEach(() => {
jest.clearAllMocks();
registry = new HotRunnerRegistry();
});
it('should register a runner and return an ID', () => {
const config = createMockConfig();
const id = registry.registerRunner(config);
expect(id).toMatch(/^hr-/);
expect(registry.size).toBe(1);
});
it('should retrieve a registered runner by ID', () => {
const config = createMockConfig();
const id = registry.registerRunner(config);
const runner = registry.getRunner(id);
expect(runner).toBeDefined();
expect(runner!.id).toBe(id);
expect(runner!.state).toBe('starting');
expect(runner!.unityVersion).toBe('2022.3.0f1');
expect(runner!.platform).toBe('StandaloneWindows64');
});
it('should return undefined for unknown runner ID', () => {
const runner = registry.getRunner('nonexistent');
expect(runner).toBeUndefined();
});
it('should unregister a runner', () => {
const id = registry.registerRunner(createMockConfig());
expect(registry.size).toBe(1);
registry.unregisterRunner(id);
expect(registry.size).toBe(0);
expect(registry.getRunner(id)).toBeUndefined();
});
it('should handle unregistering a nonexistent runner gracefully', () => {
registry.unregisterRunner('nonexistent');
expect(registry.size).toBe(0);
});
it('should list all runners without filter', () => {
registry.registerRunner(createMockConfig({ platform: 'StandaloneWindows64' }));
registry.registerRunner(createMockConfig({ platform: 'StandaloneLinux64' }));
registry.registerRunner(createMockConfig({ platform: 'StandaloneOSX' }));
const all = registry.listRunners();
expect(all).toHaveLength(3);
});
it('should filter runners by platform', () => {
registry.registerRunner(createMockConfig({ platform: 'StandaloneWindows64' }));
registry.registerRunner(createMockConfig({ platform: 'StandaloneLinux64' }));
registry.registerRunner(createMockConfig({ platform: 'StandaloneWindows64' }));
const windows = registry.listRunners({ platform: 'StandaloneWindows64' });
expect(windows).toHaveLength(2);
const linux = registry.listRunners({ platform: 'StandaloneLinux64' });
expect(linux).toHaveLength(1);
});
it('should filter runners by state', () => {
const id1 = registry.registerRunner(createMockConfig());
registry.registerRunner(createMockConfig());
registry.updateRunner(id1, { state: 'idle' });
// second runner remains in 'starting' state
const idle = registry.listRunners({ state: 'idle' });
expect(idle).toHaveLength(1);
expect(idle[0].id).toBe(id1);
});
it('should filter runners by Unity version', () => {
registry.registerRunner(createMockConfig({ unityVersion: '2022.3.0f1' }));
registry.registerRunner(createMockConfig({ unityVersion: '2023.1.0f1' }));
registry.registerRunner(createMockConfig({ unityVersion: '2022.3.0f1' }));
const v2022 = registry.listRunners({ unityVersion: '2022.3.0f1' });
expect(v2022).toHaveLength(2);
});
it('should find an available idle runner matching requirements', () => {
const id1 = registry.registerRunner(
createMockConfig({ unityVersion: '2022.3.0f1', platform: 'StandaloneWindows64' }),
);
registry.updateRunner(id1, { state: 'idle' });
const id2 = registry.registerRunner(
createMockConfig({ unityVersion: '2023.1.0f1', platform: 'StandaloneLinux64' }),
);
registry.updateRunner(id2, { state: 'idle' });
const found = registry.findAvailableRunner({
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
});
expect(found).toBeDefined();
expect(found!.id).toBe(id1);
});
it('should return undefined when no runner matches requirements', () => {
const id = registry.registerRunner(
createMockConfig({ unityVersion: '2022.3.0f1', platform: 'StandaloneWindows64' }),
);
registry.updateRunner(id, { state: 'idle' });
const found = registry.findAvailableRunner({
unityVersion: '2023.1.0f1',
platform: 'StandaloneLinux64',
});
expect(found).toBeUndefined();
});
it('should update runner status fields', () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle', memoryUsageMB: 2048 });
const runner = registry.getRunner(id);
expect(runner!.state).toBe('idle');
expect(runner!.memoryUsageMB).toBe(2048);
// ID should not be overridden by the update
expect(runner!.id).toBe(id);
});
it('should persist and load registry from disk', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.writeFileSync.mockImplementation(() => {});
mockFs.mkdirSync.mockImplementation(() => '' as any);
const id = persistenceRegistry.registerRunner(createMockConfig());
// Verify writeFileSync was called for persistence
expect(mockFs.writeFileSync).toHaveBeenCalled();
const writtenData = JSON.parse((mockFs.writeFileSync as jest.Mock).mock.calls[0][1] as string);
expect(writtenData.runners).toBeDefined();
expect(writtenData.runners[id]).toBeDefined();
});
it('should load runners from disk on loadFromDisk', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
const storedData = {
runners: {
'hr-restored': {
id: 'hr-restored',
state: 'idle',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 100,
jobsCompleted: 3,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 512,
},
},
configs: {
'hr-restored': createMockConfig(),
},
};
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(storedData));
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(1);
expect(persistenceRegistry.getRunner('hr-restored')).toBeDefined();
});
it('should discard invalid runner entries when loading from disk', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
const storedData = {
runners: {
'hr-valid': {
id: 'hr-valid',
state: 'idle',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 100,
jobsCompleted: 3,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 512,
},
'hr-invalid': {
// Missing required fields like state, unityVersion
id: 'hr-invalid',
},
'hr-bad-state': {
id: 'hr-bad-state',
state: 'nonexistent-state',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 0,
jobsCompleted: 0,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 0,
},
},
configs: {
'hr-valid': createMockConfig(),
'hr-invalid': createMockConfig(),
'hr-bad-state': createMockConfig(),
},
};
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(storedData));
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(1);
expect(persistenceRegistry.getRunner('hr-valid')).toBeDefined();
expect(persistenceRegistry.getRunner('hr-invalid')).toBeUndefined();
expect(persistenceRegistry.getRunner('hr-bad-state')).toBeUndefined();
});
it('should handle corrupt JSON persistence file gracefully', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue('{ invalid json !!!');
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(0);
expect(persistenceRegistry.size).toBe(0);
});
it('should handle persistence file with invalid top-level structure', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue('"just a string"');
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(0);
});
it('should handle persistence file with null runners', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue('{"runners": null, "configs": null}');
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(0);
});
it('should validate and repair invalid runners', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.writeFileSync.mockImplementation(() => {});
mockFs.mkdirSync.mockImplementation(() => '' as any);
// Register a valid runner first
const id = persistenceRegistry.registerRunner(createMockConfig());
persistenceRegistry.updateRunner(id, { state: 'idle' });
// Manually corrupt the runner's state by setting an invalid state
// (we access via the public API -- updateRunner with a cast)
persistenceRegistry.updateRunner(id, { state: 'invalid-state' as any });
const repaired = persistenceRegistry.validateAndRepair();
expect(repaired).toBe(1);
const runner = persistenceRegistry.getRunner(id);
expect(runner!.state).toBe('unhealthy');
});
it('should not discard configs for valid runners when loading from disk', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
const storedData = {
runners: {
'hr-valid': {
id: 'hr-valid',
state: 'idle',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 100,
jobsCompleted: 3,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 512,
},
},
configs: {
'hr-valid': createMockConfig(),
},
};
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(storedData));
persistenceRegistry.loadFromDisk();
expect(persistenceRegistry.getConfig('hr-valid')).toBeDefined();
});
});
// --- Health Monitor Tests ---
describe('HotRunnerHealthMonitor', () => {
let monitor: HotRunnerHealthMonitor;
let registry: HotRunnerRegistry;
let transports: Map<string, HotRunnerTransport>;
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
monitor = new HotRunnerHealthMonitor();
registry = new HotRunnerRegistry();
transports = new Map();
});
afterEach(() => {
monitor.stopMonitoring();
jest.useRealTimers();
});
it('should start and stop monitoring', () => {
monitor.startMonitoring(registry, 30, transports);
expect(monitor.isMonitoring).toBe(true);
monitor.stopMonitoring();
expect(monitor.isMonitoring).toBe(false);
});
it('should report healthy when transport health check passes', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport();
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
const healthy = await monitor.checkHealth(id);
expect(healthy).toBe(true);
expect(transport.healthCheck).toHaveBeenCalled();
});
it('should mark runner as unhealthy when health check fails', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
healthCheck: jest.fn().mockResolvedValue(false),
});
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
const healthy = await monitor.checkHealth(id);
expect(healthy).toBe(false);
const runner = registry.getRunner(id);
expect(runner!.state).toBe('unhealthy');
});
it('should mark runner as unhealthy when health check throws', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
healthCheck: jest.fn().mockRejectedValue(new Error('Connection refused')),
});
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
const healthy = await monitor.checkHealth(id);
expect(healthy).toBe(false);
});
it('should recycle unhealthy runner and remove from registry', async () => {
const id = registry.registerRunner(createMockConfig());
const transport = createMockTransport();
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
await monitor.recycleUnhealthyRunner(id);
expect(registry.getRunner(id)).toBeUndefined();
expect(transport.disconnect).toHaveBeenCalled();
expect(transports.has(id)).toBe(false);
});
it('should recycle idle runner when max idle time exceeded', async () => {
const id = registry.registerRunner(createMockConfig({ maxIdleTime: 60 }));
// Set lastHealthCheck to 120 seconds ago
const oldDate = new Date(Date.now() - 120 * 1000).toISOString();
registry.updateRunner(id, { state: 'idle', lastHealthCheck: oldDate });
const transport = createMockTransport();
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
await monitor.recycleIdleRunner(id, 60);
expect(registry.getRunner(id)).toBeUndefined();
});
it('should not recycle idle runner when within max idle time', async () => {
const id = registry.registerRunner(createMockConfig({ maxIdleTime: 3600 }));
registry.updateRunner(id, {
state: 'idle',
lastHealthCheck: new Date().toISOString(),
});
const transport = createMockTransport();
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
await monitor.recycleIdleRunner(id, 3600);
// Runner should still exist
expect(registry.getRunner(id)).toBeDefined();
});
it('should return false when no transport exists for runner', async () => {
const id = registry.registerRunner(createMockConfig());
// Do not set any transport for this runner
monitor.startMonitoring(registry, 30, transports);
const healthy = await monitor.checkHealth(id);
expect(healthy).toBe(false);
});
});
// --- Dispatcher Tests ---
describe('HotRunnerDispatcher', () => {
let registry: HotRunnerRegistry;
let transports: Map<string, HotRunnerTransport>;
let dispatcher: HotRunnerDispatcher;
beforeEach(() => {
jest.clearAllMocks();
registry = new HotRunnerRegistry();
transports = new Map();
dispatcher = new HotRunnerDispatcher(transports);
});
it('should dispatch a job to an available runner', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport();
transports.set(id, transport);
const request = createMockJobRequest();
const result = await dispatcher.dispatchJob(request, registry, '2022.3.0f1');
expect(result.success).toBe(true);
expect(result.exitCode).toBe(0);
expect(transport.sendJob).toHaveBeenCalledWith(request);
});
it('should mark runner as busy during job execution', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const statesDuringJob: string[] = [];
const transport = createMockTransport({
sendJob: jest.fn().mockImplementation(async () => {
const runner = registry.getRunner(id);
if (runner) statesDuringJob.push(runner.state);
return {
jobId: 'job-001',
success: true,
exitCode: 0,
duration: 1000,
output: 'ok',
};
}),
});
transports.set(id, transport);
await dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1');
expect(statesDuringJob).toContain('busy');
// After completion, should be idle again
const runner = registry.getRunner(id);
expect(runner!.state).toBe('idle');
});
it('should increment jobsCompleted after successful dispatch', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle', jobsCompleted: 5 });
const transport = createMockTransport();
transports.set(id, transport);
await dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1');
const runner = registry.getRunner(id);
expect(runner!.jobsCompleted).toBe(6);
});
it('should throw when no runner is available and wait times out', async () => {
// No runners registered at all
const request = createMockJobRequest({ timeout: 100 });
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/Timed out waiting/);
});
it('should throw when runner has no transport', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
// No transport set for this runner
const request = createMockJobRequest();
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/No transport available/);
});
it('should handle job failure and return runner to idle', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
sendJob: jest.fn().mockRejectedValue(new Error('Unity crashed')),
});
transports.set(id, transport);
await expect(dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1')).rejects.toThrow(
'Unity crashed',
);
// Runner should be back to idle despite failure
const runner = registry.getRunner(id);
expect(runner!.state).toBe('idle');
});
it('should handle job timeout', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
sendJob: jest.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 60000)), // never resolves within timeout
),
});
transports.set(id, transport);
const request = createMockJobRequest({ timeout: 50 });
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/timed out/);
});
it('should disconnect transport on job timeout', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
sendJob: jest.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 60000)), // never resolves within timeout
),
});
transports.set(id, transport);
const request = createMockJobRequest({ timeout: 50 });
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/timed out/);
// Transport should have been disconnected to clean up orphaned connection
expect(transport.disconnect).toHaveBeenCalled();
});
it('should call output callback with job output', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport();
transports.set(id, transport);
const outputCallback = jest.fn();
await dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1', outputCallback);
expect(outputCallback).toHaveBeenCalledWith('Build succeeded');
});
it('should wait for runner to become available', async () => {
const id = registry.registerRunner(createMockConfig());
// Runner starts in 'starting' state, not idle
const transport = createMockTransport();
transports.set(id, transport);
// Simulate runner becoming idle after a short delay
setTimeout(() => {
registry.updateRunner(id, { state: 'idle' });
}, 50);
const request = createMockJobRequest({ timeout: 5000 });
const result = await dispatcher.dispatchJob(request, registry, '2022.3.0f1');
expect(result.success).toBe(true);
});
});
// --- Service Integration Tests ---
describe('HotRunnerService', () => {
let service: HotRunnerService;
beforeEach(() => {
jest.clearAllMocks();
mockFs.existsSync.mockReturnValue(false);
service = new HotRunnerService();
});
afterEach(async () => {
await service.shutdown();
});
it('should initialize and shut down cleanly', async () => {
const config = createMockConfig();
await service.initialize(config);
const status = service.getStatus();
expect(status).toEqual([]);
await service.shutdown();
});
it('should register a runner with transport', async () => {
await service.initialize(createMockConfig());
const transport = createMockTransport();
const id = service.registerRunner(createMockConfig(), transport);
expect(id).toMatch(/^hr-/);
expect(service.getStatus()).toHaveLength(1);
});
it('should disconnect all transports on shutdown', async () => {
await service.initialize(createMockConfig());
const transport1 = createMockTransport();
const transport2 = createMockTransport();
service.registerRunner(createMockConfig(), transport1);
service.registerRunner(createMockConfig(), transport2);
await service.shutdown();
expect(transport1.disconnect).toHaveBeenCalled();
expect(transport2.disconnect).toHaveBeenCalled();
});
it('should expose the underlying registry', async () => {
await service.initialize(createMockConfig());
const registry = service.getRegistry();
expect(registry).toBeInstanceOf(HotRunnerRegistry);
});
});

View File

@@ -0,0 +1,11 @@
export { HotRunnerService } from './hot-runner-service';
export { HotRunnerRegistry } from './hot-runner-registry';
export { HotRunnerHealthMonitor } from './hot-runner-health-monitor';
export { HotRunnerDispatcher } from './hot-runner-dispatcher';
export type {
HotRunnerConfig,
HotRunnerStatus,
HotRunnerJobRequest,
HotRunnerJobResult,
HotRunnerTransport,
} from './hot-runner-types';