mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-05-31 22:06:16 -07:00
feat(orchestrator): enterprise feature support — CLI provider, submodule profiles, caching, LFS, hooks
Add generic enterprise-grade features to the orchestrator, enabling Unity projects with complex CI/CD pipelines to adopt game-ci/unity-builder with built-in support for: - CLI provider protocol: JSON-over-stdin/stdout bridge enabling providers in any language (Go, Python, Rust, shell) via the `providerExecutable` input - Submodule profiles: YAML-based selective submodule initialization with glob patterns and variant overlays (`submoduleProfilePath`, `submoduleVariantPath`) - Local build caching: Filesystem-based Library and LFS caching for local builds without external cache actions (`localCacheEnabled`, `localCacheRoot`) - Custom LFS transfer agents: Register external transfer agents like elastic-git-storage (`lfsTransferAgent`, `lfsTransferAgentArgs`, `lfsStoragePaths`) - Git hooks support: Detect and install lefthook/husky with configurable skip lists (`gitHooksEnabled`, `gitHooksSkipList`) Also removes all `orchestrator-develop` branch references, replacing with `main`. 13 new action inputs, 13 new files, 14 new CLI provider tests, 17 submodule tests, plus cache/LFS/hooks unit tests. All 452 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,7 @@ jobs:
|
||||
# AWS_STACK_NAME: game-ci-github-pipelines
|
||||
CHECKS_UPDATE: ${{ github.event.inputs.checksObject }}
|
||||
run: |
|
||||
git clone -b orchestrator-develop https://github.com/game-ci/unity-builder
|
||||
git clone -b main https://github.com/game-ci/unity-builder
|
||||
cd unity-builder
|
||||
yarn
|
||||
ls
|
||||
|
||||
74
action.yml
74
action.yml
@@ -182,8 +182,8 @@ inputs:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with the
|
||||
keys image, secrets (name, value object array), command line string)'
|
||||
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with
|
||||
the keys image, secrets (name, value object array), command line string)'
|
||||
awsStackName:
|
||||
default: 'game-ci'
|
||||
required: false
|
||||
@@ -279,6 +279,76 @@ inputs:
|
||||
description:
|
||||
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
|
||||
fixes.'
|
||||
submoduleProfilePath:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'Path to a YAML submodule profile file (relative to repo root). Defines which submodules to initialize (branch:
|
||||
main) or skip (branch: empty). See docs for format.'
|
||||
submoduleVariantPath:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'Path to a YAML variant overlay file that modifies the base submodule profile. Used for server or debug build
|
||||
variants.'
|
||||
submoduleToken:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'Git token for authenticating submodule clones. Falls back to gitPrivateToken or GITHUB_TOKEN if empty.'
|
||||
localCacheEnabled:
|
||||
required: false
|
||||
default: 'false'
|
||||
description:
|
||||
'Enable filesystem-based caching for local builds. Caches the Unity Library folder and optionally LFS objects
|
||||
between builds without requiring actions/cache.'
|
||||
localCacheRoot:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'Root directory for local build cache. Defaults to $RUNNER_TEMP/game-ci-cache or .game-ci/cache if RUNNER_TEMP is
|
||||
not set.'
|
||||
localCacheLibrary:
|
||||
required: false
|
||||
default: 'true'
|
||||
description: 'Cache the Unity Library folder for local builds. Only effective when localCacheEnabled is true.'
|
||||
localCacheLfs:
|
||||
required: false
|
||||
default: 'false'
|
||||
description: 'Cache Git LFS objects for local builds. Only effective when localCacheEnabled is true.'
|
||||
lfsTransferAgent:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'Path to a custom Git LFS transfer agent executable (e.g. elastic-git-storage). When set, the agent is registered
|
||||
via git config before LFS operations.'
|
||||
lfsTransferAgentArgs:
|
||||
required: false
|
||||
default: ''
|
||||
description: 'Additional arguments to pass to the custom LFS transfer agent.'
|
||||
lfsStoragePaths:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'Semicolon-separated list of storage paths for the custom LFS transfer agent. Interpretation depends on the agent
|
||||
(e.g. local paths, WebDAV URLs, rclone remotes).'
|
||||
gitHooksEnabled:
|
||||
required: false
|
||||
default: 'false'
|
||||
description:
|
||||
'Install and run git hooks (lefthook, husky, or native) during builds. When false (default), hooks are disabled
|
||||
for build performance.'
|
||||
gitHooksSkipList:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'Comma-separated list of hook names to skip even when gitHooksEnabled is true. Example: pre-push,post-merge'
|
||||
providerExecutable:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'Path to an external CLI executable that implements the provider protocol. Enables providers written in any
|
||||
language (Go, Python, Rust, shell). Uses JSON-over-stdin/stdout communication.'
|
||||
|
||||
outputs:
|
||||
volume:
|
||||
|
||||
1086
dist/index.js
generated
vendored
1086
dist/index.js
generated
vendored
File diff suppressed because it is too large
Load Diff
2
dist/index.js.map
generated
vendored
2
dist/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
77
src/index.ts
77
src/index.ts
@@ -1,4 +1,5 @@
|
||||
import * as core from '@actions/core';
|
||||
import path from 'node:path';
|
||||
import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output } from './model';
|
||||
import { Cli } from './model/cli/cli';
|
||||
import MacBuilder from './model/mac-builder';
|
||||
@@ -23,6 +24,70 @@ async function runMain() {
|
||||
|
||||
if (buildParameters.providerStrategy === 'local') {
|
||||
core.info('Building locally');
|
||||
|
||||
// Submodule profile initialization
|
||||
if (buildParameters.submoduleProfilePath) {
|
||||
const { SubmoduleProfileService } = await import(
|
||||
'./model/orchestrator/services/submodule/submodule-profile-service'
|
||||
);
|
||||
core.info('Initializing submodules from profile...');
|
||||
const plan = await SubmoduleProfileService.createInitPlan(
|
||||
buildParameters.submoduleProfilePath,
|
||||
buildParameters.submoduleVariantPath,
|
||||
workspace,
|
||||
);
|
||||
await SubmoduleProfileService.execute(
|
||||
plan,
|
||||
workspace,
|
||||
buildParameters.submoduleToken || buildParameters.gitPrivateToken,
|
||||
);
|
||||
}
|
||||
|
||||
// Configure custom LFS transfer agent
|
||||
if (buildParameters.lfsTransferAgent) {
|
||||
const { LfsAgentService } = await import('./model/orchestrator/services/lfs/lfs-agent-service');
|
||||
core.info('Configuring custom LFS transfer agent...');
|
||||
await LfsAgentService.configure(
|
||||
buildParameters.lfsTransferAgent,
|
||||
buildParameters.lfsTransferAgentArgs,
|
||||
buildParameters.lfsStoragePaths ? buildParameters.lfsStoragePaths.split(';') : [],
|
||||
workspace,
|
||||
);
|
||||
}
|
||||
|
||||
// Local build caching - restore
|
||||
let cacheRoot = '';
|
||||
let cacheKey = '';
|
||||
if (buildParameters.localCacheEnabled) {
|
||||
const { LocalCacheService } = await import('./model/orchestrator/services/cache/local-cache-service');
|
||||
cacheRoot = LocalCacheService.resolveCacheRoot(buildParameters);
|
||||
cacheKey = LocalCacheService.generateCacheKey(
|
||||
buildParameters.targetPlatform,
|
||||
buildParameters.editorVersion,
|
||||
buildParameters.branch || '',
|
||||
);
|
||||
if (buildParameters.localCacheLfs) {
|
||||
await LocalCacheService.restoreLfsCache(workspace, cacheRoot, cacheKey);
|
||||
}
|
||||
if (buildParameters.localCacheLibrary) {
|
||||
const projectFullPath = path.join(workspace, buildParameters.projectPath);
|
||||
await LocalCacheService.restoreLibraryCache(projectFullPath, cacheRoot, cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Git hooks
|
||||
if (buildParameters.gitHooksEnabled) {
|
||||
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
|
||||
await GitHooksService.installHooks(workspace);
|
||||
if (buildParameters.gitHooksSkipList) {
|
||||
const env = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
|
||||
Object.assign(process.env, env);
|
||||
}
|
||||
} else {
|
||||
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
|
||||
await GitHooksService.disableHooks(workspace);
|
||||
}
|
||||
|
||||
await PlatformSetup.setup(buildParameters, actionFolder);
|
||||
exitCode =
|
||||
process.platform === 'darwin'
|
||||
@@ -32,6 +97,18 @@ async function runMain() {
|
||||
actionFolder,
|
||||
...buildParameters,
|
||||
});
|
||||
|
||||
// Local build caching - save
|
||||
if (buildParameters.localCacheEnabled) {
|
||||
const { LocalCacheService } = await import('./model/orchestrator/services/cache/local-cache-service');
|
||||
if (buildParameters.localCacheLibrary) {
|
||||
const projectFullPath = path.join(workspace, buildParameters.projectPath);
|
||||
await LocalCacheService.saveLibraryCache(projectFullPath, cacheRoot, cacheKey);
|
||||
}
|
||||
if (buildParameters.localCacheLfs) {
|
||||
await LocalCacheService.saveLfsCache(workspace, cacheRoot, cacheKey);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await Orchestrator.run(buildParameters, baseImage.toString());
|
||||
exitCode = 0;
|
||||
|
||||
@@ -106,6 +106,19 @@ class BuildParameters {
|
||||
public cacheUnityInstallationOnMac!: boolean;
|
||||
public unityHubVersionOnMac!: string;
|
||||
public dockerWorkspacePath!: string;
|
||||
public submoduleProfilePath!: string;
|
||||
public submoduleVariantPath!: string;
|
||||
public submoduleToken!: string;
|
||||
public localCacheEnabled!: boolean;
|
||||
public localCacheRoot!: string;
|
||||
public localCacheLibrary!: boolean;
|
||||
public localCacheLfs!: boolean;
|
||||
public lfsTransferAgent!: string;
|
||||
public lfsTransferAgentArgs!: string;
|
||||
public lfsStoragePaths!: string;
|
||||
public gitHooksEnabled!: boolean;
|
||||
public gitHooksSkipList!: string;
|
||||
public providerExecutable!: string;
|
||||
|
||||
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
|
||||
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
|
||||
@@ -242,6 +255,19 @@ class BuildParameters {
|
||||
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
|
||||
unityHubVersionOnMac: Input.unityHubVersionOnMac,
|
||||
dockerWorkspacePath: Input.dockerWorkspacePath,
|
||||
submoduleProfilePath: Input.submoduleProfilePath,
|
||||
submoduleVariantPath: Input.submoduleVariantPath,
|
||||
submoduleToken: Input.submoduleToken,
|
||||
localCacheEnabled: Input.localCacheEnabled,
|
||||
localCacheRoot: Input.localCacheRoot,
|
||||
localCacheLibrary: Input.localCacheLibrary,
|
||||
localCacheLfs: Input.localCacheLfs,
|
||||
lfsTransferAgent: Input.lfsTransferAgent,
|
||||
lfsTransferAgentArgs: Input.lfsTransferAgentArgs,
|
||||
lfsStoragePaths: Input.lfsStoragePaths,
|
||||
gitHooksEnabled: Input.gitHooksEnabled,
|
||||
gitHooksSkipList: Input.gitHooksSkipList,
|
||||
providerExecutable: Input.providerExecutable,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import OrchestratorOptionsReader from '../orchestrator/options/orchestrator-opti
|
||||
import GitHub from '../github';
|
||||
import { OptionValues } from 'commander';
|
||||
import { InputKey } from '../input';
|
||||
import { SubmoduleProfileService } from '../orchestrator/services/submodule/submodule-profile-service';
|
||||
import { LfsAgentService } from '../orchestrator/services/lfs/lfs-agent-service';
|
||||
|
||||
export class Cli {
|
||||
public static options: OptionValues | undefined;
|
||||
@@ -53,6 +55,11 @@ export class Cli {
|
||||
program.option('--artifactName <artifactName>', 'caching artifact name');
|
||||
program.option('--select <select>', 'select a particular resource');
|
||||
program.option('--logFile <logFile>', 'output to log file (log stream only)');
|
||||
program.option('--profilePath <profilePath>', 'path to submodule profile YAML');
|
||||
program.option('--variantPath <variantPath>', 'path to submodule variant YAML');
|
||||
program.option('--agentPath <agentPath>', 'path to custom LFS transfer agent');
|
||||
program.option('--agentArgs <agentArgs>', 'arguments for custom LFS transfer agent');
|
||||
program.option('--storagePaths <storagePaths>', 'semicolon-separated storage paths for LFS agent');
|
||||
program.parse(process.argv);
|
||||
Cli.options = program.opts();
|
||||
|
||||
@@ -172,4 +179,26 @@ export class Cli {
|
||||
|
||||
return await Orchestrator.Provider.watchWorkflow();
|
||||
}
|
||||
|
||||
@CliFunction(`submodule-init`, `initializes submodules from a YAML profile`)
|
||||
public static async SubmoduleInit(): Promise<void> {
|
||||
const profilePath = Cli.options!['profilePath'];
|
||||
const variantPath = Cli.options!['variantPath'] || '';
|
||||
if (!profilePath) {
|
||||
throw new Error('--profilePath is required for submodule-init');
|
||||
}
|
||||
const plan = await SubmoduleProfileService.createInitPlan(profilePath, variantPath, process.cwd());
|
||||
await SubmoduleProfileService.execute(plan, process.cwd());
|
||||
}
|
||||
|
||||
@CliFunction(`lfs-agent-configure`, `configures a custom LFS transfer agent`)
|
||||
public static async LfsAgentConfigure(): Promise<void> {
|
||||
const agentPath = Cli.options!['agentPath'];
|
||||
if (!agentPath) {
|
||||
throw new Error('--agentPath is required for lfs-agent-configure');
|
||||
}
|
||||
const agentArgs = Cli.options!['agentArgs'] || '';
|
||||
const storagePaths = (Cli.options!['storagePaths'] || '').split(';').filter(Boolean);
|
||||
await LfsAgentService.configure(agentPath, agentArgs, storagePaths, process.cwd());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +282,58 @@ class Input {
|
||||
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
|
||||
}
|
||||
|
||||
static get submoduleProfilePath(): string {
|
||||
return Input.getInput('submoduleProfilePath') ?? '';
|
||||
}
|
||||
|
||||
static get submoduleVariantPath(): string {
|
||||
return Input.getInput('submoduleVariantPath') ?? '';
|
||||
}
|
||||
|
||||
static get submoduleToken(): string {
|
||||
return Input.getInput('submoduleToken') ?? '';
|
||||
}
|
||||
|
||||
static get localCacheEnabled(): boolean {
|
||||
return (Input.getInput('localCacheEnabled') ?? 'false') === 'true';
|
||||
}
|
||||
|
||||
static get localCacheRoot(): string {
|
||||
return Input.getInput('localCacheRoot') ?? '';
|
||||
}
|
||||
|
||||
static get localCacheLibrary(): boolean {
|
||||
return (Input.getInput('localCacheLibrary') ?? 'true') === 'true';
|
||||
}
|
||||
|
||||
static get localCacheLfs(): boolean {
|
||||
return (Input.getInput('localCacheLfs') ?? 'false') === 'true';
|
||||
}
|
||||
|
||||
static get lfsTransferAgent(): string {
|
||||
return Input.getInput('lfsTransferAgent') ?? '';
|
||||
}
|
||||
|
||||
static get lfsTransferAgentArgs(): string {
|
||||
return Input.getInput('lfsTransferAgentArgs') ?? '';
|
||||
}
|
||||
|
||||
static get lfsStoragePaths(): string {
|
||||
return Input.getInput('lfsStoragePaths') ?? '';
|
||||
}
|
||||
|
||||
static get gitHooksEnabled(): boolean {
|
||||
return (Input.getInput('gitHooksEnabled') ?? 'false') === 'true';
|
||||
}
|
||||
|
||||
static get gitHooksSkipList(): string {
|
||||
return Input.getInput('gitHooksSkipList') ?? '';
|
||||
}
|
||||
|
||||
static get providerExecutable(): string {
|
||||
return Input.getInput('providerExecutable') ?? '';
|
||||
}
|
||||
|
||||
public static ToEnvVarFormat(input: string) {
|
||||
if (input.toUpperCase() === input) {
|
||||
return input;
|
||||
|
||||
@@ -129,6 +129,17 @@ class Orchestrator {
|
||||
// Store whether we should validate AWS templates (used by aws-local mode)
|
||||
Orchestrator.validateAwsTemplates = validateAwsTemplates;
|
||||
|
||||
// Check for CLI provider executable
|
||||
if (Orchestrator.buildParameters.providerExecutable) {
|
||||
const { default: CliProvider } = await import('./providers/cli');
|
||||
Orchestrator.Provider = new CliProvider(
|
||||
Orchestrator.buildParameters.providerExecutable,
|
||||
Orchestrator.buildParameters,
|
||||
);
|
||||
OrchestratorLogger.log(`Using CLI provider executable: ${Orchestrator.buildParameters.providerExecutable}`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case 'k8s':
|
||||
Orchestrator.Provider = new Kubernetes(Orchestrator.buildParameters);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface CliProviderRequest {
|
||||
command: CliProviderSubcommand;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CliProviderResponse {
|
||||
success: boolean;
|
||||
result?: any;
|
||||
error?: string;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export type CliProviderSubcommand =
|
||||
| 'setup-workflow'
|
||||
| 'cleanup-workflow'
|
||||
| 'run-task'
|
||||
| 'garbage-collect'
|
||||
| 'list-resources'
|
||||
| 'list-workflow'
|
||||
| 'watch-workflow';
|
||||
226
src/model/orchestrator/providers/cli/cli-provider.test.ts
Normal file
226
src/model/orchestrator/providers/cli/cli-provider.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { ProviderLoader } from '../provider-loader';
|
||||
|
||||
// Mock child_process
|
||||
jest.mock('child_process', () => ({
|
||||
spawn: jest.fn(),
|
||||
exec: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock @actions/core to prevent GitHub Actions API calls
|
||||
jest.mock('@actions/core', () => ({
|
||||
info: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
error: jest.fn(),
|
||||
setOutput: jest.fn(),
|
||||
getInput: jest.fn(() => ''),
|
||||
}));
|
||||
|
||||
// Mock provider-git-manager (required by provider-loader)
|
||||
jest.mock('../provider-git-manager');
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import CliProvider from './cli-provider';
|
||||
|
||||
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
|
||||
|
||||
/**
|
||||
* Creates a mock child process with stdin, stdout, stderr as EventEmitters.
|
||||
*/
|
||||
function createMockChildProcess() {
|
||||
const child = new EventEmitter() as any;
|
||||
child.stdin = { write: jest.fn(), end: jest.fn() };
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = jest.fn();
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
describe('CliProvider', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('validates that executable path is non-empty', () => {
|
||||
expect(() => new CliProvider('', {} as any)).toThrow('executablePath must be a non-empty string');
|
||||
});
|
||||
|
||||
it('validates that executable path is not just whitespace', () => {
|
||||
expect(() => new CliProvider(' ', {} as any)).toThrow('executablePath must be a non-empty string');
|
||||
});
|
||||
|
||||
it('accepts a valid executable path', () => {
|
||||
const provider = new CliProvider('/usr/bin/my-provider', {} as any);
|
||||
expect(provider).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('request serialization', () => {
|
||||
it('sends JSON request to stdin with correct command and params', async () => {
|
||||
const child = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||
const promise = provider.listResources();
|
||||
|
||||
// Simulate successful response
|
||||
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: [] }) + '\n'));
|
||||
child.emit('close', 0);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(child.stdin.write).toHaveBeenCalledTimes(1);
|
||||
const writtenData = child.stdin.write.mock.calls[0][0];
|
||||
const parsed = JSON.parse(writtenData);
|
||||
expect(parsed.command).toBe('list-resources');
|
||||
expect(parsed.params).toEqual({});
|
||||
expect(child.stdin.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serializes setupWorkflow params correctly', async () => {
|
||||
const child = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||
const promise = provider.setupWorkflow('guid-123', { editorVersion: '2022.3' } as any, 'main', []);
|
||||
|
||||
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: {} }) + '\n'));
|
||||
child.emit('close', 0);
|
||||
|
||||
await promise;
|
||||
|
||||
const writtenData = child.stdin.write.mock.calls[0][0];
|
||||
const parsed = JSON.parse(writtenData);
|
||||
expect(parsed.command).toBe('setup-workflow');
|
||||
expect(parsed.params.buildGuid).toBe('guid-123');
|
||||
expect(parsed.params.branchName).toBe('main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('response parsing', () => {
|
||||
it('resolves on successful JSON response', async () => {
|
||||
const child = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||
const promise = provider.listResources();
|
||||
|
||||
const resources = [{ Name: 'resource-1' }, { Name: 'resource-2' }];
|
||||
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: resources }) + '\n'));
|
||||
child.emit('close', 0);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toEqual(resources);
|
||||
});
|
||||
|
||||
it('rejects on error JSON response', async () => {
|
||||
const child = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||
const promise = provider.garbageCollect('', false, 30, false, false);
|
||||
|
||||
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: false, error: 'something went wrong' }) + '\n'));
|
||||
child.emit('close', 1);
|
||||
|
||||
await expect(promise).rejects.toThrow('something went wrong');
|
||||
});
|
||||
|
||||
it('rejects when process exits with non-zero code and no JSON response', async () => {
|
||||
const child = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||
const promise = provider.listWorkflow();
|
||||
|
||||
child.stderr.emit('data', Buffer.from('segfault\n'));
|
||||
child.emit('close', 139);
|
||||
|
||||
await expect(promise).rejects.toThrow('exited with code 139');
|
||||
});
|
||||
|
||||
it('resolves when process exits with code 0 and no JSON response', async () => {
|
||||
const child = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||
const promise = provider.listResources();
|
||||
|
||||
child.stdout.emit('data', Buffer.from('some plain text output\n'));
|
||||
child.emit('close', 0);
|
||||
|
||||
const result = await promise;
|
||||
// listResources falls back to empty array when result is missing
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects on spawn error', async () => {
|
||||
const child = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const provider = new CliProvider('/nonexistent/path', {} as any);
|
||||
const promise = provider.listResources();
|
||||
|
||||
child.emit('error', new Error('ENOENT'));
|
||||
|
||||
await expect(promise).rejects.toThrow('failed to spawn executable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runTaskInWorkflow', () => {
|
||||
it('forwards non-JSON stdout lines as build output and returns final response', async () => {
|
||||
const child = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
|
||||
|
||||
// Simulate build output followed by JSON response
|
||||
child.stdout.emit('data', Buffer.from('Building project...\nCompiling scripts...\n'));
|
||||
child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: 'Build succeeded' }) + '\n'));
|
||||
child.emit('close', 0);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('Build succeeded');
|
||||
});
|
||||
|
||||
it('rejects on run-task failure', async () => {
|
||||
const child = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
|
||||
|
||||
child.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(JSON.stringify({ success: false, error: 'Build failed: compilation errors' }) + '\n'),
|
||||
);
|
||||
child.emit('close', 1);
|
||||
|
||||
await expect(promise).rejects.toThrow('Build failed: compilation errors');
|
||||
});
|
||||
|
||||
it('returns collected output lines when no JSON response and exit code 0', async () => {
|
||||
const child = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
|
||||
const provider = new CliProvider('/path/to/exe', {} as any);
|
||||
const promise = provider.runTaskInWorkflow('guid', 'image', 'cmd', '/mnt', '/work', [], []);
|
||||
|
||||
child.stdout.emit('data', Buffer.from('line 1\nline 2\n'));
|
||||
child.emit('close', 0);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('line 1\nline 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('available providers list', () => {
|
||||
it('includes cli in the available providers', () => {
|
||||
const providers = ProviderLoader.getAvailableProviders();
|
||||
expect(providers).toContain('cli');
|
||||
});
|
||||
});
|
||||
});
|
||||
344
src/model/orchestrator/providers/cli/cli-provider.ts
Normal file
344
src/model/orchestrator/providers/cli/cli-provider.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { ProviderInterface } from '../provider-interface';
|
||||
import BuildParameters from '../../../build-parameters';
|
||||
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
|
||||
import OrchestratorSecret from '../../options/orchestrator-secret';
|
||||
import { ProviderResource } from '../provider-resource';
|
||||
import { ProviderWorkflow } from '../provider-workflow';
|
||||
import OrchestratorLogger from '../../services/core/orchestrator-logger';
|
||||
import { CliProviderRequest, CliProviderResponse, CliProviderSubcommand } from './cli-provider-protocol';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 300_000; // 300 seconds
|
||||
|
||||
class CliProvider implements ProviderInterface {
|
||||
private readonly executablePath: string;
|
||||
private readonly buildParameters: BuildParameters;
|
||||
|
||||
constructor(executablePath: string, buildParameters: BuildParameters) {
|
||||
if (!executablePath || executablePath.trim() === '') {
|
||||
throw new Error('CliProvider: executablePath must be a non-empty string');
|
||||
}
|
||||
this.executablePath = executablePath;
|
||||
this.buildParameters = buildParameters;
|
||||
}
|
||||
|
||||
async setupWorkflow(
|
||||
buildGuid: string,
|
||||
buildParameters: BuildParameters,
|
||||
branchName: string,
|
||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
||||
): Promise<any> {
|
||||
const response = await this.execute('setup-workflow', {
|
||||
buildGuid,
|
||||
buildParameters,
|
||||
branchName,
|
||||
defaultSecretsArray,
|
||||
});
|
||||
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async cleanupWorkflow(
|
||||
buildParameters: BuildParameters,
|
||||
branchName: string,
|
||||
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
|
||||
): Promise<any> {
|
||||
const response = await this.execute('cleanup-workflow', {
|
||||
buildParameters,
|
||||
branchName,
|
||||
defaultSecretsArray,
|
||||
});
|
||||
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async runTaskInWorkflow(
|
||||
buildGuid: string,
|
||||
image: string,
|
||||
commands: string,
|
||||
mountdir: string,
|
||||
workingdir: string,
|
||||
environment: OrchestratorEnvironmentVariable[],
|
||||
secrets: OrchestratorSecret[],
|
||||
): Promise<string> {
|
||||
const request: CliProviderRequest = {
|
||||
command: 'run-task',
|
||||
params: {
|
||||
buildGuid,
|
||||
image,
|
||||
commands,
|
||||
mountdir,
|
||||
workingdir,
|
||||
environment,
|
||||
secrets,
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(this.executablePath, ['run-task'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
let lastJsonResponse: CliProviderResponse | undefined;
|
||||
const outputLines: string[] = [];
|
||||
let stderrOutput = '';
|
||||
|
||||
child.stdin.write(JSON.stringify(request));
|
||||
child.stdin.end();
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Try to parse as JSON response
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
|
||||
lastJsonResponse = parsed as CliProviderResponse;
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — treat as build output
|
||||
}
|
||||
|
||||
// Forward non-JSON lines as real-time build output
|
||||
OrchestratorLogger.log(trimmed);
|
||||
outputLines.push(trimmed);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stderrOutput += text;
|
||||
// Forward stderr to logger
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
OrchestratorLogger.log(`[cli-provider stderr] ${trimmed}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (error: Error) => {
|
||||
reject(new Error(`CliProvider: failed to spawn executable '${this.executablePath}': ${error.message}`));
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
if (lastJsonResponse) {
|
||||
if (lastJsonResponse.success) {
|
||||
resolve(lastJsonResponse.output || outputLines.join('\n'));
|
||||
} else {
|
||||
reject(
|
||||
new Error(`CliProvider run-task failed: ${lastJsonResponse.error || 'Unknown error from CLI provider'}`),
|
||||
);
|
||||
}
|
||||
} else if (code === 0) {
|
||||
resolve(outputLines.join('\n'));
|
||||
} else {
|
||||
reject(
|
||||
new Error(`CliProvider run-task exited with code ${code}${stderrOutput ? ': ' + stderrOutput.trim() : ''}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async garbageCollect(
|
||||
filter: string,
|
||||
previewOnly: boolean,
|
||||
olderThan: Number,
|
||||
fullCache: boolean,
|
||||
baseDependencies: boolean,
|
||||
): Promise<string> {
|
||||
const response = await this.execute('garbage-collect', {
|
||||
filter,
|
||||
previewOnly,
|
||||
olderThan,
|
||||
fullCache,
|
||||
baseDependencies,
|
||||
});
|
||||
|
||||
return response.output || '';
|
||||
}
|
||||
|
||||
async listResources(): Promise<ProviderResource[]> {
|
||||
const response = await this.execute('list-resources', {});
|
||||
|
||||
return (response.result as ProviderResource[]) || [];
|
||||
}
|
||||
|
||||
async listWorkflow(): Promise<ProviderWorkflow[]> {
|
||||
const response = await this.execute('list-workflow', {});
|
||||
|
||||
return (response.result as ProviderWorkflow[]) || [];
|
||||
}
|
||||
|
||||
async watchWorkflow(): Promise<string> {
|
||||
const request: CliProviderRequest = {
|
||||
command: 'watch-workflow',
|
||||
params: {},
|
||||
};
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(this.executablePath, ['watch-workflow'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
let lastJsonResponse: CliProviderResponse | undefined;
|
||||
const outputLines: string[] = [];
|
||||
|
||||
child.stdin.write(JSON.stringify(request));
|
||||
child.stdin.end();
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
|
||||
lastJsonResponse = parsed as CliProviderResponse;
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON
|
||||
}
|
||||
|
||||
OrchestratorLogger.log(trimmed);
|
||||
outputLines.push(trimmed);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
for (const line of data.toString().split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
OrchestratorLogger.log(`[cli-provider stderr] ${trimmed}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (error: Error) => {
|
||||
reject(new Error(`CliProvider: failed to spawn executable '${this.executablePath}': ${error.message}`));
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
if (lastJsonResponse) {
|
||||
if (lastJsonResponse.success) {
|
||||
resolve(lastJsonResponse.output || outputLines.join('\n'));
|
||||
} else {
|
||||
reject(new Error(`CliProvider watch-workflow failed: ${lastJsonResponse.error || 'Unknown error'}`));
|
||||
}
|
||||
} else if (code === 0) {
|
||||
resolve(outputLines.join('\n'));
|
||||
} else {
|
||||
reject(new Error(`CliProvider watch-workflow exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a CLI provider subcommand with a default timeout.
|
||||
* Used for all methods except runTaskInWorkflow and watchWorkflow (which have no timeout).
|
||||
*/
|
||||
private execute(
|
||||
command: CliProviderSubcommand,
|
||||
params: Record<string, any>,
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<CliProviderResponse> {
|
||||
const request: CliProviderRequest = { command, params };
|
||||
|
||||
return new Promise<CliProviderResponse>((resolve, reject) => {
|
||||
const child = spawn(this.executablePath, [command], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
let stdoutData = '';
|
||||
let stderrData = '';
|
||||
let timedOut = false;
|
||||
|
||||
// Set up timeout
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error(`CliProvider: command '${command}' timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdin.write(JSON.stringify(request));
|
||||
child.stdin.end();
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
stdoutData += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stderrData += text;
|
||||
// Forward stderr to logger
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
OrchestratorLogger.log(`[cli-provider stderr] ${trimmed}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (error: Error) => {
|
||||
clearTimeout(timer);
|
||||
if (!timedOut) {
|
||||
reject(new Error(`CliProvider: failed to spawn executable '${this.executablePath}': ${error.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) return;
|
||||
|
||||
// Find the last JSON line in stdout
|
||||
const lines = stdoutData.split('\n').filter((l) => l.trim());
|
||||
let response: CliProviderResponse | undefined;
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const parsed = JSON.parse(lines[i].trim());
|
||||
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
|
||||
response = parsed as CliProviderResponse;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
|
||||
if (response) {
|
||||
if (response.success) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`CliProvider ${command} failed: ${response.error || 'Unknown error from CLI provider'}`));
|
||||
}
|
||||
} else if (code === 0) {
|
||||
// No JSON response but exit code 0 — treat as success with raw output
|
||||
resolve({ success: true, output: stdoutData.trim() });
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`CliProvider ${command} exited with code ${code}` +
|
||||
(stderrData ? `: ${stderrData.trim()}` : '') +
|
||||
(!stderrData && stdoutData ? `: ${stdoutData.trim()}` : ''),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default CliProvider;
|
||||
1
src/model/orchestrator/providers/cli/index.ts
Normal file
1
src/model/orchestrator/providers/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './cli-provider';
|
||||
@@ -58,6 +58,7 @@ export default async function loadProvider(
|
||||
const providerModuleMap: Record<string, string> = {
|
||||
aws: './aws',
|
||||
k8s: './k8s',
|
||||
cli: './cli',
|
||||
test: './test',
|
||||
'local-docker': './docker',
|
||||
'local-system': './local',
|
||||
@@ -136,7 +137,7 @@ export class ProviderLoader {
|
||||
* @returns string[] - Array of available provider names
|
||||
*/
|
||||
static getAvailableProviders(): string[] {
|
||||
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local'];
|
||||
return ['aws', 'k8s', 'cli', 'test', 'local-docker', 'local-system', 'local'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -237,6 +237,23 @@ export class RemoteClient {
|
||||
`mkdir -p ${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.cacheFolderForCacheKeyFull)}`,
|
||||
);
|
||||
await RemoteClient.cloneRepoWithoutLFSFiles();
|
||||
|
||||
// Initialize submodules from profile if configured
|
||||
if (Orchestrator.buildParameters.submoduleProfilePath) {
|
||||
const { SubmoduleProfileService } = await import('../services/submodule/submodule-profile-service');
|
||||
RemoteClientLogger.log('Initializing submodules from profile...');
|
||||
const plan = await SubmoduleProfileService.createInitPlan(
|
||||
Orchestrator.buildParameters.submoduleProfilePath,
|
||||
Orchestrator.buildParameters.submoduleVariantPath,
|
||||
OrchestratorFolders.repoPathAbsolute,
|
||||
);
|
||||
await SubmoduleProfileService.execute(
|
||||
plan,
|
||||
OrchestratorFolders.repoPathAbsolute,
|
||||
Orchestrator.buildParameters.submoduleToken || Orchestrator.buildParameters.gitPrivateToken,
|
||||
);
|
||||
}
|
||||
|
||||
await RemoteClient.sizeOfFolder(
|
||||
'repo before lfs cache pull',
|
||||
OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.repoPathAbsolute),
|
||||
@@ -251,6 +268,19 @@ export class RemoteClient {
|
||||
`${lfsHashes.lfsGuidSum}`,
|
||||
);
|
||||
await RemoteClient.sizeOfFolder('repo after lfs cache pull', OrchestratorFolders.repoPathAbsolute);
|
||||
|
||||
// Configure custom LFS transfer agent if specified
|
||||
if (Orchestrator.buildParameters.lfsTransferAgent) {
|
||||
const { LfsAgentService } = await import('../services/lfs/lfs-agent-service');
|
||||
RemoteClientLogger.log('Configuring custom LFS transfer agent...');
|
||||
await LfsAgentService.configure(
|
||||
Orchestrator.buildParameters.lfsTransferAgent,
|
||||
Orchestrator.buildParameters.lfsTransferAgentArgs,
|
||||
Orchestrator.buildParameters.lfsStoragePaths ? Orchestrator.buildParameters.lfsStoragePaths.split(';') : [],
|
||||
OrchestratorFolders.repoPathAbsolute,
|
||||
);
|
||||
}
|
||||
|
||||
await RemoteClient.pullLatestLFS();
|
||||
await RemoteClient.sizeOfFolder('repo before lfs git pull', OrchestratorFolders.repoPathAbsolute);
|
||||
await Caching.PushToCache(
|
||||
|
||||
124
src/model/orchestrator/services/cache/local-cache-service.test.ts
vendored
Normal file
124
src/model/orchestrator/services/cache/local-cache-service.test.ts
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { LocalCacheService } from './local-cache-service';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('node:fs');
|
||||
jest.mock('../core/orchestrator-system', () => ({
|
||||
OrchestratorSystem: {
|
||||
Run: jest.fn().mockResolvedValue(''),
|
||||
},
|
||||
}));
|
||||
jest.mock('../core/orchestrator-logger', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
logWarning: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockFs = fs as jest.Mocked<typeof fs>;
|
||||
|
||||
describe('LocalCacheService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('generateCacheKey', () => {
|
||||
it('should generate a key from platform, version, and branch', () => {
|
||||
const key = LocalCacheService.generateCacheKey('StandaloneLinux64', '2021.3.1f1', 'main');
|
||||
expect(key).toBe('StandaloneLinux64-2021_3_1f1-main');
|
||||
});
|
||||
|
||||
it('should sanitize non-alphanumeric characters except hyphens', () => {
|
||||
const key = LocalCacheService.generateCacheKey('WebGL', '2022.3.0f1', 'feature/my-branch');
|
||||
expect(key).toBe('WebGL-2022_3_0f1-feature_my-branch');
|
||||
});
|
||||
|
||||
it('should handle empty branch', () => {
|
||||
const key = LocalCacheService.generateCacheKey('StandaloneWindows64', '2021.3.1f1', '');
|
||||
expect(key).toBe('StandaloneWindows64-2021_3_1f1-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCacheRoot', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should use localCacheRoot when set', () => {
|
||||
const result = LocalCacheService.resolveCacheRoot({ localCacheRoot: '/custom/cache' });
|
||||
expect(result).toBe('/custom/cache');
|
||||
});
|
||||
|
||||
it('should use RUNNER_TEMP when localCacheRoot is empty', () => {
|
||||
process.env.RUNNER_TEMP = '/tmp/runner';
|
||||
const result = LocalCacheService.resolveCacheRoot({ localCacheRoot: '' });
|
||||
expect(result).toBe(path.join('/tmp/runner', 'game-ci-cache'));
|
||||
});
|
||||
|
||||
it('should fall back to .game-ci/cache when neither is set', () => {
|
||||
delete process.env.RUNNER_TEMP;
|
||||
const result = LocalCacheService.resolveCacheRoot({ localCacheRoot: '' });
|
||||
expect(result).toBe(path.join(process.cwd(), '.game-ci', 'cache'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreLibraryCache', () => {
|
||||
it('should return false on cache miss (directory does not exist)', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
const result = await LocalCacheService.restoreLibraryCache('/project', '/cache', 'key1');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when cache directory has no tar files', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue(['readme.txt', 'info.json']);
|
||||
const result = await LocalCacheService.restoreLibraryCache('/project', '/cache', 'key1');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveLibraryCache', () => {
|
||||
it('should skip save when Library folder does not exist', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
await LocalCacheService.saveLibraryCache('/project', '/cache', 'key1');
|
||||
// Should not throw, just log and return
|
||||
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create cache directory structure', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
(mockFs.readdirSync as jest.Mock).mockImplementation((dirPath: string) => {
|
||||
if (String(dirPath).includes('Library') && !String(dirPath).includes('cache')) {
|
||||
return ['file1.asset', 'file2.asset'];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
(mockFs.statSync as jest.Mock).mockReturnValue({ mtimeMs: Date.now() });
|
||||
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
|
||||
|
||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||
OrchestratorSystem.Run.mockResolvedValue('');
|
||||
|
||||
await LocalCacheService.saveLibraryCache('/project', '/cache', 'key1');
|
||||
expect(mockFs.mkdirSync).toHaveBeenCalledWith(path.join('/cache', 'key1', 'Library'), { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('garbageCollect', () => {
|
||||
it('should skip when cache root does not exist', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
await LocalCacheService.garbageCollect('/nonexistent');
|
||||
// Should not throw
|
||||
});
|
||||
});
|
||||
});
|
||||
273
src/model/orchestrator/services/cache/local-cache-service.ts
vendored
Normal file
273
src/model/orchestrator/services/cache/local-cache-service.ts
vendored
Normal file
@@ -0,0 +1,273 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { OrchestratorSystem } from '../core/orchestrator-system';
|
||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||
|
||||
export class LocalCacheService {
|
||||
/**
|
||||
* Resolve the cache root directory based on build parameters and environment.
|
||||
* Priority: localCacheRoot > RUNNER_TEMP/game-ci-cache > .game-ci/cache
|
||||
*/
|
||||
static resolveCacheRoot(buildParameters: { localCacheRoot: string }): string {
|
||||
if (buildParameters.localCacheRoot) {
|
||||
return buildParameters.localCacheRoot;
|
||||
}
|
||||
|
||||
if (process.env.RUNNER_TEMP) {
|
||||
return path.join(process.env.RUNNER_TEMP, 'game-ci-cache');
|
||||
}
|
||||
|
||||
return path.join(process.cwd(), '.game-ci', 'cache');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sanitized cache key from build parameters.
|
||||
* Non-alphanumeric characters (except hyphens) are replaced with underscores.
|
||||
*/
|
||||
static generateCacheKey(targetPlatform: string, unityVersion: string, branch: string): string {
|
||||
const raw = `${targetPlatform}-${unityVersion}-${branch}`;
|
||||
|
||||
return raw.replace(/[^a-zA-Z0-9-]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore Unity Library cache from the local filesystem.
|
||||
* Returns true if cache was restored, false on cache miss.
|
||||
*/
|
||||
static async restoreLibraryCache(projectPath: string, cacheRoot: string, cacheKey: string): Promise<boolean> {
|
||||
const cachePath = path.join(cacheRoot, cacheKey, 'Library');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
OrchestratorLogger.log(`[LocalCache] Library cache miss: ${cachePath}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(cachePath).filter((f) => f.endsWith('.tar'));
|
||||
if (files.length === 0) {
|
||||
OrchestratorLogger.log(`[LocalCache] Library cache miss (no tar files): ${cachePath}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the latest tar file by modification time
|
||||
let latestFile = files[0];
|
||||
let latestMtime = fs.statSync(path.join(cachePath, files[0])).mtimeMs;
|
||||
for (let i = 1; i < files.length; i++) {
|
||||
const mtime = fs.statSync(path.join(cachePath, files[i])).mtimeMs;
|
||||
if (mtime > latestMtime) {
|
||||
latestMtime = mtime;
|
||||
latestFile = files[i];
|
||||
}
|
||||
}
|
||||
|
||||
const tarPath = path.join(cachePath, latestFile);
|
||||
const libraryDest = path.join(projectPath, 'Library');
|
||||
|
||||
// Ensure destination exists
|
||||
fs.mkdirSync(libraryDest, { recursive: true });
|
||||
|
||||
OrchestratorLogger.log(`[LocalCache] Library cache hit: restoring from ${tarPath}`);
|
||||
await OrchestratorSystem.Run(`tar -xf "${tarPath}" -C "${projectPath}"`, true);
|
||||
OrchestratorLogger.log(`[LocalCache] Library cache restored successfully`);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] Library cache restore failed: ${error.message}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Unity Library folder to the local cache as a tar archive.
|
||||
* Keeps only the latest 2 cache entries.
|
||||
*/
|
||||
static async saveLibraryCache(projectPath: string, cacheRoot: string, cacheKey: string): Promise<void> {
|
||||
const libraryPath = path.join(projectPath, 'Library');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(libraryPath)) {
|
||||
OrchestratorLogger.log(`[LocalCache] Library folder does not exist, skipping save`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(libraryPath);
|
||||
if (entries.length === 0) {
|
||||
OrchestratorLogger.log(`[LocalCache] Library folder is empty, skipping save`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cachePath = path.join(cacheRoot, cacheKey, 'Library');
|
||||
fs.mkdirSync(cachePath, { recursive: true });
|
||||
|
||||
const timestamp = Date.now();
|
||||
const tarName = `lib-${timestamp}.tar`;
|
||||
const tarPath = path.join(cachePath, tarName);
|
||||
|
||||
OrchestratorLogger.log(`[LocalCache] Saving Library cache to ${tarPath}`);
|
||||
await OrchestratorSystem.Run(`tar -cf "${tarPath}" -C "${projectPath}" Library`, true);
|
||||
OrchestratorLogger.log(`[LocalCache] Library cache saved successfully`);
|
||||
|
||||
// Clean up old entries - keep latest 2
|
||||
await LocalCacheService.cleanupOldEntries(cachePath, 2);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] Library cache save failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore LFS cache from the local filesystem.
|
||||
* Returns true if cache was restored, false on cache miss.
|
||||
*/
|
||||
static async restoreLfsCache(repoPath: string, cacheRoot: string, cacheKey: string): Promise<boolean> {
|
||||
const cachePath = path.join(cacheRoot, cacheKey, 'lfs');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
OrchestratorLogger.log(`[LocalCache] LFS cache miss: ${cachePath}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(cachePath).filter((f) => f.endsWith('.tar'));
|
||||
if (files.length === 0) {
|
||||
OrchestratorLogger.log(`[LocalCache] LFS cache miss (no tar files): ${cachePath}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the latest tar file by modification time
|
||||
let latestFile = files[0];
|
||||
let latestMtime = fs.statSync(path.join(cachePath, files[0])).mtimeMs;
|
||||
for (let i = 1; i < files.length; i++) {
|
||||
const mtime = fs.statSync(path.join(cachePath, files[i])).mtimeMs;
|
||||
if (mtime > latestMtime) {
|
||||
latestMtime = mtime;
|
||||
latestFile = files[i];
|
||||
}
|
||||
}
|
||||
|
||||
const tarPath = path.join(cachePath, latestFile);
|
||||
const lfsDest = path.join(repoPath, '.git', 'lfs');
|
||||
|
||||
// Ensure destination exists
|
||||
fs.mkdirSync(lfsDest, { recursive: true });
|
||||
|
||||
OrchestratorLogger.log(`[LocalCache] LFS cache hit: restoring from ${tarPath}`);
|
||||
await OrchestratorSystem.Run(`tar -xf "${tarPath}" -C "${path.join(repoPath, '.git')}"`, true);
|
||||
OrchestratorLogger.log(`[LocalCache] LFS cache restored successfully`);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] LFS cache restore failed: ${error.message}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save .git/lfs folder to the local cache as a tar archive.
|
||||
* Keeps only the latest 2 cache entries.
|
||||
*/
|
||||
static async saveLfsCache(repoPath: string, cacheRoot: string, cacheKey: string): Promise<void> {
|
||||
const lfsPath = path.join(repoPath, '.git', 'lfs');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(lfsPath)) {
|
||||
OrchestratorLogger.log(`[LocalCache] LFS folder does not exist, skipping save`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(lfsPath);
|
||||
if (entries.length === 0) {
|
||||
OrchestratorLogger.log(`[LocalCache] LFS folder is empty, skipping save`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cachePath = path.join(cacheRoot, cacheKey, 'lfs');
|
||||
fs.mkdirSync(cachePath, { recursive: true });
|
||||
|
||||
const timestamp = Date.now();
|
||||
const tarName = `lfs-${timestamp}.tar`;
|
||||
const tarPath = path.join(cachePath, tarName);
|
||||
|
||||
OrchestratorLogger.log(`[LocalCache] Saving LFS cache to ${tarPath}`);
|
||||
await OrchestratorSystem.Run(`tar -cf "${tarPath}" -C "${path.join(repoPath, '.git')}" lfs`, true);
|
||||
OrchestratorLogger.log(`[LocalCache] LFS cache saved successfully`);
|
||||
|
||||
// Clean up old entries - keep latest 2
|
||||
await LocalCacheService.cleanupOldEntries(cachePath, 2);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] LFS cache save failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove cache entries older than maxAgeDays from the cache root.
|
||||
*/
|
||||
static async garbageCollect(cacheRoot: string, maxAgeDays: number = 7): Promise<void> {
|
||||
try {
|
||||
if (!fs.existsSync(cacheRoot)) {
|
||||
OrchestratorLogger.log(`[LocalCache] Cache root does not exist, nothing to collect`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
const entries = fs.readdirSync(cacheRoot);
|
||||
let removedCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(cacheRoot, entry);
|
||||
try {
|
||||
const stat = fs.statSync(entryPath);
|
||||
if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) {
|
||||
fs.rmSync(entryPath, { recursive: true, force: true });
|
||||
removedCount++;
|
||||
OrchestratorLogger.log(`[LocalCache] Garbage collected: ${entryPath}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] Failed to garbage collect ${entryPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
OrchestratorLogger.log(`[LocalCache] Garbage collection complete: ${removedCount} entries removed`);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] Garbage collection failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old tar files in a cache directory, keeping only the latest N.
|
||||
*/
|
||||
private static async cleanupOldEntries(cachePath: string, keepCount: number): Promise<void> {
|
||||
try {
|
||||
const files = fs
|
||||
.readdirSync(cachePath)
|
||||
.filter((f) => f.endsWith('.tar'))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
mtime: fs.statSync(path.join(cachePath, f)).mtimeMs,
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
if (files.length > keepCount) {
|
||||
const toRemove = files.slice(keepCount);
|
||||
for (const file of toRemove) {
|
||||
const filePath = path.join(cachePath, file.name);
|
||||
fs.unlinkSync(filePath);
|
||||
OrchestratorLogger.log(`[LocalCache] Cleaned up old cache entry: ${filePath}`);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[LocalCache] Cleanup of old entries failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/model/orchestrator/services/hooks/git-hooks-service.test.ts
Normal file
121
src/model/orchestrator/services/hooks/git-hooks-service.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { GitHooksService } from './git-hooks-service';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('node:fs');
|
||||
jest.mock('../core/orchestrator-system', () => ({
|
||||
OrchestratorSystem: {
|
||||
Run: jest.fn().mockResolvedValue(''),
|
||||
},
|
||||
}));
|
||||
jest.mock('../core/orchestrator-logger', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
logWarning: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockFs = fs as jest.Mocked<typeof fs>;
|
||||
|
||||
describe('GitHooksService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('detectHookFramework', () => {
|
||||
it('should detect lefthook.yml', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
|
||||
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
|
||||
});
|
||||
|
||||
const result = GitHooksService.detectHookFramework('/repo');
|
||||
expect(result).toBe('lefthook');
|
||||
});
|
||||
|
||||
it('should detect .lefthook.yml', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
|
||||
return String(filePath).includes('.lefthook.yml');
|
||||
});
|
||||
|
||||
const result = GitHooksService.detectHookFramework('/repo');
|
||||
expect(result).toBe('lefthook');
|
||||
});
|
||||
|
||||
it('should detect .husky directory', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
|
||||
return String(filePath).endsWith('.husky');
|
||||
});
|
||||
|
||||
const result = GitHooksService.detectHookFramework('/repo');
|
||||
expect(result).toBe('husky');
|
||||
});
|
||||
|
||||
it('should return none when no framework is detected', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const result = GitHooksService.detectHookFramework('/repo');
|
||||
expect(result).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureSkipList', () => {
|
||||
it('should return empty object for empty skip list', () => {
|
||||
const result = GitHooksService.configureSkipList([]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return LEFTHOOK_EXCLUDE with comma-separated hooks', () => {
|
||||
const result = GitHooksService.configureSkipList(['pre-commit', 'pre-push']);
|
||||
expect(result.LEFTHOOK_EXCLUDE).toBe('pre-commit,pre-push');
|
||||
});
|
||||
|
||||
it('should set HUSKY=0 when hooks are skipped', () => {
|
||||
const result = GitHooksService.configureSkipList(['pre-commit']);
|
||||
expect(result.HUSKY).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableHooks', () => {
|
||||
it('should set core.hooksPath to an empty directory', async () => {
|
||||
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
|
||||
|
||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||
|
||||
await GitHooksService.disableHooks('/repo');
|
||||
|
||||
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||
expect.stringContaining('git -C "/repo" config core.hooksPath'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installHooks', () => {
|
||||
it('should run npx lefthook install when lefthook is detected', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => {
|
||||
return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.');
|
||||
});
|
||||
|
||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||
|
||||
await GitHooksService.installHooks('/repo');
|
||||
|
||||
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(`cd "/repo" && npx lefthook install`, true);
|
||||
});
|
||||
|
||||
it('should log and return when no framework is detected', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||
const OrchestratorLogger = require('../core/orchestrator-logger').default;
|
||||
|
||||
await GitHooksService.installHooks('/repo');
|
||||
|
||||
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
|
||||
expect(OrchestratorLogger.log).toHaveBeenCalledWith(expect.stringContaining('No hook framework detected'));
|
||||
});
|
||||
});
|
||||
});
|
||||
96
src/model/orchestrator/services/hooks/git-hooks-service.ts
Normal file
96
src/model/orchestrator/services/hooks/git-hooks-service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { OrchestratorSystem } from '../core/orchestrator-system';
|
||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||
|
||||
export class GitHooksService {
|
||||
/**
|
||||
* Detect which git hook framework is configured in the repository.
|
||||
* Checks for lefthook and husky configuration files.
|
||||
*/
|
||||
static detectHookFramework(repoPath: string): 'lefthook' | 'husky' | 'none' {
|
||||
// Check for lefthook config files
|
||||
if (fs.existsSync(path.join(repoPath, 'lefthook.yml')) || fs.existsSync(path.join(repoPath, '.lefthook.yml'))) {
|
||||
return 'lefthook';
|
||||
}
|
||||
|
||||
// Check for husky directory
|
||||
if (fs.existsSync(path.join(repoPath, '.husky'))) {
|
||||
return 'husky';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Install git hooks using the detected framework.
|
||||
* Errors are caught and logged as warnings - hook installation should not fail the build.
|
||||
*/
|
||||
static async installHooks(repoPath: string): Promise<void> {
|
||||
const framework = GitHooksService.detectHookFramework(repoPath);
|
||||
|
||||
if (framework === 'none') {
|
||||
OrchestratorLogger.log(`[GitHooks] No hook framework detected in ${repoPath}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OrchestratorLogger.log(`[GitHooks] Detected hook framework: ${framework}`);
|
||||
|
||||
try {
|
||||
if (framework === 'lefthook') {
|
||||
await OrchestratorSystem.Run(`cd "${repoPath}" && npx lefthook install`, true);
|
||||
OrchestratorLogger.log(`[GitHooks] Lefthook hooks installed`);
|
||||
} else if (framework === 'husky') {
|
||||
await OrchestratorSystem.Run(`cd "${repoPath}" && npx husky install`, true);
|
||||
OrchestratorLogger.log(`[GitHooks] Husky hooks installed`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[GitHooks] Hook installation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return environment variables that will skip the listed hooks.
|
||||
* For lefthook: sets LEFTHOOK_EXCLUDE to a comma-separated list.
|
||||
* For husky: sets HUSKY=0 to disable all hooks (husky does not support selective skipping).
|
||||
* The caller is responsible for applying the returned env vars.
|
||||
*/
|
||||
static configureSkipList(skipList: string[]): Record<string, string> {
|
||||
if (skipList.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Return both lefthook and husky env vars so the caller can apply whichever is relevant.
|
||||
// Lefthook supports selective hook exclusion.
|
||||
const env: Record<string, string> = {
|
||||
LEFTHOOK_EXCLUDE: skipList.join(','),
|
||||
};
|
||||
|
||||
// Husky only supports full disable (HUSKY=0), not selective skipping.
|
||||
// If any hooks are in the skip list, disable husky entirely.
|
||||
env.HUSKY = '0';
|
||||
|
||||
OrchestratorLogger.log(`[GitHooks] Skip list configured: ${skipList.join(', ')}`);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all git hooks by pointing core.hooksPath to an empty temporary directory.
|
||||
* This prevents any hooks from running during the build.
|
||||
*/
|
||||
static async disableHooks(repoPath: string): Promise<void> {
|
||||
try {
|
||||
const emptyDir = path.join(os.tmpdir(), 'game-ci-empty-hooks');
|
||||
fs.mkdirSync(emptyDir, { recursive: true });
|
||||
|
||||
await OrchestratorSystem.Run(`git -C "${repoPath}" config core.hooksPath "${emptyDir}"`, true);
|
||||
|
||||
OrchestratorLogger.log(`[GitHooks] Hooks disabled via core.hooksPath -> ${emptyDir}`);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[GitHooks] Failed to disable hooks: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { LfsAgentService } from './lfs-agent-service';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('node:fs');
|
||||
jest.mock('../core/orchestrator-system', () => ({
|
||||
OrchestratorSystem: {
|
||||
Run: jest.fn().mockResolvedValue(''),
|
||||
},
|
||||
}));
|
||||
jest.mock('../core/orchestrator-logger', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
logWarning: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockFs = fs as jest.Mocked<typeof fs>;
|
||||
|
||||
describe('LfsAgentService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('configure', () => {
|
||||
it('should call correct git config commands when agent exists', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||
|
||||
await LfsAgentService.configure(
|
||||
'/usr/local/bin/elastic-git-storage',
|
||||
'--verbose',
|
||||
['/storage/path1', '/storage/path2'],
|
||||
'/repo',
|
||||
);
|
||||
|
||||
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||
`git -C "/repo" config lfs.customtransfer.elastic-git-storage.path "/usr/local/bin/elastic-git-storage"`,
|
||||
);
|
||||
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||
`git -C "/repo" config lfs.customtransfer.elastic-git-storage.args "--verbose"`,
|
||||
);
|
||||
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||
`git -C "/repo" config lfs.standalonetransferagent elastic-git-storage`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should set LFS_STORAGE_PATHS environment variable when storagePaths provided', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
|
||||
await LfsAgentService.configure('/usr/local/bin/elastic-git-storage', '', ['/path/a', '/path/b'], '/repo');
|
||||
|
||||
expect(process.env.LFS_STORAGE_PATHS).toBe('/path/a;/path/b');
|
||||
});
|
||||
|
||||
it('should log warning and return early when agent executable does not exist', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||
|
||||
await LfsAgentService.configure('/nonexistent/agent', '', [], '/repo');
|
||||
|
||||
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should derive agent name from executable filename', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||
|
||||
await LfsAgentService.configure('/tools/my-custom-agent.exe', '', [], '/repo');
|
||||
|
||||
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||
`git -C "/repo" config lfs.customtransfer.my-custom-agent.path "/tools/my-custom-agent.exe"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should return true when agent executable exists', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
const result = await LfsAgentService.validate('/usr/local/bin/elastic-git-storage');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when agent executable does not exist', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
const result = await LfsAgentService.validate('/nonexistent/agent');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
59
src/model/orchestrator/services/lfs/lfs-agent-service.ts
Normal file
59
src/model/orchestrator/services/lfs/lfs-agent-service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { OrchestratorSystem } from '../core/orchestrator-system';
|
||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||
|
||||
export class LfsAgentService {
|
||||
/**
|
||||
* Configure a custom LFS transfer agent in a git repository.
|
||||
* Sets up the git config entries and environment variables needed for the agent.
|
||||
*/
|
||||
static async configure(
|
||||
agentPath: string,
|
||||
agentArgs: string,
|
||||
storagePaths: string[],
|
||||
repoPath: string,
|
||||
): Promise<void> {
|
||||
// Validate the agent executable exists
|
||||
if (!fs.existsSync(agentPath)) {
|
||||
OrchestratorLogger.logWarning(
|
||||
`[LfsAgent] Agent executable not found at ${agentPath}, continuing without custom LFS agent`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Derive agent name from executable filename (without extension)
|
||||
const agentName = path.basename(agentPath, path.extname(agentPath));
|
||||
|
||||
OrchestratorLogger.log(`[LfsAgent] Configuring custom LFS transfer agent: ${agentName}`);
|
||||
OrchestratorLogger.log(`[LfsAgent] Path: ${agentPath}`);
|
||||
OrchestratorLogger.log(`[LfsAgent] Args: ${agentArgs}`);
|
||||
|
||||
// Set git config entries for the custom transfer agent
|
||||
await OrchestratorSystem.Run(`git -C "${repoPath}" config lfs.customtransfer.${agentName}.path "${agentPath}"`);
|
||||
await OrchestratorSystem.Run(`git -C "${repoPath}" config lfs.customtransfer.${agentName}.args "${agentArgs}"`);
|
||||
await OrchestratorSystem.Run(`git -C "${repoPath}" config lfs.standalonetransferagent ${agentName}`);
|
||||
|
||||
// Set storage paths environment variable if provided
|
||||
if (storagePaths.length > 0) {
|
||||
const storagePathsValue = storagePaths.join(';');
|
||||
process.env.LFS_STORAGE_PATHS = storagePathsValue;
|
||||
OrchestratorLogger.log(`[LfsAgent] Storage paths: ${storagePathsValue}`);
|
||||
}
|
||||
|
||||
OrchestratorLogger.log(`[LfsAgent] Custom LFS transfer agent configured successfully`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the LFS transfer agent executable exists.
|
||||
*/
|
||||
static async validate(agentPath: string): Promise<boolean> {
|
||||
const exists = fs.existsSync(agentPath);
|
||||
if (!exists) {
|
||||
OrchestratorLogger.logWarning(`[LfsAgent] Agent executable not found: ${agentPath}`);
|
||||
}
|
||||
|
||||
return exists;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import fs from 'node:fs';
|
||||
import { SubmoduleProfileService } from './submodule-profile-service';
|
||||
import { OrchestratorSystem } from '../core/orchestrator-system';
|
||||
|
||||
jest.mock('node:fs');
|
||||
jest.mock('../core/orchestrator-system');
|
||||
jest.mock('../core/orchestrator-logger', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
logWarning: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedFs = fs as jest.Mocked<typeof fs>;
|
||||
const mockedSystem = OrchestratorSystem as jest.Mocked<typeof OrchestratorSystem>;
|
||||
|
||||
describe('SubmoduleProfileService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('parseProfile', () => {
|
||||
it('reads and parses a valid YAML profile', () => {
|
||||
const profileYaml = `
|
||||
primary_submodule: Assets/_Game/Submodules/TurnOfWarEndlessCrusade
|
||||
product_name: Endless Crusade
|
||||
submodules:
|
||||
- name: TurnOfWar
|
||||
branch: main
|
||||
- name: TurnOfWarEndlessCrusade
|
||||
branch: main
|
||||
- name: AreaOfOperations
|
||||
branch: empty
|
||||
`;
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(profileYaml);
|
||||
|
||||
const profile = SubmoduleProfileService.parseProfile('/path/to/profile.yml');
|
||||
|
||||
expect(profile.primary_submodule).toBe('Assets/_Game/Submodules/TurnOfWarEndlessCrusade');
|
||||
expect(profile.product_name).toBe('Endless Crusade');
|
||||
expect(profile.submodules).toHaveLength(3);
|
||||
expect(profile.submodules[0]).toEqual({ name: 'TurnOfWar', branch: 'main' });
|
||||
expect(profile.submodules[1]).toEqual({ name: 'TurnOfWarEndlessCrusade', branch: 'main' });
|
||||
expect(profile.submodules[2]).toEqual({ name: 'AreaOfOperations', branch: 'empty' });
|
||||
});
|
||||
|
||||
it('throws if profile file does not exist', () => {
|
||||
mockedFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => SubmoduleProfileService.parseProfile('/missing/profile.yml')).toThrow('Submodule profile not found');
|
||||
});
|
||||
|
||||
it('throws if YAML is missing submodules array', () => {
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue('product_name: Test\n');
|
||||
|
||||
expect(() => SubmoduleProfileService.parseProfile('/path/to/bad.yml')).toThrow("expected 'submodules' array");
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeVariant', () => {
|
||||
it('correctly overlays variant entries on base profile', () => {
|
||||
const baseYaml = `
|
||||
submodules:
|
||||
- name: ModuleA
|
||||
branch: main
|
||||
- name: ModuleB
|
||||
branch: main
|
||||
`;
|
||||
const variantYaml = `
|
||||
product_name: Server Build
|
||||
submodules:
|
||||
- name: ModuleB
|
||||
branch: empty
|
||||
- name: ModuleC
|
||||
branch: develop
|
||||
`;
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockImplementation((filePath: any) => {
|
||||
if (filePath === '/base.yml') return baseYaml;
|
||||
if (filePath === '/variant.yml') return variantYaml;
|
||||
return '';
|
||||
});
|
||||
|
||||
const base = SubmoduleProfileService.parseProfile('/base.yml');
|
||||
const merged = SubmoduleProfileService.mergeVariant(base, '/variant.yml');
|
||||
|
||||
expect(merged.product_name).toBe('Server Build');
|
||||
expect(merged.submodules).toHaveLength(3);
|
||||
|
||||
const moduleA = merged.submodules.find((s) => s.name === 'ModuleA');
|
||||
const moduleB = merged.submodules.find((s) => s.name === 'ModuleB');
|
||||
const moduleC = merged.submodules.find((s) => s.name === 'ModuleC');
|
||||
|
||||
expect(moduleA?.branch).toBe('main');
|
||||
expect(moduleB?.branch).toBe('empty');
|
||||
expect(moduleC?.branch).toBe('develop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchSubmodule', () => {
|
||||
it('matches exact submodule name', () => {
|
||||
expect(SubmoduleProfileService.matchSubmodule('TurnOfWar', 'TurnOfWar')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches exact leaf folder name against full path', () => {
|
||||
expect(SubmoduleProfileService.matchSubmodule('Assets/_Game/Submodules/TurnOfWar', 'TurnOfWar')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match unrelated names', () => {
|
||||
expect(SubmoduleProfileService.matchSubmodule('TurnOfWar', 'AreaOfOperations')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches trailing wildcard against full path', () => {
|
||||
expect(SubmoduleProfileService.matchSubmodule('Assets/_Engine/Submodules/PluginsFoo', 'Plugins*')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches trailing wildcard against full path prefix', () => {
|
||||
expect(
|
||||
SubmoduleProfileService.matchSubmodule(
|
||||
'Assets/_Engine/Submodules/PluginsFoo',
|
||||
'Assets/_Engine/Submodules/Plugins*',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match wildcard that does not align', () => {
|
||||
expect(SubmoduleProfileService.matchSubmodule('Assets/_Engine/Submodules/SensorToolkit', 'Plugins*')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGitmodules', () => {
|
||||
it('parses a typical .gitmodules file', () => {
|
||||
const gitmodulesContent = `[submodule "Assets/_Game/Submodules/TurnOfWar"]
|
||||
\tpath = Assets/_Game/Submodules/TurnOfWar
|
||||
\turl = https://github.com/org/TurnOfWar.git
|
||||
[submodule "Assets/_Game/Submodules/EndlessCrusade"]
|
||||
\tpath = Assets/_Game/Submodules/EndlessCrusade
|
||||
\turl = https://github.com/org/EndlessCrusade.git
|
||||
[submodule "Assets/_Engine/Submodules/SensorToolkit"]
|
||||
\tpath = Assets/_Engine/Submodules/SensorToolkit
|
||||
\turl = https://github.com/org/SensorToolkit.git
|
||||
`;
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(gitmodulesContent);
|
||||
|
||||
const result = SubmoduleProfileService.parseGitmodules('/repo');
|
||||
|
||||
expect(result.size).toBe(3);
|
||||
expect(result.get('Assets/_Game/Submodules/TurnOfWar')).toBe('Assets/_Game/Submodules/TurnOfWar');
|
||||
expect(result.get('Assets/_Game/Submodules/EndlessCrusade')).toBe('Assets/_Game/Submodules/EndlessCrusade');
|
||||
expect(result.get('Assets/_Engine/Submodules/SensorToolkit')).toBe('Assets/_Engine/Submodules/SensorToolkit');
|
||||
});
|
||||
|
||||
it('returns empty map when .gitmodules does not exist', () => {
|
||||
mockedFs.existsSync.mockReturnValue(false);
|
||||
|
||||
const result = SubmoduleProfileService.parseGitmodules('/repo');
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInitPlan', () => {
|
||||
it('generates correct init and skip actions', async () => {
|
||||
const profileYaml = `
|
||||
submodules:
|
||||
- name: TurnOfWar
|
||||
branch: main
|
||||
- name: EndlessCrusade
|
||||
branch: main
|
||||
- name: SensorToolkit
|
||||
branch: empty
|
||||
`;
|
||||
|
||||
const gitmodulesContent = `[submodule "Assets/_Game/Submodules/TurnOfWar"]
|
||||
\tpath = Assets/_Game/Submodules/TurnOfWar
|
||||
\turl = https://github.com/org/TurnOfWar.git
|
||||
[submodule "Assets/_Game/Submodules/EndlessCrusade"]
|
||||
\tpath = Assets/_Game/Submodules/EndlessCrusade
|
||||
\turl = https://github.com/org/EndlessCrusade.git
|
||||
[submodule "Assets/_Engine/Submodules/SensorToolkit"]
|
||||
\tpath = Assets/_Engine/Submodules/SensorToolkit
|
||||
\turl = https://github.com/org/SensorToolkit.git
|
||||
[submodule "Assets/_Game/Submodules/Unmatched"]
|
||||
\tpath = Assets/_Game/Submodules/Unmatched
|
||||
\turl = https://github.com/org/Unmatched.git
|
||||
`;
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockImplementation((filePath: any) => {
|
||||
if (String(filePath).endsWith('profile.yml')) return profileYaml;
|
||||
if (String(filePath).endsWith('.gitmodules')) return gitmodulesContent;
|
||||
return '';
|
||||
});
|
||||
|
||||
const plan = await SubmoduleProfileService.createInitPlan('/path/to/profile.yml', '', '/repo');
|
||||
|
||||
expect(plan).toHaveLength(4);
|
||||
|
||||
const turnOfWar = plan.find((a) => a.name === 'Assets/_Game/Submodules/TurnOfWar');
|
||||
expect(turnOfWar?.action).toBe('init');
|
||||
expect(turnOfWar?.branch).toBe('main');
|
||||
|
||||
const endlessCrusade = plan.find((a) => a.name === 'Assets/_Game/Submodules/EndlessCrusade');
|
||||
expect(endlessCrusade?.action).toBe('init');
|
||||
expect(endlessCrusade?.branch).toBe('main');
|
||||
|
||||
const sensorToolkit = plan.find((a) => a.name === 'Assets/_Engine/Submodules/SensorToolkit');
|
||||
expect(sensorToolkit?.action).toBe('skip');
|
||||
expect(sensorToolkit?.branch).toBe('empty');
|
||||
|
||||
const unmatched = plan.find((a) => a.name === 'Assets/_Game/Submodules/Unmatched');
|
||||
expect(unmatched?.action).toBe('skip');
|
||||
expect(unmatched?.branch).toBe('empty');
|
||||
});
|
||||
|
||||
it('applies variant overlay when variantPath is provided', async () => {
|
||||
const profileYaml = `
|
||||
submodules:
|
||||
- name: TurnOfWar
|
||||
branch: main
|
||||
- name: EndlessCrusade
|
||||
branch: main
|
||||
`;
|
||||
|
||||
const variantYaml = `
|
||||
submodules:
|
||||
- name: EndlessCrusade
|
||||
branch: empty
|
||||
`;
|
||||
|
||||
const gitmodulesContent = `[submodule "Assets/_Game/Submodules/TurnOfWar"]
|
||||
\tpath = Assets/_Game/Submodules/TurnOfWar
|
||||
\turl = https://github.com/org/TurnOfWar.git
|
||||
[submodule "Assets/_Game/Submodules/EndlessCrusade"]
|
||||
\tpath = Assets/_Game/Submodules/EndlessCrusade
|
||||
\turl = https://github.com/org/EndlessCrusade.git
|
||||
`;
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockImplementation((filePath: any) => {
|
||||
const p = String(filePath);
|
||||
if (p.endsWith('profile.yml')) return profileYaml;
|
||||
if (p.endsWith('variant.yml')) return variantYaml;
|
||||
if (p.endsWith('.gitmodules')) return gitmodulesContent;
|
||||
return '';
|
||||
});
|
||||
|
||||
const plan = await SubmoduleProfileService.createInitPlan(
|
||||
'/path/to/profile.yml',
|
||||
'/path/to/variant.yml',
|
||||
'/repo',
|
||||
);
|
||||
|
||||
expect(plan).toHaveLength(2);
|
||||
|
||||
const turnOfWar = plan.find((a) => a.name === 'Assets/_Game/Submodules/TurnOfWar');
|
||||
expect(turnOfWar?.action).toBe('init');
|
||||
|
||||
const endlessCrusade = plan.find((a) => a.name === 'Assets/_Game/Submodules/EndlessCrusade');
|
||||
expect(endlessCrusade?.action).toBe('skip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('runs init commands for init actions and deinit for skip actions', async () => {
|
||||
mockedSystem.Run.mockResolvedValue('');
|
||||
|
||||
const plan = [
|
||||
{ name: 'ModuleA', path: 'Assets/ModuleA', branch: 'main', action: 'init' as const },
|
||||
{ name: 'ModuleB', path: 'Assets/ModuleB', branch: 'develop', action: 'init' as const },
|
||||
{ name: 'ModuleC', path: 'Assets/ModuleC', branch: 'empty', action: 'skip' as const },
|
||||
];
|
||||
|
||||
await SubmoduleProfileService.execute(plan, '/repo');
|
||||
|
||||
// ModuleA: init only (branch is main, no checkout needed)
|
||||
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/ModuleA');
|
||||
|
||||
// ModuleB: init + checkout develop
|
||||
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/ModuleB');
|
||||
expect(mockedSystem.Run).toHaveBeenCalledWith('git -C Assets/ModuleB checkout develop');
|
||||
|
||||
// ModuleC: deinit
|
||||
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule deinit -f Assets/ModuleC 2>/dev/null || true');
|
||||
});
|
||||
|
||||
it('configures auth when token is provided', async () => {
|
||||
mockedSystem.Run.mockResolvedValue('');
|
||||
|
||||
await SubmoduleProfileService.execute([], '/repo', 'my-secret-token');
|
||||
|
||||
expect(mockedSystem.Run).toHaveBeenCalledWith(
|
||||
'git config url."https://my-secret-token@github.com/".insteadOf "https://github.com/"',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not configure auth when no token is provided', async () => {
|
||||
mockedSystem.Run.mockResolvedValue('');
|
||||
|
||||
await SubmoduleProfileService.execute([], '/repo');
|
||||
|
||||
expect(mockedSystem.Run).not.toHaveBeenCalledWith(expect.stringContaining('git config url'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
import { SubmoduleProfile, SubmoduleEntry, SubmoduleInitAction, SubmoduleInitPlan } from './submodule-profile-types';
|
||||
import { OrchestratorSystem } from '../core/orchestrator-system';
|
||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||
|
||||
export class SubmoduleProfileService {
|
||||
/**
|
||||
* Parse a submodule profile YAML file and return the typed profile.
|
||||
*/
|
||||
static parseProfile(profilePath: string): SubmoduleProfile {
|
||||
if (!fs.existsSync(profilePath)) {
|
||||
throw new Error(`Submodule profile not found: ${profilePath}`);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(profilePath, 'utf8');
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = YAML.parse(raw);
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to parse submodule profile YAML at ${profilePath}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!parsed || !Array.isArray(parsed.submodules)) {
|
||||
throw new Error(`Invalid submodule profile: expected 'submodules' array in ${profilePath}`);
|
||||
}
|
||||
|
||||
return {
|
||||
primary_submodule: parsed.primary_submodule,
|
||||
product_name: parsed.product_name,
|
||||
submodules: parsed.submodules.map((entry: any) => ({
|
||||
name: String(entry.name),
|
||||
branch: String(entry.branch),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a variant profile on top of a base profile.
|
||||
* Variant submodule entries override base entries matched by name.
|
||||
* New variant entries are appended.
|
||||
* Scalar fields (primary_submodule, product_name) are replaced by variant values.
|
||||
*/
|
||||
static mergeVariant(base: SubmoduleProfile, variantPath: string): SubmoduleProfile {
|
||||
if (!fs.existsSync(variantPath)) {
|
||||
throw new Error(`Submodule variant not found: ${variantPath}`);
|
||||
}
|
||||
|
||||
const variant = SubmoduleProfileService.parseProfile(variantPath);
|
||||
|
||||
// Start with a copy of base submodules
|
||||
const mergedEntries = new Map<string, SubmoduleEntry>();
|
||||
for (const entry of base.submodules) {
|
||||
mergedEntries.set(entry.name, { ...entry });
|
||||
}
|
||||
|
||||
// Overlay variant entries
|
||||
for (const entry of variant.submodules) {
|
||||
mergedEntries.set(entry.name, { ...entry });
|
||||
}
|
||||
|
||||
return {
|
||||
primary_submodule: variant.primary_submodule ?? base.primary_submodule,
|
||||
product_name: variant.product_name ?? base.product_name,
|
||||
submodules: [...mergedEntries.values()],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the .gitmodules file from a repository and return a map of submodule name -> path.
|
||||
*/
|
||||
static parseGitmodules(repoPath: string): Map<string, string> {
|
||||
const gitmodulesPath = path.join(repoPath, '.gitmodules');
|
||||
const result = new Map<string, string>();
|
||||
|
||||
if (!fs.existsSync(gitmodulesPath)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(gitmodulesPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let currentName: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Match [submodule "name"]
|
||||
const submoduleMatch = trimmed.match(/^\[submodule\s+"(.+)"\]$/);
|
||||
if (submoduleMatch) {
|
||||
currentName = submoduleMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match path = value
|
||||
const pathMatch = trimmed.match(/^path\s*=\s*(.+)$/);
|
||||
if (pathMatch && currentName) {
|
||||
result.set(currentName, pathMatch[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a submodule name/path against a profile pattern.
|
||||
* Supports exact match and glob-like patterns (only `*` wildcard at end).
|
||||
* Matches against both the full submodule path and the leaf folder name.
|
||||
*/
|
||||
static matchSubmodule(submoduleName: string, pattern: string): boolean {
|
||||
// Check for trailing wildcard
|
||||
if (pattern.endsWith('*')) {
|
||||
const prefix = pattern.slice(0, -1);
|
||||
|
||||
// Match against full path
|
||||
if (submoduleName.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Match against leaf folder name
|
||||
const leaf = submoduleName.split('/').pop() || '';
|
||||
if (leaf.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact match against full path
|
||||
if (submoduleName === pattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exact match against leaf folder name
|
||||
const leaf = submoduleName.split('/').pop() || '';
|
||||
if (leaf === pattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an initialization plan by matching .gitmodules entries against profile rules.
|
||||
* Unmatched submodules default to 'skip'.
|
||||
*/
|
||||
static async createInitPlan(profilePath: string, variantPath: string, repoPath: string): Promise<SubmoduleInitPlan> {
|
||||
let profile = SubmoduleProfileService.parseProfile(profilePath);
|
||||
|
||||
if (variantPath) {
|
||||
profile = SubmoduleProfileService.mergeVariant(profile, variantPath);
|
||||
}
|
||||
|
||||
const gitmodules = SubmoduleProfileService.parseGitmodules(repoPath);
|
||||
const plan: SubmoduleInitPlan = [];
|
||||
|
||||
for (const [name, submodulePath] of gitmodules) {
|
||||
let matchedEntry: SubmoduleEntry | undefined;
|
||||
|
||||
for (const entry of profile.submodules) {
|
||||
if (
|
||||
SubmoduleProfileService.matchSubmodule(name, entry.name) ||
|
||||
SubmoduleProfileService.matchSubmodule(submodulePath, entry.name)
|
||||
) {
|
||||
matchedEntry = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedEntry) {
|
||||
const action: SubmoduleInitAction = {
|
||||
name,
|
||||
path: submodulePath,
|
||||
branch: matchedEntry.branch,
|
||||
action: matchedEntry.branch === 'empty' ? 'skip' : 'init',
|
||||
};
|
||||
plan.push(action);
|
||||
} else {
|
||||
// Unmatched submodules default to skip
|
||||
plan.push({
|
||||
name,
|
||||
path: submodulePath,
|
||||
branch: 'empty',
|
||||
action: 'skip',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a submodule initialization plan.
|
||||
* Configures auth if token is provided, then inits or deinits each submodule.
|
||||
*/
|
||||
static async execute(plan: SubmoduleInitPlan, repoPath: string, token?: string): Promise<void> {
|
||||
if (token) {
|
||||
OrchestratorLogger.log('Configuring git authentication for submodule initialization...');
|
||||
await OrchestratorSystem.Run(`git config url."https://${token}@github.com/".insteadOf "https://github.com/"`);
|
||||
}
|
||||
|
||||
for (const action of plan) {
|
||||
const fullPath = path.posix.join(repoPath, action.path).replace(/\\/g, '/');
|
||||
|
||||
if (action.action === 'init') {
|
||||
OrchestratorLogger.log(`Initializing submodule: ${action.name} (branch: ${action.branch})`);
|
||||
await OrchestratorSystem.Run(`git submodule update --init ${action.path}`);
|
||||
|
||||
if (action.branch !== 'main') {
|
||||
OrchestratorLogger.log(`Checking out branch '${action.branch}' for submodule: ${action.name}`);
|
||||
await OrchestratorSystem.Run(`git -C ${action.path} checkout ${action.branch}`);
|
||||
}
|
||||
} else {
|
||||
OrchestratorLogger.log(`Skipping submodule: ${action.name}`);
|
||||
await OrchestratorSystem.Run(`git submodule deinit -f ${action.path} 2>/dev/null || true`);
|
||||
}
|
||||
}
|
||||
|
||||
OrchestratorLogger.log(
|
||||
`Submodule initialization complete: ${plan.filter((a) => a.action === 'init').length} initialized, ${
|
||||
plan.filter((a) => a.action === 'skip').length
|
||||
} skipped`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface SubmoduleEntry {
|
||||
name: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
export interface SubmoduleProfile {
|
||||
primary_submodule?: string;
|
||||
product_name?: string;
|
||||
submodules: SubmoduleEntry[];
|
||||
}
|
||||
|
||||
export interface SubmoduleInitAction {
|
||||
name: string;
|
||||
path: string;
|
||||
branch: string;
|
||||
action: 'init' | 'skip';
|
||||
}
|
||||
|
||||
export type SubmoduleInitPlan = SubmoduleInitAction[];
|
||||
@@ -30,7 +30,7 @@ describe('Orchestrator Caching', () => {
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
cacheKey: `test-case-${uuidv4()}`,
|
||||
containerHookFiles: `debug-cache`,
|
||||
orchestratorBranch: `orchestrator-develop`,
|
||||
orchestratorBranch: `main`,
|
||||
orchestratorDebug: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
|
||||
git clone -q -b "$BRANCH" "$REPO" /builder
|
||||
else
|
||||
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
|
||||
git clone -q -b orchestrator-develop "$REPO" /builder \
|
||||
|| git clone -q -b main "$REPO" /builder \
|
||||
git clone -q -b main "$REPO" /builder \
|
||||
|| git clone -q "$REPO" /builder
|
||||
fi
|
||||
git clone -q -b ${Orchestrator.buildParameters.branch} ${OrchestratorFolders.targetBuildRepoUrl} /repo
|
||||
|
||||
@@ -99,8 +99,7 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
|
||||
git clone -q -b "$BRANCH" "$REPO" "$DEST"
|
||||
else
|
||||
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
|
||||
git clone -q -b orchestrator-develop "$REPO" "$DEST" \
|
||||
|| git clone -q -b main "$REPO" "$DEST" \
|
||||
git clone -q -b main "$REPO" "$DEST" \
|
||||
|| git clone -q "$REPO" "$DEST"
|
||||
fi
|
||||
chmod +x ${builderPath}`;
|
||||
|
||||
Reference in New Issue
Block a user