diff --git a/package.json b/package.json index 82652d330..67c5df5d3 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "version": "3.0.0", "description": "Build Unity projects for different platforms.", "main": "dist/index.js", + "exports": { + ".": "./dist/index.js", + "./cli-plugin": "./src/cli-plugin/index.ts" + }, "repository": "git@github.com:game-ci/unity-builder.git", "author": "Webber ", "license": "MIT", diff --git a/src/cli-plugin/build-parameters-adapter.ts b/src/cli-plugin/build-parameters-adapter.ts new file mode 100644 index 000000000..79cb771d7 --- /dev/null +++ b/src/cli-plugin/build-parameters-adapter.ts @@ -0,0 +1,131 @@ +import BuildParameters from '../model/build-parameters'; +import { customAlphabet } from 'nanoid'; +import OrchestratorConstants from '../model/orchestrator/options/orchestrator-constants'; + +/** + * Maps CLI yargs options (flat key-value pairs) into a BuildParameters object. + * + * When the orchestrator is consumed as a CLI plugin, the CLI collects options via + * yargs and passes them as a plain object. This adapter bridges that format to the + * BuildParameters class that all providers expect. + */ +export function createBuildParametersFromCliOptions(options: Record): BuildParameters { + const bp = new BuildParameters(); + + // Unity / build settings + bp.editorVersion = options.engineVersion || options.editorVersion || options.unityVersion || ''; + bp.customImage = options.customImage || ''; + bp.unitySerial = options.unitySerial || process.env.UNITY_SERIAL || ''; + bp.unityLicensingServer = options.unityLicensingServer || ''; + bp.skipActivation = options.skipActivation || 'false'; + bp.runnerTempPath = options.runnerTempPath || process.env.RUNNER_TEMP || ''; + bp.targetPlatform = options.targetPlatform || 'StandaloneLinux64'; + bp.projectPath = options.projectPath || '.'; + bp.buildProfile = options.buildProfile || ''; + bp.buildName = options.buildName || options.targetPlatform || 'StandaloneLinux64'; + bp.buildPath = options.buildPath || `build/${bp.targetPlatform}`; + bp.buildFile = options.buildFile || bp.buildName; + bp.buildMethod = options.buildMethod || ''; + bp.buildVersion = options.buildVersion || '0.0.1'; + bp.manualExit = options.manualExit === true || options.manualExit === 'true'; + bp.enableGpu = options.enableGpu === true || options.enableGpu === 'true'; + + // Android + bp.androidVersionCode = options.androidVersionCode || ''; + bp.androidKeystoreName = options.androidKeystoreName || ''; + bp.androidKeystoreBase64 = options.androidKeystoreBase64 || ''; + bp.androidKeystorePass = options.androidKeystorePass || ''; + bp.androidKeyaliasName = options.androidKeyaliasName || ''; + bp.androidKeyaliasPass = options.androidKeyaliasPass || ''; + bp.androidTargetSdkVersion = options.androidTargetSdkVersion || ''; + bp.androidSdkManagerParameters = options.androidSdkManagerParameters || ''; + bp.androidExportType = options.androidExportType || 'androidPackage'; + bp.androidSymbolType = options.androidSymbolType || 'none'; + + // Docker / container settings + bp.dockerCpuLimit = options.dockerCpuLimit || ''; + bp.dockerMemoryLimit = options.dockerMemoryLimit || ''; + bp.dockerIsolationMode = options.dockerIsolationMode || 'default'; + bp.containerRegistryRepository = options.containerRegistryRepository || 'unityci/editor'; + bp.containerRegistryImageVersion = options.containerRegistryImageVersion || '3'; + bp.dockerWorkspacePath = options.dockerWorkspacePath || '/github/workspace'; + + // Provider / orchestrator settings + bp.providerStrategy = options.providerStrategy || 'local-docker'; + bp.customParameters = options.customParameters || ''; + bp.sshAgent = options.sshAgent || ''; + bp.sshPublicKeysDirectoryPath = options.sshPublicKeysDirectoryPath || ''; + bp.gitPrivateToken = options.gitPrivateToken || ''; + bp.runAsHostUser = options.runAsHostUser || 'false'; + bp.chownFilesTo = options.chownFilesTo || ''; + + // AWS + bp.awsStackName = options.awsStackName || 'game-ci'; + bp.awsEndpoint = options.awsEndpoint; + bp.awsCloudFormationEndpoint = options.awsCloudFormationEndpoint || options.awsEndpoint; + bp.awsEcsEndpoint = options.awsEcsEndpoint || options.awsEndpoint; + bp.awsKinesisEndpoint = options.awsKinesisEndpoint || options.awsEndpoint; + bp.awsCloudWatchLogsEndpoint = options.awsCloudWatchLogsEndpoint || options.awsEndpoint; + bp.awsS3Endpoint = options.awsS3Endpoint || options.awsEndpoint; + + // Storage + bp.storageProvider = options.storageProvider || 's3'; + bp.rcloneRemote = options.rcloneRemote || ''; + + // Kubernetes + bp.kubeConfig = options.kubeConfig || ''; + bp.kubeVolume = options.kubeVolume || ''; + bp.kubeVolumeSize = options.kubeVolumeSize || '25Gi'; + bp.kubeStorageClass = options.kubeStorageClass || ''; + + // Container resources + bp.containerMemory = options.containerMemory || '3072'; + bp.containerCpu = options.containerCpu || '1024'; + bp.containerNamespace = options.containerNamespace || 'default'; + + // Hooks + bp.commandHooks = options.commandHooks || ''; + bp.postBuildContainerHooks = options.postBuildContainerHooks || ''; + bp.preBuildContainerHooks = options.preBuildContainerHooks || ''; + bp.customJob = options.customJob || ''; + + // Git / CI + bp.runNumber = options.runNumber || process.env.GITHUB_RUN_NUMBER || '0'; + bp.branch = options.branch || process.env.GITHUB_REF?.replace('refs/', '').replace('heads/', '') || ''; + bp.githubRepo = options.githubRepo || process.env.GITHUB_REPOSITORY || ''; + bp.orchestratorRepoName = options.orchestratorRepoName || 'game-ci/unity-builder'; + bp.cloneDepth = Number.parseInt(options.cloneDepth || '50', 10); + bp.gitSha = options.gitSha || process.env.GITHUB_SHA || ''; + bp.orchestratorBranch = options.orchestratorBranch || 'main'; + bp.orchestratorDebug = options.orchestratorDebug === true || options.orchestratorDebug === 'true'; + + // Build platform + bp.buildPlatform = options.buildPlatform || (bp.providerStrategy !== 'local' ? 'linux' : process.platform); + bp.isCliMode = true; + + // Caching + bp.cacheKey = options.cacheKey || bp.branch; + bp.pullInputList = options.pullInputList ? options.pullInputList.split(',') : []; + bp.inputPullCommand = options.inputPullCommand || ''; + + // Advanced + bp.maxRetainedWorkspaces = Number.parseInt(options.maxRetainedWorkspaces || '0', 10); + bp.useLargePackages = options.useLargePackages === true || options.useLargePackages === 'true'; + bp.useCompressionStrategy = options.useCompressionStrategy === true || options.useCompressionStrategy === 'true'; + bp.garbageMaxAge = Number(options.garbageMaxAge) || 24; + bp.githubChecks = options.githubChecks === true || options.githubChecks === 'true'; + bp.asyncWorkflow = options.asyncOrchestrator === true || options.asyncOrchestrator === 'true'; + bp.githubCheckId = options.githubCheckId || ''; + bp.finalHooks = options.finalHooks ? options.finalHooks.split(',') : []; + bp.skipLfs = options.skipLfs === true || options.skipLfs === 'true'; + bp.skipCache = options.skipCache === true || options.skipCache === 'true'; + bp.cacheUnityInstallationOnMac = + options.cacheUnityInstallationOnMac === true || options.cacheUnityInstallationOnMac === 'true'; + bp.unityHubVersionOnMac = options.unityHubVersionOnMac || ''; + + // IDs + bp.logId = customAlphabet(OrchestratorConstants.alphabet, 9)(); + bp.buildGuid = options.buildGuid || `${bp.runNumber}-${bp.targetPlatform}`; + + return bp; +} diff --git a/src/cli-plugin/cli-plugin.test.ts b/src/cli-plugin/cli-plugin.test.ts new file mode 100644 index 000000000..6e902cbc2 --- /dev/null +++ b/src/cli-plugin/cli-plugin.test.ts @@ -0,0 +1,94 @@ +import orchestratorPlugin from './index'; +import { createBuildParametersFromCliOptions } from './build-parameters-adapter'; + +describe('CLI Plugin Adapter', () => { + describe('orchestratorPlugin', () => { + it('has required GameCIPlugin fields', () => { + expect(orchestratorPlugin.name).toBe('orchestrator'); + expect(orchestratorPlugin.version).toBe('3.0.0'); + }); + + it('registers options plugin with wildcard engine', () => { + expect(orchestratorPlugin.options).toHaveLength(1); + expect(orchestratorPlugin.options[0].engine).toBe('*'); + expect(typeof orchestratorPlugin.options[0].configure).toBe('function'); + }); + + it('exposes all built-in provider strategies', () => { + const providers = orchestratorPlugin.providers; + expect(providers).toHaveProperty('aws'); + expect(providers).toHaveProperty('k8s'); + expect(providers).toHaveProperty('local-docker'); + expect(providers).toHaveProperty('local-system'); + expect(providers).toHaveProperty('local'); + expect(providers).toHaveProperty('test'); + }); + + it('provider constructors are functions', () => { + for (const [, Ctor] of Object.entries(orchestratorPlugin.providers)) { + expect(typeof Ctor).toBe('function'); + } + }); + }); + + describe('configureOrchestratorOptions', () => { + it('registers options on a yargs-like object', () => { + const registered: Record = {}; + const mockYargs = { + option(name: string, config: any) { + registered[name] = config; + + return mockYargs; + }, + }; + + orchestratorPlugin.options[0].configure(mockYargs); + + // Spot-check key options + + expect(registered).toHaveProperty('containerCpu'); + expect(registered).toHaveProperty('containerMemory'); + expect(registered).toHaveProperty('awsStackName'); + expect(registered).toHaveProperty('kubeConfig'); + expect(registered).toHaveProperty('storageProvider'); + expect(registered).toHaveProperty('commandHooks'); + expect(registered).toHaveProperty('orchestratorDebug'); + expect(registered).toHaveProperty('region'); + }); + }); + + describe('createBuildParametersFromCliOptions', () => { + it('maps yargs options to BuildParameters', () => { + const bp = createBuildParametersFromCliOptions({ + providerStrategy: 'aws', + containerMemory: '4096', + containerCpu: '2048', + awsStackName: 'my-stack', + targetPlatform: 'StandaloneLinux64', + buildName: 'MyGame', + kubeConfig: 'base64config', + }); + + expect(bp.providerStrategy).toBe('aws'); + expect(bp.containerMemory).toBe('4096'); + expect(bp.containerCpu).toBe('2048'); + expect(bp.awsStackName).toBe('my-stack'); + expect(bp.targetPlatform).toBe('StandaloneLinux64'); + expect(bp.buildName).toBe('MyGame'); + expect(bp.kubeConfig).toBe('base64config'); + expect(bp.isCliMode).toBe(true); + }); + + it('applies defaults for missing options', () => { + const bp = createBuildParametersFromCliOptions({}); + + expect(bp.providerStrategy).toBe('local-docker'); + expect(bp.containerMemory).toBe('3072'); + expect(bp.containerCpu).toBe('1024'); + expect(bp.awsStackName).toBe('game-ci'); + expect(bp.containerNamespace).toBe('default'); + expect(bp.kubeVolumeSize).toBe('25Gi'); + expect(bp.storageProvider).toBe('s3'); + }); + }); +}); diff --git a/src/cli-plugin/index.ts b/src/cli-plugin/index.ts new file mode 100644 index 000000000..b188d96b5 --- /dev/null +++ b/src/cli-plugin/index.ts @@ -0,0 +1,65 @@ +/** + * @game-ci/orchestrator-plugin + * + * CLI plugin adapter for the unity-builder orchestrator. + * Exports a GameCIPlugin that the CLI consumes via PluginRegistry. + * + * Usage in CLI: + * import orchestratorPlugin from '@game-ci/orchestrator-plugin'; + * await PluginRegistry.register(orchestratorPlugin); + * + * Or via plugin loader: + * await PluginLoader.load('@game-ci/orchestrator-plugin'); + */ + +import AwsBuildPlatform from '../model/orchestrator/providers/aws'; +import Kubernetes from '../model/orchestrator/providers/k8s'; +import LocalDockerOrchestrator from '../model/orchestrator/providers/docker'; +import LocalOrchestrator from '../model/orchestrator/providers/local'; +import TestOrchestrator from '../model/orchestrator/providers/test'; +import { configureOrchestratorOptions } from './orchestrator-options-plugin'; +import { createProviderAdapter } from './provider-adapter'; + +/** + * GameCIPlugin-compatible export. + * + * This object matches the GameCIPlugin interface defined in @game-ci/cli: + * - name, version: plugin metadata + * - options: registers orchestrator-specific CLI options (aws, k8s, hooks, etc.) + * - providers: maps strategy names to provider constructors + */ +const orchestratorPlugin = { + name: 'orchestrator', + version: '3.0.0', + + /** + * Options plugins — register orchestrator-specific yargs options. + * engine: '*' means these options apply regardless of which engine is detected. + */ + options: [ + { + engine: '*', + configure: configureOrchestratorOptions, + }, + ], + + /** + * Provider constructors keyed by strategy name. + * Each is wrapped via createProviderAdapter so the CLI can instantiate them + * with yargs options (flat key-value) instead of BuildParameters directly. + */ + providers: { + aws: createProviderAdapter(AwsBuildPlatform), + k8s: createProviderAdapter(Kubernetes), + 'local-docker': createProviderAdapter(LocalDockerOrchestrator), + 'local-system': createProviderAdapter(LocalOrchestrator), + local: createProviderAdapter(LocalOrchestrator), + test: createProviderAdapter(TestOrchestrator), + }, +}; + +export default orchestratorPlugin; +export { orchestratorPlugin }; +export { createBuildParametersFromCliOptions } from './build-parameters-adapter'; +export { configureOrchestratorOptions } from './orchestrator-options-plugin'; +export { createProviderAdapter } from './provider-adapter'; diff --git a/src/cli-plugin/orchestrator-options-plugin.ts b/src/cli-plugin/orchestrator-options-plugin.ts new file mode 100644 index 000000000..cc375006d --- /dev/null +++ b/src/cli-plugin/orchestrator-options-plugin.ts @@ -0,0 +1,269 @@ +/** + * Registers all orchestrator-specific options with the CLI's yargs instance. + * + * These options are provider-specific (aws, k8s, storage, hooks, etc.) and + * were previously hardcoded in the CLI's RemoteOptions. Now they live here + * in the orchestrator plugin where they belong. + */ +export function configureOrchestratorOptions(yargs: any): void { + // --- Provider parameters --- + yargs.option('region', { + description: 'Cloud provider region', + type: 'string', + default: 'eu-west-2', + }); + + yargs.option('buildPlatform', { + description: 'Build platform (linux, win32, darwin)', + type: 'string', + }); + + // --- Container resources --- + yargs.option('containerCpu', { + description: 'Container CPU units (1024 = 1 vCPU)', + type: 'string', + default: '1024', + }); + + yargs.option('containerMemory', { + description: 'Container memory in MB', + type: 'string', + default: '3072', + }); + + yargs.option('containerNamespace', { + description: 'Container/Kubernetes namespace', + type: 'string', + default: 'default', + }); + + // --- AWS options --- + yargs.option('awsStackName', { + description: 'AWS CloudFormation stack name', + type: 'string', + default: 'game-ci', + }); + + yargs.option('awsEndpoint', { + description: 'AWS endpoint override (e.g., for LocalStack)', + type: 'string', + }); + + yargs.option('awsCloudFormationEndpoint', { + description: 'AWS CloudFormation endpoint override', + type: 'string', + }); + + yargs.option('awsEcsEndpoint', { + description: 'AWS ECS endpoint override', + type: 'string', + }); + + yargs.option('awsKinesisEndpoint', { + description: 'AWS Kinesis endpoint override', + type: 'string', + }); + + yargs.option('awsCloudWatchLogsEndpoint', { + description: 'AWS CloudWatch Logs endpoint override', + type: 'string', + }); + + yargs.option('awsS3Endpoint', { + description: 'AWS S3 endpoint override', + type: 'string', + }); + + // --- Kubernetes options --- + yargs.option('kubeConfig', { + description: 'Kubernetes config (base64 encoded or path)', + type: 'string', + default: '', + }); + + yargs.option('kubeVolume', { + description: 'Kubernetes persistent volume name', + type: 'string', + default: '', + }); + + yargs.option('kubeVolumeSize', { + description: 'Kubernetes persistent volume size', + type: 'string', + default: '25Gi', + }); + + yargs.option('kubeStorageClass', { + description: 'Kubernetes storage class', + type: 'string', + default: '', + }); + + // --- Storage --- + yargs.option('storageProvider', { + description: 'Remote storage provider (s3, gcs, etc.)', + type: 'string', + default: 's3', + }); + + yargs.option('rcloneRemote', { + description: 'Rclone remote name for storage', + type: 'string', + default: '', + }); + + // --- Hooks --- + yargs.option('containerHookFiles', { + description: 'Comma-separated container hook file paths', + type: 'string', + default: '', + }); + + yargs.option('commandHookFiles', { + description: 'Comma-separated command hook file paths', + type: 'string', + default: '', + }); + + yargs.option('commandHooks', { + description: 'YAML command hooks', + type: 'string', + default: '', + }); + + yargs.option('postBuildContainerHooks', { + description: 'Post-build container hooks (YAML)', + type: 'string', + default: '', + }); + + yargs.option('preBuildContainerHooks', { + description: 'Pre-build container hooks (YAML)', + type: 'string', + default: '', + }); + + yargs.option('finalHooks', { + description: 'Comma-separated final hook workflows to trigger', + type: 'string', + default: '', + }); + + // --- Input override --- + yargs.option('pullInputList', { + description: 'Comma-separated list of inputs to pull from secret manager', + type: 'string', + default: '', + }); + + yargs.option('inputPullCommand', { + description: 'Command template for pulling secrets (gcp-secret-manager, aws-secret-manager, or custom)', + type: 'string', + default: '', + }); + + // --- Git / orchestrator --- + yargs.option('orchestratorBranch', { + description: 'Orchestrator repo branch', + type: 'string', + default: 'main', + }); + + yargs.option('orchestratorRepoName', { + description: 'Orchestrator GitHub repo', + type: 'string', + default: 'game-ci/unity-builder', + }); + + yargs.option('cloneDepth', { + description: 'Git clone depth', + type: 'string', + default: '50', + }); + + // --- Caching --- + yargs.option('cacheKey', { + description: 'Cache key for build caching', + type: 'string', + }); + + yargs.option('skipLfs', { + description: 'Skip Git LFS', + type: 'boolean', + default: false, + }); + + yargs.option('skipCache', { + description: 'Skip caching', + type: 'boolean', + default: false, + }); + + // --- Advanced --- + yargs.option('orchestratorDebug', { + description: 'Enable orchestrator debug logging', + type: 'boolean', + default: false, + }); + + yargs.option('asyncOrchestrator', { + description: 'Enable async workflow mode', + type: 'boolean', + default: false, + }); + + yargs.option('resourceTracking', { + description: 'Enable resource tracking', + type: 'boolean', + default: false, + }); + + yargs.option('useLargePackages', { + description: 'Use large packages mode', + type: 'boolean', + default: false, + }); + + yargs.option('useSharedBuilder', { + description: 'Use shared builder', + type: 'boolean', + default: false, + }); + + yargs.option('useCompressionStrategy', { + description: 'Enable compression strategy', + type: 'boolean', + default: false, + }); + + yargs.option('useCleanupCron', { + description: 'Enable cleanup cron', + type: 'boolean', + default: true, + }); + + yargs.option('maxRetainedWorkspaces', { + description: 'Max retained workspaces for shared builds', + type: 'string', + default: '0', + }); + + yargs.option('garbageMaxAge', { + description: 'Max age in hours for garbage collection', + type: 'number', + default: 24, + }); + + // --- GitHub integration --- + yargs.option('githubChecks', { + description: 'Enable GitHub Checks integration', + type: 'boolean', + default: false, + }); + + yargs.option('githubCheckId', { + description: 'Existing GitHub Check ID to update', + type: 'string', + default: '', + }); +} diff --git a/src/cli-plugin/provider-adapter.ts b/src/cli-plugin/provider-adapter.ts new file mode 100644 index 000000000..e6b2ca7eb --- /dev/null +++ b/src/cli-plugin/provider-adapter.ts @@ -0,0 +1,67 @@ +import { ProviderInterface } from '../model/orchestrator/providers/provider-interface'; +import { createBuildParametersFromCliOptions } from './build-parameters-adapter'; + +/** + * Wraps an orchestrator ProviderInterface constructor so it can be consumed + * by the CLI's PluginRegistry as a ProviderPlugin. + * + * The CLI calls `new ProviderPlugin(yargsOptions)` — this adapter converts + * the flat yargs options into a BuildParameters object and then instantiates + * the real provider. + */ +export function createProviderAdapter( + // eslint-disable-next-line no-unused-vars + ProviderClass: new (buildParameters: any) => ProviderInterface, + // eslint-disable-next-line no-unused-vars +): new (options: any) => any { + return class ProviderAdapter { + private provider: ProviderInterface; + + constructor(options: Record) { + const buildParameters = createBuildParametersFromCliOptions(options); + this.provider = new ProviderClass(buildParameters); + } + + async cleanupWorkflow(buildParameters: any, branchName: string, defaultSecretsArray: any[]) { + return this.provider.cleanupWorkflow(buildParameters, branchName, defaultSecretsArray); + } + + async setupWorkflow(buildGuid: string, buildParameters: any, branchName: string, defaultSecretsArray: any[]) { + return this.provider.setupWorkflow(buildGuid, buildParameters, branchName, defaultSecretsArray); + } + + async runTaskInWorkflow( + buildGuid: string, + image: string, + commands: string, + mountdir: string, + workingdir: string, + environment: any[], + secrets: any[], + ): Promise { + return this.provider.runTaskInWorkflow(buildGuid, image, commands, mountdir, workingdir, environment, secrets); + } + + async garbageCollect( + filter: string, + previewOnly: boolean, + olderThan: number, + fullCache: boolean, + baseDependencies: boolean, + ): Promise { + return this.provider.garbageCollect(filter, previewOnly, olderThan, fullCache, baseDependencies); + } + + async listResources(): Promise { + return this.provider.listResources(); + } + + async listWorkflow(): Promise { + return this.provider.listWorkflow(); + } + + async watchWorkflow(): Promise { + return this.provider.watchWorkflow(); + } + }; +}