feat(orchestrator): middleware pipeline — composable trigger-aware hooks

Add a middleware system that wraps around build pipeline phases with
before/after semantics, built on the existing command hook and container
hook fundamentals. Middleware supports rich trigger conditions (phase,
provider, platform, environment expressions), priority-based ordering
with wrapping semantics, and both inline YAML and file-based definitions
from game-ci/middleware/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
frostebite
2026-03-06 22:57:20 +00:00
parent 488c259481
commit 483bab04c7
10 changed files with 1388 additions and 5 deletions

View File

@@ -387,6 +387,21 @@ inputs:
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.'
middleware:
required: false
default: ''
description:
'[Orchestrator] Inline YAML middleware pipeline definitions. Middleware wraps around build phases with
before/after semantics, supports trigger conditions (phase, provider, platform, environment expressions), and
resolves to either command hooks (inline in build container) or container hooks (separate Docker containers).
Middleware executes in priority order — lower priority values run first for before phases and last for after
phases (wrapping pattern). See documentation for full YAML format.'
middlewareFiles:
required: false
default: ''
description:
'[Orchestrator] Comma-separated list of middleware file names (without extension) to load from the
game-ci/middleware/ directory in your repository. Each file should contain YAML middleware definitions.'
outputs:
volume:

451
dist/index.js generated vendored
View File

@@ -472,6 +472,8 @@ class BuildParameters {
gitHooksSkipList: input_1.default.gitHooksSkipList,
gitHooksRunBeforeBuild: input_1.default.gitHooksRunBeforeBuild,
providerExecutable: input_1.default.providerExecutable,
middleware: orchestrator_options_1.default.middleware,
middlewareFiles: orchestrator_options_1.default.middlewareFiles,
};
}
static parseBuildFile(filename, platform, androidExportType) {
@@ -2011,6 +2013,12 @@ class Input {
static get providerExecutable() {
return Input.getInput('providerExecutable') ?? '';
}
static get middleware() {
return Input.getInput('middleware') ?? '';
}
static get middlewareFiles() {
return Input.getInput('middlewareFiles') ?? '';
}
static ToEnvVarFormat(input) {
if (input.toUpperCase() === input) {
return input;
@@ -2410,6 +2418,12 @@ class OrchestratorOptions {
static get commandHookFiles() {
return OrchestratorOptions.getInput('commandHookFiles')?.split(`,`) || [];
}
static get middleware() {
return OrchestratorOptions.getInput('middleware') || '';
}
static get middlewareFiles() {
return OrchestratorOptions.getInput('middlewareFiles')?.split(',') || [];
}
// ### ### ###
// Custom commands from yaml parameters
// ### ### ###
@@ -10422,6 +10436,24 @@ echo "---${buildParameters.logId}"`;
exports.CommandHookService = CommandHookService;
/***/ }),
/***/ 59619:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.CommandHook = void 0;
class CommandHook {
constructor() {
this.commands = new Array();
this.secrets = new Array();
}
}
exports.CommandHook = CommandHook;
/***/ }),
/***/ 80824:
@@ -10792,6 +10824,25 @@ class ContainerHookService {
exports.ContainerHookService = ContainerHookService;
/***/ }),
/***/ 54667:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.ContainerHook = void 0;
class ContainerHook {
constructor() {
this.secrets = new Array();
this.image = `ubuntu`;
this.allowFailure = false; // If true, hook failures won't stop the build
}
}
exports.ContainerHook = ContainerHook;
/***/ }),
/***/ 9146:
@@ -11009,6 +11060,360 @@ exports.GitHooksService = GitHooksService;
GitHooksService.UNITY_GIT_HOOKS_PACKAGE = 'com.frostebite.unitygithooks';
/***/ }),
/***/ 78089:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.MiddlewareService = void 0;
const yaml_1 = __importDefault(__nccwpck_require__(44083));
const node_fs_1 = __importDefault(__nccwpck_require__(87561));
const node_path_1 = __importDefault(__nccwpck_require__(49411));
const middleware_1 = __nccwpck_require__(86786);
const container_hook_1 = __nccwpck_require__(54667);
const command_hook_1 = __nccwpck_require__(59619);
const orchestrator_options_1 = __importDefault(__nccwpck_require__(82473));
const orchestrator_1 = __importDefault(__nccwpck_require__(8330));
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
const input_1 = __importDefault(__nccwpck_require__(91933));
/**
* Service for loading, evaluating, and resolving middleware into hooks.
*
* Middleware is a higher-level composable abstraction over the existing
* command hook and container hook systems. Each middleware:
* - Wraps around pipeline phases with before/after semantics
* - Has trigger conditions (phase, provider, platform, expression)
* - Resolves to either CommandHooks or ContainerHooks at execution time
* - Executes in priority order (before: ascending, after: descending)
*/
class MiddlewareService {
/**
* Load all active middleware from inline YAML + file-based definitions.
* Returns them sorted by priority (ascending).
*/
static getMiddleware(inlineYaml) {
const middleware = [];
// Parse inline YAML definitions
if (inlineYaml && inlineYaml !== '') {
middleware.push(...MiddlewareService.parseMiddleware(inlineYaml));
}
// Load file-based definitions from game-ci/middleware/
middleware.push(...MiddlewareService.getMiddlewareFromFiles());
// Sort by priority (lower = earlier)
middleware.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
orchestrator_logger_1.default.log(`Middleware: loaded ${middleware.length} definition(s)`);
return middleware;
}
/**
* Resolve middleware to CommandHooks for a given phase and timing.
* Filters by trigger conditions and converts to hooks.
*
* Before hooks: ascending priority (lowest priority runs first, closest to core phase).
* After hooks: descending priority (highest priority runs first, closest to core phase).
* This produces the wrapping pattern: outermost middleware's before runs first and after runs last.
*/
static resolveCommandHooks(middleware, phase, timing) {
const applicable = middleware
.filter((m) => m.type === 'command')
.filter((m) => MiddlewareService.evaluateTrigger(m.trigger, phase));
// before: ascending priority; after: descending (wrapping order)
if (timing === 'after') {
applicable.reverse();
}
const hooks = applicable
.filter((m) => (timing === 'before' ? m.before : m.after))
.map((m) => {
const mPhase = timing === 'before' ? m.before : m.after;
const hook = new command_hook_1.CommandHook();
hook.name = `middleware:${m.name}:${timing}`;
hook.commands = typeof mPhase.commands === 'string' ? [mPhase.commands] : [mPhase.commands];
hook.hook = [timing];
hook.step = [phase];
hook.secrets = m.secrets || [];
return hook;
});
if (hooks.length > 0) {
orchestrator_logger_1.default.log(`Middleware: resolved ${hooks.length} command hook(s) for ${phase}:${timing}${hooks
.map((h) => h.name)
.join(', ')}`);
}
return hooks;
}
/**
* Resolve middleware to ContainerHooks for a given phase and timing.
* Same ordering logic as resolveCommandHooks.
*/
static resolveContainerHooks(middleware, phase, timing) {
const applicable = middleware
.filter((m) => m.type === 'container')
.filter((m) => MiddlewareService.evaluateTrigger(m.trigger, phase));
// before: ascending priority; after: descending (wrapping order)
if (timing === 'after') {
applicable.reverse();
}
const hooks = applicable
.filter((m) => (timing === 'before' ? m.before : m.after))
.map((m) => {
const mPhase = timing === 'before' ? m.before : m.after;
const hook = new container_hook_1.ContainerHook();
hook.name = `middleware:${m.name}:${timing}`;
hook.commands = typeof mPhase.commands === 'string' ? mPhase.commands : mPhase.commands;
hook.image = mPhase.image || m.image || 'ubuntu';
hook.hook = timing === 'before' ? 'before' : 'after';
hook.secrets = m.secrets || [];
hook.allowFailure = m.allowFailure ?? false;
return hook;
});
if (hooks.length > 0) {
orchestrator_logger_1.default.log(`Middleware: resolved ${hooks.length} container hook(s) for ${phase}:${timing}${hooks
.map((h) => h.name)
.join(', ')}`);
}
return hooks;
}
/**
* Evaluate whether a middleware's trigger conditions are met.
* All specified conditions must pass (AND logic).
*/
static evaluateTrigger(trigger, currentPhase) {
// Phase must match
if (!trigger.phase || !trigger.phase.includes(currentPhase)) {
return false;
}
// Provider filter (if specified)
if (trigger.provider && trigger.provider.length > 0) {
const currentProvider = orchestrator_1.default.buildParameters?.providerStrategy || orchestrator_options_1.default.providerStrategy;
if (!trigger.provider.includes(currentProvider)) {
return false;
}
}
// Platform filter (if specified)
if (trigger.platform && trigger.platform.length > 0) {
const currentPlatform = orchestrator_1.default.buildParameters?.targetPlatform || '';
if (!trigger.platform.includes(currentPlatform)) {
return false;
}
}
// Expression-based condition
if (trigger.when) {
if (!MiddlewareService.evaluateExpression(trigger.when)) {
return false;
}
}
return true;
}
/**
* Evaluate a simple expression string against environment variables.
*
* Supported formats:
* - env.VAR_NAME == 'value' equality check
* - env.VAR_NAME != 'value' inequality check
* - env.VAR_NAME truthy check (defined, non-empty, not 'false')
* - !env.VAR_NAME falsy check
*/
static evaluateExpression(expression) {
const trimmed = expression.trim();
// Match: env.VAR == 'value' or env.VAR != 'value'
const comparisonMatch = trimmed.match(/^env\.(\w+)\s*(==|!=)\s*['"](.*)['"]$/);
if (comparisonMatch) {
const [, varName, operator, value] = comparisonMatch;
const envValue = process.env[varName] || '';
return operator === '==' ? envValue === value : envValue !== value;
}
// Match: !env.VAR (falsy check)
const falsyMatch = trimmed.match(/^!env\.(\w+)$/);
if (falsyMatch) {
const [, varName] = falsyMatch;
const envValue = process.env[varName];
return envValue === undefined || envValue === '' || envValue === 'false';
}
// Match: env.VAR (truthy check)
const truthyMatch = trimmed.match(/^env\.(\w+)$/);
if (truthyMatch) {
const [, varName] = truthyMatch;
const envValue = process.env[varName];
return envValue !== undefined && envValue !== '' && envValue !== 'false';
}
// Unknown expression format — log warning, default to true
orchestrator_logger_1.default.logWarning(`Middleware: unknown expression format "${expression}", defaulting to true`);
return true;
}
/**
* Parse middleware definitions from a YAML string.
* Accepts both single-object and array format.
*/
static parseMiddleware(yamlString) {
if (!yamlString || yamlString.trim() === '') {
return [];
}
try {
const isArray = yamlString.replace(/\s/g, '')[0] === '-';
const parsed = isArray ? yaml_1.default.parse(yamlString) : [yaml_1.default.parse(yamlString)];
if (!parsed || !Array.isArray(parsed)) {
return [];
}
return parsed.map((m) => MiddlewareService.hydrateMiddleware(m));
}
catch (error) {
orchestrator_logger_1.default.logWarning(`Middleware: failed to parse YAML — ${error.message}`);
return [];
}
}
/**
* Hydrate a raw parsed YAML object into a Middleware instance.
*/
static hydrateMiddleware(m) {
const middleware = new middleware_1.Middleware();
middleware.name = m.name || 'unnamed';
middleware.description = m.description;
middleware.type = m.type || 'command';
middleware.priority = m.priority ?? 100;
middleware.image = m.image || 'ubuntu';
middleware.allowFailure = m.allowFailure ?? false;
middleware.outputs = m.outputs;
// Parse trigger — normalize scalar values to arrays
middleware.trigger = {
phase: MiddlewareService.toStringArray(m.trigger?.phase),
provider: m.trigger?.provider ? MiddlewareService.toStringArray(m.trigger.provider) : undefined,
platform: m.trigger?.platform ? MiddlewareService.toStringArray(m.trigger.platform) : undefined,
when: m.trigger?.when,
};
// Parse before/after phases — accept string shorthand or object format
if (m.before) {
middleware.before = {
commands: typeof m.before === 'string' ? m.before : m.before.commands || '',
image: typeof m.before === 'string' ? undefined : m.before.image,
};
}
if (m.after) {
middleware.after = {
commands: typeof m.after === 'string' ? m.after : m.after.commands || '',
image: typeof m.after === 'string' ? undefined : m.after.image,
};
}
// Parse secrets
if (m.secrets && Array.isArray(m.secrets)) {
middleware.secrets = m.secrets.map((s) => ({
ParameterKey: s.name,
EnvironmentVariable: input_1.default.ToEnvVarFormat(s.name),
ParameterValue: s.value ?? process.env[s.name] ?? process.env[input_1.default.ToEnvVarFormat(s.name)] ?? '',
}));
}
return middleware;
}
/**
* Load middleware definitions from game-ci/middleware/ directory files.
* Only files whose base name appears in the middlewareFiles allowlist are loaded.
*/
static getMiddlewareFromFiles() {
const results = [];
const allowedFiles = orchestrator_options_1.default.middlewareFiles;
if (!allowedFiles || allowedFiles.length === 0) {
return results;
}
try {
const middlewarePath = node_path_1.default.join(process.cwd(), 'game-ci', 'middleware');
if (!node_fs_1.default.existsSync(middlewarePath)) {
return results;
}
const files = node_fs_1.default.readdirSync(middlewarePath);
for (const file of files) {
const baseName = file.replace(/\.ya?ml$/, '');
if (!allowedFiles.includes(baseName)) {
continue;
}
try {
const contents = node_fs_1.default.readFileSync(node_path_1.default.join(middlewarePath, file), 'utf8');
results.push(...MiddlewareService.parseMiddleware(contents));
}
catch (error) {
orchestrator_logger_1.default.logWarning(`Middleware: failed to parse file ${file}${error.message}`);
}
}
}
catch {
// Directory doesn't exist or can't be read — not an error
}
return results;
}
/**
* Normalize a value to a string array. Accepts string, string[], or undefined.
*/
static toStringArray(value) {
if (!value)
return [];
if (Array.isArray(value))
return value;
return [value];
}
}
exports.MiddlewareService = MiddlewareService;
/***/ }),
/***/ 86786:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.Middleware = void 0;
/**
* Middleware a composable, trigger-aware pipeline unit built on hooks.
*
* Middleware wraps around pipeline phases with before/after semantics.
* Each middleware resolves to either CommandHooks (inline in build container)
* or ContainerHooks (separate Docker containers) at execution time.
*
* Execution order: before phases run in ascending priority order,
* after phases run in descending priority order (wrapping pattern).
*
* Example YAML:
* ```yaml
* name: code-signing
* description: Signs build artifacts after successful build
* type: container
* priority: 50
* image: ubuntu:22.04
* trigger:
* phase: [build]
* provider: [aws, k8s]
* platform: [StandaloneWindows64]
* when: "env.SIGN_BUILDS == 'true'"
* before:
* commands: |
* echo "Preparing signing environment..."
* after:
* commands: |
* echo "Signing build artifacts..."
* secrets:
* - name: SIGNING_KEY
* - name: SIGNING_CERT
* allowFailure: false
* ```
*/
class Middleware {
constructor() {
/** Execution priority. Lower values run first for before, last for after (wrapping order). Default: 100 */
this.priority = 100;
/** Default Docker image for container type middleware */
this.image = 'ubuntu';
/** Secrets injected as environment variables */
this.secrets = [];
/** If true, failures warn but don't stop the build. Default: false */
this.allowFailure = false;
}
}
exports.Middleware = Middleware;
/***/ }),
/***/ 85985:
@@ -11424,29 +11829,67 @@ const command_hook_service_1 = __nccwpck_require__(66604);
const node_path_1 = __importDefault(__nccwpck_require__(49411));
const orchestrator_1 = __importDefault(__nccwpck_require__(8330));
const container_hook_service_1 = __nccwpck_require__(80824);
const middleware_service_1 = __nccwpck_require__(78089);
const custom_workflow_1 = __nccwpck_require__(19118);
class BuildAutomationWorkflow {
async run(orchestratorStepState) {
return await BuildAutomationWorkflow.standardBuildAutomation(orchestratorStepState.image, orchestratorStepState);
}
static async standardBuildAutomation(baseImage, orchestratorStepState) {
// TODO accept post and pre build steps as yaml files in the repo
orchestrator_logger_1.default.log(`Orchestrator is running standard build automation`);
let output = '';
// Load middleware pipeline
const middleware = middleware_service_1.MiddlewareService.getMiddleware(orchestrator_1.default.buildParameters.middleware);
// --- Pre-build phase ---
// Middleware container hooks: pre-build before
const preBuildMiddlewareBefore = middleware_service_1.MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
if (preBuildMiddlewareBefore.length > 0) {
output += await custom_workflow_1.CustomWorkflow.runContainerJob(preBuildMiddlewareBefore, orchestratorStepState.environment, orchestratorStepState.secrets);
orchestrator_logger_1.default.logWithTime('Middleware pre-build:before container step(s) time');
}
// Standard pre-build container hooks
output += await container_hook_service_1.ContainerHookService.RunPreBuildSteps(orchestratorStepState);
orchestrator_logger_1.default.logWithTime('Configurable pre build step(s) time');
// Middleware container hooks: pre-build after
const preBuildMiddlewareAfter = middleware_service_1.MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'after');
if (preBuildMiddlewareAfter.length > 0) {
output += await custom_workflow_1.CustomWorkflow.runContainerJob(preBuildMiddlewareAfter, orchestratorStepState.environment, orchestratorStepState.secrets);
orchestrator_logger_1.default.logWithTime('Middleware pre-build:after container step(s) time');
}
// --- Build phase ---
orchestrator_logger_1.default.log(baseImage);
orchestrator_logger_1.default.logLine(` `);
orchestrator_logger_1.default.logLine('Starting build automation job');
output += await orchestrator_1.default.Provider.runTaskInWorkflow(orchestrator_1.default.buildParameters.buildGuid, baseImage.toString(), BuildAutomationWorkflow.BuildWorkflow, `/${orchestrator_folders_1.OrchestratorFolders.buildVolumeFolder}`, `/${orchestrator_folders_1.OrchestratorFolders.buildVolumeFolder}/`, orchestratorStepState.environment, orchestratorStepState.secrets);
orchestrator_logger_1.default.logWithTime('Build time');
// --- Post-build phase ---
// Middleware container hooks: post-build before
const postBuildMiddlewareBefore = middleware_service_1.MiddlewareService.resolveContainerHooks(middleware, 'post-build', 'before');
if (postBuildMiddlewareBefore.length > 0) {
output += await custom_workflow_1.CustomWorkflow.runContainerJob(postBuildMiddlewareBefore, orchestratorStepState.environment, orchestratorStepState.secrets);
orchestrator_logger_1.default.logWithTime('Middleware post-build:before container step(s) time');
}
// Standard post-build container hooks
output += await container_hook_service_1.ContainerHookService.RunPostBuildSteps(orchestratorStepState);
orchestrator_logger_1.default.logWithTime('Configurable post build step(s) time');
// Middleware container hooks: post-build after
const postBuildMiddlewareAfter = middleware_service_1.MiddlewareService.resolveContainerHooks(middleware, 'post-build', 'after');
if (postBuildMiddlewareAfter.length > 0) {
output += await custom_workflow_1.CustomWorkflow.runContainerJob(postBuildMiddlewareAfter, orchestratorStepState.environment, orchestratorStepState.secrets);
orchestrator_logger_1.default.logWithTime('Middleware post-build:after container step(s) time');
}
orchestrator_logger_1.default.log(`Orchestrator finished running standard build automation`);
return output;
}
static get BuildWorkflow() {
const setupHooks = command_hook_service_1.CommandHookService.getHooks(orchestrator_1.default.buildParameters.commandHooks).filter((x) => x.step?.includes(`setup`));
const buildHooks = command_hook_service_1.CommandHookService.getHooks(orchestrator_1.default.buildParameters.commandHooks).filter((x) => x.step?.includes(`build`));
// Resolve middleware command hooks for setup and build phases
const middleware = middleware_service_1.MiddlewareService.getMiddleware(orchestrator_1.default.buildParameters.middleware);
const middlewareSetupBefore = middleware_service_1.MiddlewareService.resolveCommandHooks(middleware, 'setup', 'before');
const middlewareSetupAfter = middleware_service_1.MiddlewareService.resolveCommandHooks(middleware, 'setup', 'after');
const middlewareBuildBefore = middleware_service_1.MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
const middlewareBuildAfter = middleware_service_1.MiddlewareService.resolveCommandHooks(middleware, 'build', 'after');
const isContainerized = orchestrator_1.default.buildParameters.providerStrategy === 'aws' ||
orchestrator_1.default.buildParameters.providerStrategy === 'k8s' ||
orchestrator_1.default.buildParameters.providerStrategy === 'local-docker';
@@ -11461,6 +11904,7 @@ class BuildAutomationWorkflow {
${isContainerized && orchestrator_1.default.buildParameters.providerStrategy !== 'local-docker'
? 'apt-get install -y curl tar tree npm git-lfs jq git > /dev/null || true\n npm --version || true\n npm i -g n > /dev/null || true\n npm i -g semver > /dev/null || true\n npm install --global yarn > /dev/null || true\n n 20.8.0 || true\n node --version || true'
: '# skipping toolchain setup in local-docker or non-container provider'}
${middlewareSetupBefore.map((x) => x.commands).join('\n ') || ' '}
${setupHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
${orchestrator_1.default.buildParameters.providerStrategy === 'local-docker'
? `export GITHUB_WORKSPACE="${orchestrator_1.default.buildParameters.dockerWorkspacePath}"
@@ -11470,9 +11914,12 @@ class BuildAutomationWorkflow {
export LOG_FILE=${isContainerized ? '/home/job-log.txt' : '$(pwd)/temp/job-log.txt'}
${BuildAutomationWorkflow.setupCommands(builderPath, isContainerized)}
${setupHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
${middlewareSetupAfter.map((x) => x.commands).join('\n ') || ' '}
${middlewareBuildBefore.map((x) => x.commands).join('\n ') || ' '}
${buildHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
${BuildAutomationWorkflow.BuildCommands(builderPath, isContainerized)}
${buildHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}`;
${buildHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
${middlewareBuildAfter.map((x) => x.commands).join('\n ') || ' '}`;
}
static setupCommands(builderPath, isContainerized) {
// prettier-ignore

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -125,6 +125,8 @@ class BuildParameters {
public gitHooksSkipList!: string;
public gitHooksRunBeforeBuild!: string;
public providerExecutable!: string;
public middleware!: string;
public middlewareFiles!: string[];
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -280,6 +282,8 @@ class BuildParameters {
gitHooksSkipList: Input.gitHooksSkipList,
gitHooksRunBeforeBuild: Input.gitHooksRunBeforeBuild,
providerExecutable: Input.providerExecutable,
middleware: OrchestratorOptions.middleware,
middlewareFiles: OrchestratorOptions.middlewareFiles,
};
}

View File

@@ -358,6 +358,14 @@ class Input {
return Input.getInput('providerExecutable') ?? '';
}
static get middleware(): string {
return Input.getInput('middleware') ?? '';
}
static get middlewareFiles(): string {
return Input.getInput('middlewareFiles') ?? '';
}
public static ToEnvVarFormat(input: string) {
if (input.toUpperCase() === input) {
return input;

View File

@@ -166,6 +166,14 @@ class OrchestratorOptions {
return OrchestratorOptions.getInput('commandHookFiles')?.split(`,`) || [];
}
static get middleware(): string {
return OrchestratorOptions.getInput('middleware') || '';
}
static get middlewareFiles(): string[] {
return OrchestratorOptions.getInput('middlewareFiles')?.split(',') || [];
}
// ### ### ###
// Custom commands from yaml parameters
// ### ### ###

View File

@@ -0,0 +1,422 @@
import { MiddlewareService } from './middleware-service';
import { Middleware } from './middleware';
// Mock dependencies
jest.mock('../../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
providerStrategy: 'aws',
targetPlatform: 'StandaloneLinux64',
},
},
}));
jest.mock('../../options/orchestrator-options', () => ({
__esModule: true,
default: {
providerStrategy: 'aws',
middlewareFiles: ['code-signing', 'cache-optimizer'],
},
}));
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
},
}));
describe('MiddlewareService', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
describe('parseMiddleware', () => {
it('should parse a single middleware definition', () => {
const yaml = `
name: test-middleware
type: command
priority: 50
trigger:
phase: [build]
before:
commands: echo "before"
after:
commands: echo "after"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('test-middleware');
expect(result[0].type).toBe('command');
expect(result[0].priority).toBe(50);
expect(result[0].trigger.phase).toEqual(['build']);
expect(result[0].before?.commands).toBe('echo "before"');
expect(result[0].after?.commands).toBe('echo "after"');
});
it('should parse an array of middleware definitions', () => {
const yaml = `
- name: first
type: command
trigger:
phase: [setup]
before:
commands: echo "first"
- name: second
type: container
image: node:20
trigger:
phase: [build]
after:
commands: echo "second"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('first');
expect(result[0].type).toBe('command');
expect(result[1].name).toBe('second');
expect(result[1].type).toBe('container');
expect(result[1].image).toBe('node:20');
});
it('should handle string shorthand for before/after', () => {
const yaml = `
name: shorthand
type: command
trigger:
phase: [build]
before: echo "shorthand before"
after: echo "shorthand after"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result).toHaveLength(1);
expect(result[0].before?.commands).toBe('echo "shorthand before"');
expect(result[0].after?.commands).toBe('echo "shorthand after"');
});
it('should parse secrets', () => {
const yaml = `
name: with-secrets
type: container
trigger:
phase: [build]
secrets:
- name: MY_SECRET
value: secret-value
- name: ANOTHER_SECRET
after:
commands: echo "done"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result[0].secrets).toHaveLength(2);
expect(result[0].secrets[0].ParameterKey).toBe('MY_SECRET');
expect(result[0].secrets[0].ParameterValue).toBe('secret-value');
expect(result[0].secrets[1].ParameterKey).toBe('ANOTHER_SECRET');
});
it('should default priority to 100', () => {
const yaml = `
name: no-priority
type: command
trigger:
phase: [build]
before: echo "test"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result[0].priority).toBe(100);
});
it('should default type to command', () => {
const yaml = `
name: no-type
trigger:
phase: [build]
before: echo "test"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result[0].type).toBe('command');
});
it('should return empty array for empty input', () => {
expect(MiddlewareService.parseMiddleware('')).toEqual([]);
expect(MiddlewareService.parseMiddleware(' ')).toEqual([]);
});
it('should normalize scalar trigger values to arrays', () => {
const yaml = `
name: scalar-triggers
type: command
trigger:
phase: build
provider: aws
platform: StandaloneLinux64
before: echo "test"
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result[0].trigger.phase).toEqual(['build']);
expect(result[0].trigger.provider).toEqual(['aws']);
expect(result[0].trigger.platform).toEqual(['StandaloneLinux64']);
});
it('should handle per-phase image override for container type', () => {
const yaml = `
name: multi-image
type: container
image: ubuntu
trigger:
phase: [build]
before:
image: node:20
commands: npm install
after:
image: python:3
commands: python verify.py
`;
const result = MiddlewareService.parseMiddleware(yaml);
expect(result[0].image).toBe('ubuntu');
expect(result[0].before?.image).toBe('node:20');
expect(result[0].after?.image).toBe('python:3');
});
});
describe('evaluateExpression', () => {
it('should evaluate equality expressions', () => {
process.env.TEST_VAR = 'hello';
expect(MiddlewareService.evaluateExpression("env.TEST_VAR == 'hello'")).toBe(true);
expect(MiddlewareService.evaluateExpression("env.TEST_VAR == 'world'")).toBe(false);
});
it('should evaluate inequality expressions', () => {
process.env.TEST_VAR = 'hello';
expect(MiddlewareService.evaluateExpression("env.TEST_VAR != 'world'")).toBe(true);
expect(MiddlewareService.evaluateExpression("env.TEST_VAR != 'hello'")).toBe(false);
});
it('should evaluate truthy expressions', () => {
process.env.DEFINED_VAR = 'yes';
expect(MiddlewareService.evaluateExpression('env.DEFINED_VAR')).toBe(true);
process.env.FALSE_VAR = 'false';
expect(MiddlewareService.evaluateExpression('env.FALSE_VAR')).toBe(false);
process.env.EMPTY_VAR = '';
expect(MiddlewareService.evaluateExpression('env.EMPTY_VAR')).toBe(false);
delete process.env.MISSING_VAR;
expect(MiddlewareService.evaluateExpression('env.MISSING_VAR')).toBe(false);
});
it('should evaluate falsy expressions', () => {
delete process.env.MISSING_VAR;
expect(MiddlewareService.evaluateExpression('!env.MISSING_VAR')).toBe(true);
process.env.DEFINED_VAR = 'yes';
expect(MiddlewareService.evaluateExpression('!env.DEFINED_VAR')).toBe(false);
});
it('should default to true for unknown expressions', () => {
expect(MiddlewareService.evaluateExpression('some unknown expression')).toBe(true);
});
it('should handle double-quoted values', () => {
process.env.TEST_VAR = 'hello';
expect(MiddlewareService.evaluateExpression('env.TEST_VAR == "hello"')).toBe(true);
});
});
describe('evaluateTrigger', () => {
it('should match when phase matches', () => {
const trigger = { phase: ['build'] };
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(true);
expect(MiddlewareService.evaluateTrigger(trigger, 'setup')).toBe(false);
});
it('should match multiple phases', () => {
const trigger = { phase: ['setup', 'build'] };
expect(MiddlewareService.evaluateTrigger(trigger, 'setup')).toBe(true);
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(true);
expect(MiddlewareService.evaluateTrigger(trigger, 'pre-build')).toBe(false);
});
it('should filter by provider', () => {
const trigger = { phase: ['build'], provider: ['k8s'] };
// Mock provider is 'aws', so this should not match
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
const matchingTrigger = { phase: ['build'], provider: ['aws', 'k8s'] };
expect(MiddlewareService.evaluateTrigger(matchingTrigger, 'build')).toBe(true);
});
it('should filter by platform', () => {
const trigger = { phase: ['build'], platform: ['StandaloneWindows64'] };
// Mock platform is 'StandaloneLinux64', so this should not match
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
const matchingTrigger = { phase: ['build'], platform: ['StandaloneLinux64'] };
expect(MiddlewareService.evaluateTrigger(matchingTrigger, 'build')).toBe(true);
});
it('should evaluate when expressions', () => {
process.env.FEATURE_FLAG = 'true';
const trigger = { phase: ['build'], when: "env.FEATURE_FLAG == 'true'" };
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(true);
process.env.FEATURE_FLAG = 'false';
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
});
it('should require all conditions to pass (AND logic)', () => {
process.env.FEATURE_FLAG = 'true';
const trigger = {
phase: ['build'],
provider: ['k8s'], // won't match (mock provider is 'aws')
when: "env.FEATURE_FLAG == 'true'", // would match
};
expect(MiddlewareService.evaluateTrigger(trigger, 'build')).toBe(false);
});
});
describe('resolveCommandHooks', () => {
const middleware: Middleware[] = [
{
name: 'low-priority',
type: 'command',
priority: 10,
trigger: { phase: ['build'] },
image: 'ubuntu',
before: { commands: 'echo "low-before"' },
after: { commands: 'echo "low-after"' },
secrets: [],
allowFailure: false,
},
{
name: 'high-priority',
type: 'command',
priority: 90,
trigger: { phase: ['build'] },
image: 'ubuntu',
before: { commands: 'echo "high-before"' },
after: { commands: 'echo "high-after"' },
secrets: [],
allowFailure: false,
},
{
name: 'container-type',
type: 'container',
priority: 50,
trigger: { phase: ['build'] },
image: 'node:20',
before: { commands: 'echo "container"' },
secrets: [],
allowFailure: false,
},
];
it('should only return command type middleware', () => {
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
expect(hooks).toHaveLength(2);
expect(hooks.every((h) => h.name.startsWith('middleware:'))).toBe(true);
expect(hooks.find((h) => h.name.includes('container-type'))).toBeUndefined();
});
it('should order before hooks by ascending priority', () => {
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
expect(hooks[0].name).toBe('middleware:low-priority:before');
expect(hooks[1].name).toBe('middleware:high-priority:before');
});
it('should order after hooks by descending priority (wrapping)', () => {
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'build', 'after');
expect(hooks[0].name).toBe('middleware:high-priority:after');
expect(hooks[1].name).toBe('middleware:low-priority:after');
});
it('should filter by phase', () => {
const hooks = MiddlewareService.resolveCommandHooks(middleware, 'setup', 'before');
expect(hooks).toHaveLength(0);
});
});
describe('resolveContainerHooks', () => {
const middleware: Middleware[] = [
{
name: 'container-a',
type: 'container',
priority: 20,
trigger: { phase: ['pre-build'] },
image: 'ubuntu',
before: { commands: 'echo "a-before"' },
after: { commands: 'echo "a-after"' },
secrets: [],
allowFailure: false,
},
{
name: 'container-b',
type: 'container',
priority: 80,
trigger: { phase: ['pre-build'] },
image: 'node:20',
before: { commands: 'echo "b-before"', image: 'python:3' },
secrets: [],
allowFailure: true,
},
];
it('should resolve container hooks with correct images', () => {
const hooks = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
expect(hooks).toHaveLength(2);
expect(hooks[0].image).toBe('ubuntu'); // default image from middleware
expect(hooks[1].image).toBe('python:3'); // per-phase override
});
it('should propagate allowFailure', () => {
const hooks = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
expect(hooks[0].allowFailure).toBe(false);
expect(hooks[1].allowFailure).toBe(true);
});
it('should only include phases that have definitions', () => {
// container-b has no after phase
const hooks = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'after');
expect(hooks).toHaveLength(1);
expect(hooks[0].name).toBe('middleware:container-a:after');
});
});
describe('getMiddleware', () => {
it('should sort by priority ascending', () => {
const yaml = `
- name: high
type: command
priority: 200
trigger:
phase: [build]
before: echo "high"
- name: low
type: command
priority: 5
trigger:
phase: [build]
before: echo "low"
- name: medium
type: command
priority: 50
trigger:
phase: [build]
before: echo "medium"
`;
const result = MiddlewareService.getMiddleware(yaml);
expect(result[0].name).toBe('low');
expect(result[1].name).toBe('medium');
expect(result[2].name).toBe('high');
});
});
});

View File

@@ -0,0 +1,329 @@
import YAML from 'yaml';
import fs from 'node:fs';
import path from 'node:path';
import { Middleware, MiddlewareTrigger } from './middleware';
import { ContainerHook } from './container-hook';
import { CommandHook } from './command-hook';
import OrchestratorOptions from '../../options/orchestrator-options';
import Orchestrator from '../../orchestrator';
import OrchestratorLogger from '../core/orchestrator-logger';
import Input from '../../../input';
/**
* Service for loading, evaluating, and resolving middleware into hooks.
*
* Middleware is a higher-level composable abstraction over the existing
* command hook and container hook systems. Each middleware:
* - Wraps around pipeline phases with before/after semantics
* - Has trigger conditions (phase, provider, platform, expression)
* - Resolves to either CommandHooks or ContainerHooks at execution time
* - Executes in priority order (before: ascending, after: descending)
*/
export class MiddlewareService {
/**
* Load all active middleware from inline YAML + file-based definitions.
* Returns them sorted by priority (ascending).
*/
static getMiddleware(inlineYaml: string): Middleware[] {
const middleware: Middleware[] = [];
// Parse inline YAML definitions
if (inlineYaml && inlineYaml !== '') {
middleware.push(...MiddlewareService.parseMiddleware(inlineYaml));
}
// Load file-based definitions from game-ci/middleware/
middleware.push(...MiddlewareService.getMiddlewareFromFiles());
// Sort by priority (lower = earlier)
middleware.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
OrchestratorLogger.log(`Middleware: loaded ${middleware.length} definition(s)`);
return middleware;
}
/**
* Resolve middleware to CommandHooks for a given phase and timing.
* Filters by trigger conditions and converts to hooks.
*
* Before hooks: ascending priority (lowest priority runs first, closest to core phase).
* After hooks: descending priority (highest priority runs first, closest to core phase).
* This produces the wrapping pattern: outermost middleware's before runs first and after runs last.
*/
static resolveCommandHooks(middleware: Middleware[], phase: string, timing: 'before' | 'after'): CommandHook[] {
const applicable = middleware
.filter((m) => m.type === 'command')
.filter((m) => MiddlewareService.evaluateTrigger(m.trigger, phase));
// before: ascending priority; after: descending (wrapping order)
if (timing === 'after') {
applicable.reverse();
}
const hooks = applicable
.filter((m) => (timing === 'before' ? m.before : m.after))
.map((m) => {
const mPhase = timing === 'before' ? m.before! : m.after!;
const hook = new CommandHook();
hook.name = `middleware:${m.name}:${timing}`;
hook.commands = typeof mPhase.commands === 'string' ? [mPhase.commands] : [mPhase.commands];
hook.hook = [timing];
hook.step = [phase];
hook.secrets = m.secrets || [];
return hook;
});
if (hooks.length > 0) {
OrchestratorLogger.log(
`Middleware: resolved ${hooks.length} command hook(s) for ${phase}:${timing}${hooks
.map((h) => h.name)
.join(', ')}`,
);
}
return hooks;
}
/**
* Resolve middleware to ContainerHooks for a given phase and timing.
* Same ordering logic as resolveCommandHooks.
*/
static resolveContainerHooks(middleware: Middleware[], phase: string, timing: 'before' | 'after'): ContainerHook[] {
const applicable = middleware
.filter((m) => m.type === 'container')
.filter((m) => MiddlewareService.evaluateTrigger(m.trigger, phase));
// before: ascending priority; after: descending (wrapping order)
if (timing === 'after') {
applicable.reverse();
}
const hooks = applicable
.filter((m) => (timing === 'before' ? m.before : m.after))
.map((m) => {
const mPhase = timing === 'before' ? m.before! : m.after!;
const hook = new ContainerHook();
hook.name = `middleware:${m.name}:${timing}`;
hook.commands = typeof mPhase.commands === 'string' ? mPhase.commands : mPhase.commands;
hook.image = mPhase.image || m.image || 'ubuntu';
hook.hook = timing === 'before' ? 'before' : 'after';
hook.secrets = m.secrets || [];
hook.allowFailure = m.allowFailure ?? false;
return hook;
});
if (hooks.length > 0) {
OrchestratorLogger.log(
`Middleware: resolved ${hooks.length} container hook(s) for ${phase}:${timing}${hooks
.map((h) => h.name)
.join(', ')}`,
);
}
return hooks;
}
/**
* Evaluate whether a middleware's trigger conditions are met.
* All specified conditions must pass (AND logic).
*/
static evaluateTrigger(trigger: MiddlewareTrigger, currentPhase: string): boolean {
// Phase must match
if (!trigger.phase || !trigger.phase.includes(currentPhase)) {
return false;
}
// Provider filter (if specified)
if (trigger.provider && trigger.provider.length > 0) {
const currentProvider = Orchestrator.buildParameters?.providerStrategy || OrchestratorOptions.providerStrategy;
if (!trigger.provider.includes(currentProvider)) {
return false;
}
}
// Platform filter (if specified)
if (trigger.platform && trigger.platform.length > 0) {
const currentPlatform = Orchestrator.buildParameters?.targetPlatform || '';
if (!trigger.platform.includes(currentPlatform)) {
return false;
}
}
// Expression-based condition
if (trigger.when) {
if (!MiddlewareService.evaluateExpression(trigger.when)) {
return false;
}
}
return true;
}
/**
* Evaluate a simple expression string against environment variables.
*
* Supported formats:
* - env.VAR_NAME == 'value' — equality check
* - env.VAR_NAME != 'value' — inequality check
* - env.VAR_NAME — truthy check (defined, non-empty, not 'false')
* - !env.VAR_NAME — falsy check
*/
static evaluateExpression(expression: string): boolean {
const trimmed = expression.trim();
// Match: env.VAR == 'value' or env.VAR != 'value'
const comparisonMatch = trimmed.match(/^env\.(\w+)\s*(==|!=)\s*['"](.*)['"]$/);
if (comparisonMatch) {
const [, varName, operator, value] = comparisonMatch;
const envValue = process.env[varName] || '';
return operator === '==' ? envValue === value : envValue !== value;
}
// Match: !env.VAR (falsy check)
const falsyMatch = trimmed.match(/^!env\.(\w+)$/);
if (falsyMatch) {
const [, varName] = falsyMatch;
const envValue = process.env[varName];
return envValue === undefined || envValue === '' || envValue === 'false';
}
// Match: env.VAR (truthy check)
const truthyMatch = trimmed.match(/^env\.(\w+)$/);
if (truthyMatch) {
const [, varName] = truthyMatch;
const envValue = process.env[varName];
return envValue !== undefined && envValue !== '' && envValue !== 'false';
}
// Unknown expression format — log warning, default to true
OrchestratorLogger.logWarning(`Middleware: unknown expression format "${expression}", defaulting to true`);
return true;
}
/**
* Parse middleware definitions from a YAML string.
* Accepts both single-object and array format.
*/
static parseMiddleware(yamlString: string): Middleware[] {
if (!yamlString || yamlString.trim() === '') {
return [];
}
try {
const isArray = yamlString.replace(/\s/g, '')[0] === '-';
const parsed = isArray ? YAML.parse(yamlString) : [YAML.parse(yamlString)];
if (!parsed || !Array.isArray(parsed)) {
return [];
}
return parsed.map((m: any) => MiddlewareService.hydrateMiddleware(m));
} catch (error: any) {
OrchestratorLogger.logWarning(`Middleware: failed to parse YAML — ${error.message}`);
return [];
}
}
/**
* Hydrate a raw parsed YAML object into a Middleware instance.
*/
private static hydrateMiddleware(m: any): Middleware {
const middleware = new Middleware();
middleware.name = m.name || 'unnamed';
middleware.description = m.description;
middleware.type = m.type || 'command';
middleware.priority = m.priority ?? 100;
middleware.image = m.image || 'ubuntu';
middleware.allowFailure = m.allowFailure ?? false;
middleware.outputs = m.outputs;
// Parse trigger — normalize scalar values to arrays
middleware.trigger = {
phase: MiddlewareService.toStringArray(m.trigger?.phase),
provider: m.trigger?.provider ? MiddlewareService.toStringArray(m.trigger.provider) : undefined,
platform: m.trigger?.platform ? MiddlewareService.toStringArray(m.trigger.platform) : undefined,
when: m.trigger?.when,
};
// Parse before/after phases — accept string shorthand or object format
if (m.before) {
middleware.before = {
commands: typeof m.before === 'string' ? m.before : m.before.commands || '',
image: typeof m.before === 'string' ? undefined : m.before.image,
};
}
if (m.after) {
middleware.after = {
commands: typeof m.after === 'string' ? m.after : m.after.commands || '',
image: typeof m.after === 'string' ? undefined : m.after.image,
};
}
// Parse secrets
if (m.secrets && Array.isArray(m.secrets)) {
middleware.secrets = m.secrets.map((s: any) => ({
ParameterKey: s.name,
EnvironmentVariable: Input.ToEnvVarFormat(s.name),
ParameterValue: s.value ?? process.env[s.name] ?? process.env[Input.ToEnvVarFormat(s.name)] ?? '',
}));
}
return middleware;
}
/**
* Load middleware definitions from game-ci/middleware/ directory files.
* Only files whose base name appears in the middlewareFiles allowlist are loaded.
*/
static getMiddlewareFromFiles(): Middleware[] {
const results: Middleware[] = [];
const allowedFiles = OrchestratorOptions.middlewareFiles;
if (!allowedFiles || allowedFiles.length === 0) {
return results;
}
try {
const middlewarePath = path.join(process.cwd(), 'game-ci', 'middleware');
if (!fs.existsSync(middlewarePath)) {
return results;
}
const files = fs.readdirSync(middlewarePath);
for (const file of files) {
const baseName = file.replace(/\.ya?ml$/, '');
if (!allowedFiles.includes(baseName)) {
continue;
}
try {
const contents = fs.readFileSync(path.join(middlewarePath, file), 'utf8');
results.push(...MiddlewareService.parseMiddleware(contents));
} catch (error: any) {
OrchestratorLogger.logWarning(`Middleware: failed to parse file ${file}${error.message}`);
}
}
} catch {
// Directory doesn't exist or can't be read — not an error
}
return results;
}
/**
* Normalize a value to a string array. Accepts string, string[], or undefined.
*/
private static toStringArray(value: any): string[] {
if (!value) return [];
if (Array.isArray(value)) return value;
return [value];
}
}

View File

@@ -0,0 +1,85 @@
import OrchestratorSecret from '../../options/orchestrator-secret';
/**
* Trigger conditions that determine when a middleware activates.
* All specified conditions must be true (AND logic).
*/
export interface MiddlewareTrigger {
/** Pipeline phases this middleware applies to: 'setup', 'build', 'pre-build', 'post-build' */
phase: string[];
/** Restrict to specific providers. If omitted, applies to all providers. */
provider?: string[];
/** Restrict to specific build target platforms. If omitted, applies to all platforms. */
platform?: string[];
/** Expression-based condition. Supports: env.VAR == 'value', env.VAR != 'value', env.VAR (truthy) */
when?: string;
}
/**
* A single phase (before or after) of a middleware definition.
*/
export interface MiddlewarePhase {
/** Shell commands to execute */
commands: string;
/** Override image for this phase (container type only) */
image?: string;
}
/**
* Middleware — a composable, trigger-aware pipeline unit built on hooks.
*
* Middleware wraps around pipeline phases with before/after semantics.
* Each middleware resolves to either CommandHooks (inline in build container)
* or ContainerHooks (separate Docker containers) at execution time.
*
* Execution order: before phases run in ascending priority order,
* after phases run in descending priority order (wrapping pattern).
*
* Example YAML:
* ```yaml
* name: code-signing
* description: Signs build artifacts after successful build
* type: container
* priority: 50
* image: ubuntu:22.04
* trigger:
* phase: [build]
* provider: [aws, k8s]
* platform: [StandaloneWindows64]
* when: "env.SIGN_BUILDS == 'true'"
* before:
* commands: |
* echo "Preparing signing environment..."
* after:
* commands: |
* echo "Signing build artifacts..."
* secrets:
* - name: SIGNING_KEY
* - name: SIGNING_CERT
* allowFailure: false
* ```
*/
export class Middleware {
/** Unique name identifying this middleware */
public name!: string;
/** Human-readable description */
public description?: string;
/** Hook type: 'command' (inline in build container) or 'container' (separate Docker container) */
public type!: 'command' | 'container';
/** Execution priority. Lower values run first for before, last for after (wrapping order). Default: 100 */
public priority: number = 100;
/** Conditions that determine when this middleware activates */
public trigger!: MiddlewareTrigger;
/** Default Docker image for container type middleware */
public image: string = 'ubuntu';
/** Commands to run before the target phase */
public before?: MiddlewarePhase;
/** Commands to run after the target phase */
public after?: MiddlewarePhase;
/** Secrets injected as environment variables */
public secrets: OrchestratorSecret[] = [];
/** If true, failures warn but don't stop the build. Default: false */
public allowFailure: boolean = false;
/** Environment variable names this middleware exports to downstream middleware */
public outputs?: string[];
}

View File

@@ -6,6 +6,8 @@ import { CommandHookService } from '../services/hooks/command-hook-service';
import path from 'node:path';
import Orchestrator from '../orchestrator';
import { ContainerHookService } from '../services/hooks/container-hook-service';
import { MiddlewareService } from '../services/hooks/middleware-service';
import { CustomWorkflow } from './custom-workflow';
export class BuildAutomationWorkflow implements WorkflowInterface {
async run(orchestratorStepState: OrchestratorStepParameters) {
@@ -13,13 +15,41 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
}
private static async standardBuildAutomation(baseImage: string, orchestratorStepState: OrchestratorStepParameters) {
// TODO accept post and pre build steps as yaml files in the repo
OrchestratorLogger.log(`Orchestrator is running standard build automation`);
let output = '';
// Load middleware pipeline
const middleware = MiddlewareService.getMiddleware(Orchestrator.buildParameters.middleware);
// --- Pre-build phase ---
// Middleware container hooks: pre-build before
const preBuildMiddlewareBefore = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'before');
if (preBuildMiddlewareBefore.length > 0) {
output += await CustomWorkflow.runContainerJob(
preBuildMiddlewareBefore,
orchestratorStepState.environment,
orchestratorStepState.secrets,
);
OrchestratorLogger.logWithTime('Middleware pre-build:before container step(s) time');
}
// Standard pre-build container hooks
output += await ContainerHookService.RunPreBuildSteps(orchestratorStepState);
OrchestratorLogger.logWithTime('Configurable pre build step(s) time');
// Middleware container hooks: pre-build after
const preBuildMiddlewareAfter = MiddlewareService.resolveContainerHooks(middleware, 'pre-build', 'after');
if (preBuildMiddlewareAfter.length > 0) {
output += await CustomWorkflow.runContainerJob(
preBuildMiddlewareAfter,
orchestratorStepState.environment,
orchestratorStepState.secrets,
);
OrchestratorLogger.logWithTime('Middleware pre-build:after container step(s) time');
}
// --- Build phase ---
OrchestratorLogger.log(baseImage);
OrchestratorLogger.logLine(` `);
OrchestratorLogger.logLine('Starting build automation job');
@@ -35,9 +65,33 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
);
OrchestratorLogger.logWithTime('Build time');
// --- Post-build phase ---
// Middleware container hooks: post-build before
const postBuildMiddlewareBefore = MiddlewareService.resolveContainerHooks(middleware, 'post-build', 'before');
if (postBuildMiddlewareBefore.length > 0) {
output += await CustomWorkflow.runContainerJob(
postBuildMiddlewareBefore,
orchestratorStepState.environment,
orchestratorStepState.secrets,
);
OrchestratorLogger.logWithTime('Middleware post-build:before container step(s) time');
}
// Standard post-build container hooks
output += await ContainerHookService.RunPostBuildSteps(orchestratorStepState);
OrchestratorLogger.logWithTime('Configurable post build step(s) time');
// Middleware container hooks: post-build after
const postBuildMiddlewareAfter = MiddlewareService.resolveContainerHooks(middleware, 'post-build', 'after');
if (postBuildMiddlewareAfter.length > 0) {
output += await CustomWorkflow.runContainerJob(
postBuildMiddlewareAfter,
orchestratorStepState.environment,
orchestratorStepState.secrets,
);
OrchestratorLogger.logWithTime('Middleware post-build:after container step(s) time');
}
OrchestratorLogger.log(`Orchestrator finished running standard build automation`);
return output;
@@ -50,6 +104,13 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
const buildHooks = CommandHookService.getHooks(Orchestrator.buildParameters.commandHooks).filter((x) =>
x.step?.includes(`build`),
);
// Resolve middleware command hooks for setup and build phases
const middleware = MiddlewareService.getMiddleware(Orchestrator.buildParameters.middleware);
const middlewareSetupBefore = MiddlewareService.resolveCommandHooks(middleware, 'setup', 'before');
const middlewareSetupAfter = MiddlewareService.resolveCommandHooks(middleware, 'setup', 'after');
const middlewareBuildBefore = MiddlewareService.resolveCommandHooks(middleware, 'build', 'before');
const middlewareBuildAfter = MiddlewareService.resolveCommandHooks(middleware, 'build', 'after');
const isContainerized =
Orchestrator.buildParameters.providerStrategy === 'aws' ||
Orchestrator.buildParameters.providerStrategy === 'k8s' ||
@@ -71,6 +132,7 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
? 'apt-get install -y curl tar tree npm git-lfs jq git > /dev/null || true\n npm --version || true\n npm i -g n > /dev/null || true\n npm i -g semver > /dev/null || true\n npm install --global yarn > /dev/null || true\n n 20.8.0 || true\n node --version || true'
: '# skipping toolchain setup in local-docker or non-container provider'
}
${middlewareSetupBefore.map((x) => x.commands).join('\n ') || ' '}
${setupHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
${
Orchestrator.buildParameters.providerStrategy === 'local-docker'
@@ -82,9 +144,12 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
export LOG_FILE=${isContainerized ? '/home/job-log.txt' : '$(pwd)/temp/job-log.txt'}
${BuildAutomationWorkflow.setupCommands(builderPath, isContainerized)}
${setupHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
${middlewareSetupAfter.map((x) => x.commands).join('\n ') || ' '}
${middlewareBuildBefore.map((x) => x.commands).join('\n ') || ' '}
${buildHooks.filter((x) => x.hook.includes(`before`)).map((x) => x.commands) || ' '}
${BuildAutomationWorkflow.BuildCommands(builderPath, isContainerized)}
${buildHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}`;
${buildHooks.filter((x) => x.hook.includes(`after`)).map((x) => x.commands) || ' '}
${middlewareBuildAfter.map((x) => x.commands).join('\n ') || ' '}`;
}
private static setupCommands(builderPath: string, isContainerized: boolean) {