Compare commits

..

5 Commits

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:07:26 +00:00
frostebite
1171b7e7ae ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:33:44 +00:00
frostebite
7db70a712f style: fix prettier formatting and eslint errors on test files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:23:05 +00:00
frostebite
f4451060a7 ci(orchestrator): add fast unit test gate to integrity workflow
Adds a fast-fail unit test step at the top of orchestrator-integrity,
right after yarn install and before any infrastructure setup (k3d,
LocalStack). Runs 113 mock-based orchestrator tests in ~5 seconds.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:08:49 +00:00
12 changed files with 583 additions and 1244 deletions

View File

@@ -196,6 +196,15 @@ jobs:
fi
- run: yarn install --frozen-lockfile
# ==========================================
# FAST UNIT TESTS (no infra required, fast-fail gate)
# ==========================================
- name: Run orchestrator unit tests (fast, no infra)
timeout-minutes: 2
run: >-
yarn run test
--testPathPattern="orchestrator-guid|orchestrator-folders|task-parameter-serializer|follow-log-stream-service|runner-availability-service|provider-url-parser|provider-loader|provider-git-manager|orchestrator-image|orchestrator-hooks|orchestrator-github-checks"
--verbose --detectOpenHandles --forceExit --runInBand
# ==========================================
# K8S TESTS SECTION
# ==========================================
- name: Clean up disk space before K8s tests

View File

@@ -194,15 +194,6 @@ inputs:
description:
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
be configured.'
secretSource:
default: ''
required: false
description:
'[Orchestrator] Premade secret source for pulling build secrets. Supported values: aws-secrets-manager,
aws-parameter-store, gcp-secret-manager, azure-key-vault, hashicorp-vault, hashicorp-vault-kv1,
vault (alias for hashicorp-vault), env. Can also be a custom shell command with {0} placeholder
for the key, or a path to a YAML file defining custom sources. Takes precedence over
inputPullCommand when set.'
resourceTracking:
default: 'false'
required: false

397
dist/index.js generated vendored
View File

@@ -2243,9 +2243,6 @@ class OrchestratorOptions {
static get pullInputList() {
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
}
static get secretSource() {
return OrchestratorOptions.getInput('secretSource') || '';
}
static get inputPullCommand() {
const value = OrchestratorOptions.getInput('inputPullCommand');
if (value === 'gcp-secret-manager') {
@@ -2367,39 +2364,13 @@ exports["default"] = OrchestratorOptions;
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
const core = __importStar(__nccwpck_require__(42186));
const input_1 = __importDefault(__nccwpck_require__(91933));
const generic_input_reader_1 = __nccwpck_require__(2263);
const orchestrator_options_1 = __importDefault(__nccwpck_require__(82473));
const secret_source_service_1 = __nccwpck_require__(79089);
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
const formatFunction = (value, arguments_) => {
for (const element of arguments_) {
value = value.replace(`{${element.key}}`, element.value);
@@ -2407,6 +2378,7 @@ const formatFunction = (value, arguments_) => {
return value;
};
class OrchestratorQueryOverride {
// TODO accept premade secret sources or custom secret source definition yamls
static query(key, alternativeKey) {
if (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) {
return OrchestratorQueryOverride.queryOverrides[key];
@@ -2434,49 +2406,11 @@ class OrchestratorQueryOverride {
if (!this.shouldUseOverride(query)) {
throw new Error(`Should not be trying to run override query on ${query}`);
}
// Validate the query key before interpolating it into a shell command
(0, secret_source_service_1.validateSecretKey)(query);
const result = await generic_input_reader_1.GenericInputReader.Run(formatFunction(orchestrator_options_1.default.inputPullCommand, [{ key: 0, value: query }]));
// Mask the fetched secret value so it does not appear in GitHub Actions logs
if (result && result.trim().length > 0) {
core.setSecret(result);
}
return result;
return await generic_input_reader_1.GenericInputReader.Run(formatFunction(orchestrator_options_1.default.inputPullCommand, [{ key: 0, value: query }]));
}
/**
* Populate query overrides using either:
* 1. Premade/custom secret sources (via secretSource input), or
* 2. Shell command (via inputPullCommand, legacy approach)
*
* The secretSource input takes precedence if set. It supports:
* - Premade names: 'aws-secrets-manager', 'aws-parameter-store', 'gcp-secret-manager', 'azure-key-vault', 'env'
* - Custom commands: any string containing {0} placeholder
* - YAML file path: a path ending in .yml or .yaml containing custom source definitions
*/
static async PopulateQueryOverrideInput() {
const queries = orchestrator_options_1.default.pullInputList;
OrchestratorQueryOverride.queryOverrides = {};
const secretSource = orchestrator_options_1.default.secretSource;
// Use SecretSourceService if secretSource is configured
if (secretSource) {
orchestrator_logger_1.default.log(`Using secret source: ${secretSource}`);
// YAML file: load definitions and use the first source
if (secretSource.endsWith('.yml') || secretSource.endsWith('.yaml')) {
const definitions = secret_source_service_1.SecretSourceService.loadFromYaml(secretSource);
if (definitions.length > 0) {
orchestrator_logger_1.default.log(`Loaded ${definitions.length} secret source(s) from ${secretSource}`);
for (const key of queries) {
OrchestratorQueryOverride.queryOverrides[key] = await secret_source_service_1.SecretSourceService.fetchSecret(definitions[0], key);
}
}
return;
}
// Premade or custom command source
const results = await secret_source_service_1.SecretSourceService.fetchAll(secretSource, queries);
Object.assign(OrchestratorQueryOverride.queryOverrides, results);
return;
}
// Legacy: use inputPullCommand if set
for (const element of queries) {
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);
@@ -9698,333 +9632,6 @@ class ContainerHookService {
exports.ContainerHookService = ContainerHookService;
/***/ }),
/***/ 79089:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.SecretSourceService = exports.validateSecretKey = void 0;
const node_fs_1 = __importDefault(__nccwpck_require__(87561));
const core = __importStar(__nccwpck_require__(42186));
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
const orchestrator_system_1 = __nccwpck_require__(9744);
/**
* Validate that a secret key name contains only safe characters.
* Prevents shell injection when keys are interpolated into commands.
*
* Allowed characters: alphanumeric, hyphens, underscores, dots, forward slashes.
*
* @param key - The secret key name to validate
* @returns The validated key (unchanged)
* @throws Error if the key contains disallowed characters
*/
function validateSecretKey(key) {
if (!/^[a-zA-Z0-9\-_./]+$/.test(key)) {
throw new Error(`Invalid secret key name: "${key}". Keys may only contain alphanumeric characters, hyphens, underscores, dots, and forward slashes.`);
}
return key;
}
exports.validateSecretKey = validateSecretKey;
/**
* Mask a secret value so it does not appear in GitHub Actions logs.
* Empty or whitespace-only values are skipped (core.setSecret would be a no-op).
*/
function maskSecretValue(value) {
if (value.trim().length > 0) {
core.setSecret(value);
}
}
/**
* Premade secret sources and custom YAML-based secret source definitions.
*
* Premade sources are string shortcuts that expand to shell commands:
* - `aws-secrets-manager` -- AWS Secrets Manager
* - `aws-parameter-store` -- AWS Systems Manager Parameter Store
* - `gcp-secret-manager` -- Google Cloud Secret Manager
* - `azure-key-vault` -- Azure Key Vault (requires AZURE_VAULT_NAME env var)
* - `hashicorp-vault` -- HashiCorp Vault KV v2 (requires VAULT_ADDR, optionally VAULT_MOUNT)
* - `hashicorp-vault-kv1` -- HashiCorp Vault KV v1 (requires VAULT_ADDR, optionally VAULT_MOUNT)
* - `env` -- Read from environment variables (no shell command needed)
*
* Custom YAML format:
* sources:
* - name: my-vault
* command: 'vault kv get -field=value secret/{0}'
* - name: my-api
* command: 'curl -s https://secrets.example.com/api/{0}'
* parseOutput: json-field
* jsonField: value
*/
class SecretSourceService {
/**
* Check if a source name is a known premade source.
*/
static isPremadeSource(sourceName) {
return sourceName in SecretSourceService.premadeSources;
}
/**
* Get the list of available premade source names.
*/
static getAvailableSources() {
return Object.keys(SecretSourceService.premadeSources);
}
/**
* Resolve a source name to a SecretSourceDefinition.
*
* - If the name matches a premade source, returns that definition.
* - If it looks like a shell command (contains spaces or {0}), wraps it as a custom command.
* - Otherwise, returns undefined.
*/
static resolveSource(sourceName) {
// Check premade sources
if (SecretSourceService.isPremadeSource(sourceName)) {
return SecretSourceService.premadeSources[sourceName];
}
// If it contains a placeholder or spaces, treat it as a raw command
if (sourceName.includes('{0}') || sourceName.includes(' ')) {
return {
name: 'custom-command',
command: sourceName,
parseOutput: 'raw',
};
}
return undefined;
}
/**
* Load custom secret source definitions from a YAML file.
*
* Expected format:
* sources:
* - name: my-source
* command: 'my-tool get-secret {0}'
* - name: my-api
* command: 'curl -s https://api.example.com/secrets/{0}'
* parseOutput: json-field
* jsonField: value
*/
static loadFromYaml(filePath) {
if (!node_fs_1.default.existsSync(filePath)) {
orchestrator_logger_1.default.logWarning(`Secret source YAML not found: ${filePath}`);
return [];
}
try {
const content = node_fs_1.default.readFileSync(filePath, 'utf8');
const parsed = SecretSourceService.parseSimpleYaml(content);
return parsed;
}
catch (error) {
orchestrator_logger_1.default.logWarning(`Failed to parse secret source YAML: ${error.message}`);
return [];
}
}
/**
* Fetch a secret value using the given source definition.
*
* Validates the key against an allowlist pattern before interpolating it
* into the command string to prevent shell injection. The fetched secret
* value is masked via core.setSecret() so it does not leak in logs.
*
* @param source - The secret source definition to use
* @param key - The secret key to fetch
* @returns The secret value, or empty string on failure
*/
static async fetchSecret(source, key) {
// Validate the key to prevent shell injection
validateSecretKey(key);
const command = source.command.replace(/\{0\}/g, key);
try {
const output = await orchestrator_system_1.OrchestratorSystem.Run(command, false, true);
let value;
if (source.parseOutput === 'json-field' && source.jsonField) {
try {
const parsed = JSON.parse(output);
value = parsed[source.jsonField] || '';
}
catch {
orchestrator_logger_1.default.logWarning(`Failed to parse JSON output from ${source.name} for key ${key}`);
value = output.trim();
}
}
else {
value = output.trim();
}
// Mask the secret value so it does not appear in GitHub Actions logs
maskSecretValue(value);
return value;
}
catch (error) {
orchestrator_logger_1.default.logWarning(`Failed to fetch secret '${key}' from ${source.name}: ${error.message}`);
return '';
}
}
/**
* Fetch a secret from an environment variable. No shell command needed.
* The value is masked via core.setSecret() so it does not leak in logs.
*/
static fetchFromEnv(key) {
const value = process.env[key] || '';
maskSecretValue(value);
return value;
}
/**
* Resolve a source name and fetch all secrets from it.
*
* @param sourceName - Premade source name, shell command, or 'env'
* @param keys - List of secret keys to fetch
* @returns Map of key -> value
*/
static async fetchAll(sourceName, keys) {
const results = {};
if (sourceName === 'env') {
for (const key of keys) {
results[key] = SecretSourceService.fetchFromEnv(key);
}
return results;
}
const source = SecretSourceService.resolveSource(sourceName);
if (!source) {
orchestrator_logger_1.default.logWarning(`Unknown secret source '${sourceName}'. Available sources: ${SecretSourceService.getAvailableSources().join(', ')}`);
return results;
}
orchestrator_logger_1.default.log(`Fetching ${keys.length} secret(s) from ${source.name}`);
for (const key of keys) {
results[key] = await SecretSourceService.fetchSecret(source, key);
}
return results;
}
/**
* Simple YAML parser for secret source definitions.
* Handles the specific structure we expect without requiring a YAML library.
*/
static parseSimpleYaml(content) {
const definitions = [];
const lines = content.split('\n');
let current = null;
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, '');
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#'))
continue;
if (trimmed === '- name:' || trimmed.startsWith('- name:')) {
if (current?.name && current?.command) {
definitions.push(current);
}
current = {
name: trimmed
.replace('- name:', '')
.trim()
.replace(/^['"]|['"]$/g, ''),
parseOutput: 'raw',
};
continue;
}
if (current && trimmed.startsWith('command:')) {
current.command = trimmed
.replace('command:', '')
.trim()
.replace(/^['"]|['"]$/g, '');
}
else if (current && trimmed.startsWith('parseOutput:')) {
const value = trimmed
.replace('parseOutput:', '')
.trim()
.replace(/^['"]|['"]$/g, '');
current.parseOutput = value;
}
else if (current && trimmed.startsWith('jsonField:')) {
current.jsonField = trimmed
.replace('jsonField:', '')
.trim()
.replace(/^['"]|['"]$/g, '');
}
}
if (current?.name && current?.command) {
definitions.push(current);
}
return definitions;
}
}
exports.SecretSourceService = SecretSourceService;
SecretSourceService.premadeSources = {
'aws-secrets-manager': {
name: 'aws-secrets-manager',
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
parseOutput: 'raw',
},
'aws-secret-manager': {
// Alias for backward compatibility (original name in inputPullCommand)
name: 'aws-secret-manager',
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
parseOutput: 'raw',
},
'aws-parameter-store': {
name: 'aws-parameter-store',
command: 'aws ssm get-parameter --name {0} --with-decryption --query Parameter.Value --output text',
parseOutput: 'raw',
},
'gcp-secret-manager': {
name: 'gcp-secret-manager',
command: 'gcloud secrets versions access latest --secret="{0}"',
parseOutput: 'raw',
},
'azure-key-vault': {
name: 'azure-key-vault',
command: 'az keyvault secret show --vault-name "$AZURE_VAULT_NAME" --name {0} --query value --output tsv',
parseOutput: 'raw',
},
'hashicorp-vault': {
// HashiCorp Vault KV v2 (default). Requires VAULT_ADDR env var.
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
// Authentication is handled by VAULT_TOKEN or other Vault auth env vars.
name: 'hashicorp-vault',
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
parseOutput: 'raw',
},
'hashicorp-vault-kv1': {
// HashiCorp Vault KV v1. Requires VAULT_ADDR env var.
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
name: 'hashicorp-vault-kv1',
command: 'vault read -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
parseOutput: 'raw',
},
vault: {
// Short alias for hashicorp-vault (KV v2)
name: 'vault',
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
parseOutput: 'raw',
},
};
/***/ }),
/***/ 23451:

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,161 @@
import { OrchestratorFolders } from './orchestrator-folders';
// Mock Orchestrator
jest.mock('../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
buildGuid: 'test-guid-abc',
cacheKey: 'my-cache-key',
projectPath: 'test-project',
buildPath: 'Builds',
maxRetainedWorkspaces: 0,
gitPrivateToken: 'ghp_test123',
orchestratorRepoName: 'game-ci/unity-builder',
githubRepo: 'user/my-game',
},
lockedWorkspace: '',
},
}));
jest.mock('../../build-parameters', () => ({
__esModule: true,
default: {
shouldUseRetainedWorkspaceMode: jest.fn().mockReturnValue(false),
},
}));
jest.mock('./orchestrator-options', () => ({
__esModule: true,
default: {
useSharedBuilder: false,
},
}));
// Normalize paths for cross-platform test compatibility
const normalize = (p: string) => p.replace(/\\/g, '/');
describe('OrchestratorFolders', () => {
describe('static constants', () => {
it('repositoryFolder is "repo"', () => {
expect(OrchestratorFolders.repositoryFolder).toBe('repo');
});
it('buildVolumeFolder is "data"', () => {
expect(OrchestratorFolders.buildVolumeFolder).toBe('data');
});
it('cacheFolder is "cache"', () => {
expect(OrchestratorFolders.cacheFolder).toBe('cache');
});
});
describe('ToLinuxFolder', () => {
it('converts backslashes to forward slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('C:\\Users\\test\\project')).toBe('C:/Users/test/project');
});
it('preserves forward slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('/home/user/project')).toBe('/home/user/project');
});
it('handles mixed slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('some/path\\mixed/slashes\\here')).toBe('some/path/mixed/slashes/here');
});
it('handles empty string', () => {
expect(OrchestratorFolders.ToLinuxFolder('')).toBe('');
});
});
describe('path computations (non-retained workspace mode)', () => {
it('uniqueOrchestratorJobFolderAbsolute uses buildGuid', () => {
const result = normalize(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute);
expect(result).toBe('/data/test-guid-abc');
});
it('cacheFolderForAllFull returns /data/cache', () => {
const result = normalize(OrchestratorFolders.cacheFolderForAllFull);
expect(result).toBe('/data/cache');
});
it('cacheFolderForCacheKeyFull includes cache key', () => {
const result = normalize(OrchestratorFolders.cacheFolderForCacheKeyFull);
expect(result).toBe('/data/cache/my-cache-key');
});
it('repoPathAbsolute is under job folder', () => {
const result = normalize(OrchestratorFolders.repoPathAbsolute);
expect(result).toBe('/data/test-guid-abc/repo');
});
it('projectPathAbsolute includes project path', () => {
const result = normalize(OrchestratorFolders.projectPathAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/test-project');
});
it('libraryFolderAbsolute is under project path', () => {
const result = normalize(OrchestratorFolders.libraryFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/test-project/Library');
});
it('projectBuildFolderAbsolute uses buildPath', () => {
const result = normalize(OrchestratorFolders.projectBuildFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/Builds');
});
it('lfsFolderAbsolute is under .git/lfs', () => {
const result = normalize(OrchestratorFolders.lfsFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/.git/lfs');
});
it('lfsCacheFolderFull is under cache key', () => {
const result = normalize(OrchestratorFolders.lfsCacheFolderFull);
expect(result).toBe('/data/cache/my-cache-key/lfs');
});
it('libraryCacheFolderFull is under cache key', () => {
const result = normalize(OrchestratorFolders.libraryCacheFolderFull);
expect(result).toBe('/data/cache/my-cache-key/Library');
});
});
describe('builderPathAbsolute', () => {
it('uses job folder when shared builder is disabled', () => {
const result = normalize(OrchestratorFolders.builderPathAbsolute);
expect(result).toBe('/data/test-guid-abc/builder');
});
});
describe('repo URLs', () => {
it('unityBuilderRepoUrl includes token and repo name', () => {
const url = OrchestratorFolders.unityBuilderRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/game-ci/unity-builder.git');
});
it('targetBuildRepoUrl includes token and github repo', () => {
const url = OrchestratorFolders.targetBuildRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/user/my-game.git');
});
});
describe('purgeRemoteCaching', () => {
it('returns false when env var is not set', () => {
const original = process.env.PURGE_REMOTE_BUILDER_CACHE;
delete process.env.PURGE_REMOTE_BUILDER_CACHE;
expect(OrchestratorFolders.purgeRemoteCaching).toBe(false);
if (original !== undefined) process.env.PURGE_REMOTE_BUILDER_CACHE = original;
});
it('returns true when env var is set', () => {
const original = process.env.PURGE_REMOTE_BUILDER_CACHE;
process.env.PURGE_REMOTE_BUILDER_CACHE = 'true';
expect(OrchestratorFolders.purgeRemoteCaching).toBe(true);
if (original !== undefined) {
process.env.PURGE_REMOTE_BUILDER_CACHE = original;
} else {
delete process.env.PURGE_REMOTE_BUILDER_CACHE;
}
});
});
});

View File

@@ -0,0 +1,53 @@
import OrchestratorNamespace from './orchestrator-guid';
describe('OrchestratorNamespace', () => {
describe('generateGuid', () => {
it('generates a guid with correct format', () => {
const guid = OrchestratorNamespace.generateGuid('42', 'StandaloneLinux64');
// Format: {runNumber}-{platform}-{nanoid4}
expect(guid).toMatch(/^42-linux64-[a-z0-9]{4}$/);
});
it('strips "standalone" prefix from platform (case-insensitive)', () => {
const guid = OrchestratorNamespace.generateGuid('1', 'StandaloneWindows64');
expect(guid).toMatch(/^1-windows64-[a-z0-9]{4}$/);
});
it('lowercases platform name', () => {
const guid = OrchestratorNamespace.generateGuid('5', 'Android');
expect(guid).toMatch(/^5-android-[a-z0-9]{4}$/);
});
it('handles numeric run number', () => {
const guid = OrchestratorNamespace.generateGuid(100, 'iOS');
expect(guid).toMatch(/^100-ios-[a-z0-9]{4}$/);
});
it('generates unique guids on repeated calls', () => {
const guids = new Set<string>();
for (let i = 0; i < 20; i++) {
guids.add(OrchestratorNamespace.generateGuid('1', 'StandaloneLinux64'));
}
// With 4 alphanumeric chars (36^4 = ~1.7M possibilities), 20 calls should almost certainly be unique
expect(guids.size).toBeGreaterThan(1);
});
it('handles StandaloneOSX platform', () => {
const guid = OrchestratorNamespace.generateGuid('7', 'StandaloneOSX');
expect(guid).toMatch(/^7-osx-[a-z0-9]{4}$/);
});
it('handles WebGL platform (no standalone prefix)', () => {
const guid = OrchestratorNamespace.generateGuid('3', 'WebGL');
expect(guid).toMatch(/^3-webgl-[a-z0-9]{4}$/);
});
it('uses only lowercase alphanumeric characters in nanoid portion', () => {
for (let i = 0; i < 10; i++) {
const guid = OrchestratorNamespace.generateGuid('1', 'test');
const nanoidPart = guid.split('-').pop()!;
expect(nanoidPart).toMatch(/^[0-9a-z]{4}$/);
}
});
});
});

View File

@@ -190,10 +190,6 @@ class OrchestratorOptions {
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
}
static get secretSource(): string {
return OrchestratorOptions.getInput('secretSource') || '';
}
static get inputPullCommand(): string {
const value = OrchestratorOptions.getInput('inputPullCommand');

View File

@@ -1,9 +1,6 @@
import * as core from '@actions/core';
import Input from '../../input';
import { GenericInputReader } from '../../input-readers/generic-input-reader';
import OrchestratorOptions from './orchestrator-options';
import { SecretSourceService, validateSecretKey } from '../services/secrets/secret-source-service';
import OrchestratorLogger from '../services/core/orchestrator-logger';
const formatFunction = (value: string, arguments_: any[]) => {
for (const element of arguments_) {
@@ -16,6 +13,8 @@ const formatFunction = (value: string, arguments_: any[]) => {
class OrchestratorQueryOverride {
static queryOverrides: { [key: string]: string } | undefined;
// TODO accept premade secret sources or custom secret source definition yamls
public static query(key: string, alternativeKey: string) {
if (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) {
return OrchestratorQueryOverride.queryOverrides[key];
@@ -50,62 +49,14 @@ class OrchestratorQueryOverride {
throw new Error(`Should not be trying to run override query on ${query}`);
}
// Validate the query key before interpolating it into a shell command
validateSecretKey(query);
const result = await GenericInputReader.Run(
return await GenericInputReader.Run(
formatFunction(OrchestratorOptions.inputPullCommand, [{ key: 0, value: query }]),
);
// Mask the fetched secret value so it does not appear in GitHub Actions logs
if (result && result.trim().length > 0) {
core.setSecret(result);
}
return result;
}
/**
* Populate query overrides using either:
* 1. Premade/custom secret sources (via secretSource input), or
* 2. Shell command (via inputPullCommand, legacy approach)
*
* The secretSource input takes precedence if set. It supports:
* - Premade names: 'aws-secrets-manager', 'aws-parameter-store', 'gcp-secret-manager', 'azure-key-vault', 'env'
* - Custom commands: any string containing {0} placeholder
* - YAML file path: a path ending in .yml or .yaml containing custom source definitions
*/
public static async PopulateQueryOverrideInput() {
const queries = OrchestratorOptions.pullInputList;
OrchestratorQueryOverride.queryOverrides = {};
const secretSource = OrchestratorOptions.secretSource;
// Use SecretSourceService if secretSource is configured
if (secretSource) {
OrchestratorLogger.log(`Using secret source: ${secretSource}`);
// YAML file: load definitions and use the first source
if (secretSource.endsWith('.yml') || secretSource.endsWith('.yaml')) {
const definitions = SecretSourceService.loadFromYaml(secretSource);
if (definitions.length > 0) {
OrchestratorLogger.log(`Loaded ${definitions.length} secret source(s) from ${secretSource}`);
for (const key of queries) {
OrchestratorQueryOverride.queryOverrides[key] = await SecretSourceService.fetchSecret(definitions[0], key);
}
}
return;
}
// Premade or custom command source
const results = await SecretSourceService.fetchAll(secretSource, queries);
Object.assign(OrchestratorQueryOverride.queryOverrides, results);
return;
}
// Legacy: use inputPullCommand if set
for (const element of queries) {
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);

View File

@@ -0,0 +1,147 @@
import { FollowLogStreamService } from './follow-log-stream-service';
import * as core from '@actions/core';
import GitHub from '../../../github';
// Mock dependencies
jest.mock('../../../github', () => ({
__esModule: true,
default: {
updateGitHubCheck: jest.fn(),
githubInputEnabled: false,
},
}));
jest.mock('@actions/core', () => ({
warning: jest.fn(),
setOutput: jest.fn(),
setFailed: jest.fn(),
error: jest.fn(),
getInput: jest.fn().mockReturnValue(''),
}));
jest.mock('../../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
logId: 'test-log-id-123',
},
},
}));
jest.mock('../../options/orchestrator-statics', () => ({
OrchestratorStatics: {
logPrefix: 'TEST',
},
}));
jest.mock('./orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
},
}));
describe('FollowLogStreamService', () => {
beforeEach(() => {
jest.clearAllMocks();
FollowLogStreamService.Reset();
FollowLogStreamService.errors = '';
});
describe('Reset', () => {
it('resets DidReceiveEndOfTransmission to false', () => {
FollowLogStreamService.DidReceiveEndOfTransmission = true;
FollowLogStreamService.Reset();
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(false);
});
});
describe('handleIteration', () => {
it('detects end of transmission marker', () => {
const result = FollowLogStreamService.handleIteration('---test-log-id-123', true, false, '');
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(true);
expect(result.shouldReadLogs).toBe(false);
});
it('does not trigger end of transmission for non-matching log ID', () => {
const result = FollowLogStreamService.handleIteration('---different-log-id', true, false, '');
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(false);
expect(result.shouldReadLogs).toBe(true);
});
it('detects Library rebuild message', () => {
FollowLogStreamService.handleIteration(
'Rebuilding Library because the asset database could not be found!',
true,
false,
'',
);
expect(GitHub.updateGitHubCheck).toHaveBeenCalledWith('Library was not found, importing new Library', '');
expect(core.warning).toHaveBeenCalledWith('LIBRARY NOT FOUND!');
expect(core.setOutput).toHaveBeenCalledWith('library-found', 'false');
});
it('detects Build succeeded message', () => {
FollowLogStreamService.handleIteration('Build succeeded', true, false, '');
expect(GitHub.updateGitHubCheck).toHaveBeenCalledWith('Build succeeded', 'Build succeeded');
expect(core.setOutput).toHaveBeenCalledWith('build-result', 'success');
});
it('detects Build fail message', () => {
FollowLogStreamService.handleIteration('Build fail', true, false, '');
expect(GitHub.updateGitHubCheck).toHaveBeenCalled();
expect(core.setOutput).toHaveBeenCalledWith('build-result', 'failed');
expect(core.setFailed).toHaveBeenCalledWith('unity build failed');
expect(core.error).toHaveBeenCalledWith('BUILD FAILED!');
});
it('accumulates error messages with "error " pattern', () => {
FollowLogStreamService.handleIteration('error CS0001: Something went wrong', true, false, '');
expect(FollowLogStreamService.errors).toContain('error CS0001: Something went wrong');
expect(core.error).toHaveBeenCalled();
});
it('accumulates error messages with "error: " pattern', () => {
FollowLogStreamService.handleIteration('Fatal Error: Out of memory', true, false, '');
expect(FollowLogStreamService.errors).toContain('Fatal Error: Out of memory');
});
it('accumulates "command failed: " messages', () => {
FollowLogStreamService.handleIteration('command failed: git pull', true, false, '');
expect(FollowLogStreamService.errors).toContain('command failed: git pull');
});
it('accumulates "invalid " messages', () => {
FollowLogStreamService.handleIteration('invalid configuration value', true, false, '');
expect(FollowLogStreamService.errors).toContain('invalid configuration value');
});
it('accumulates "cannot be found" messages', () => {
FollowLogStreamService.handleIteration('Assembly cannot be found', true, false, '');
expect(FollowLogStreamService.errors).toContain('Assembly cannot be found');
});
it('appends message to output', () => {
const result = FollowLogStreamService.handleIteration('Some normal log line', true, false, 'previous output\n');
expect(result.output).toContain('Some normal log line');
expect(result.output).toContain('previous output');
});
it('preserves shouldCleanup value', () => {
const result = FollowLogStreamService.handleIteration('normal message', true, true, '');
expect(result.shouldCleanup).toBe(true);
});
it('does not change shouldReadLogs for normal messages', () => {
const result = FollowLogStreamService.handleIteration('Just a regular build log', true, false, '');
expect(result.shouldReadLogs).toBe(true);
});
it('includes accumulated errors in Build fail GitHub check message', () => {
FollowLogStreamService.errors = '\nprevious error';
FollowLogStreamService.handleIteration('Build fail', true, false, '');
const updateCall = (GitHub.updateGitHubCheck as jest.Mock).mock.calls[0];
expect(updateCall[0]).toContain('previous error');
});
});
});

View File

@@ -0,0 +1,207 @@
import { TaskParameterSerializer } from './task-parameter-serializer';
// Mock dependencies that TaskParameterSerializer uses internally
jest.mock('@actions/core', () => ({
getInput: jest.fn().mockReturnValue(''),
setOutput: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
}));
jest.mock('../../options/orchestrator-options', () => ({
__esModule: true,
default: {
getInput: jest.fn().mockReturnValue(undefined),
ToEnvVarFormat: (input: string) => {
if (input.toUpperCase() === input) {
return input;
}
return input
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
.replace(/ /g, '_');
},
},
}));
jest.mock('../../options/orchestrator-options-reader', () => ({
__esModule: true,
default: {
GetProperties: jest.fn().mockReturnValue([]),
},
}));
jest.mock('../../options/orchestrator-query-override', () => ({
__esModule: true,
default: {
queryOverrides: undefined,
},
}));
jest.mock('../hooks/command-hook-service', () => ({
CommandHookService: {
getHooks: jest.fn().mockReturnValue([]),
getSecrets: jest.fn().mockReturnValue([]),
},
}));
jest.mock('../../../input', () => ({
__esModule: true,
default: {},
}));
jest.mock('../../../github', () => ({
__esModule: true,
default: {
githubInputEnabled: false,
},
}));
describe('TaskParameterSerializer', () => {
describe('ToEnvVarFormat', () => {
it('converts camelCase to UPPER_SNAKE_CASE', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('targetPlatform')).toBe('TARGET_PLATFORM');
});
it('converts single word to uppercase', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('version')).toBe('VERSION');
});
it('preserves already-uppercase strings', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('AWS_REGION')).toBe('AWS_REGION');
});
it('handles multi-word camelCase', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('buildPlatformTarget')).toBe('BUILD_PLATFORM_TARGET');
});
it('handles string starting with uppercase', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('BuildGuid')).toBe('BUILD_GUID');
});
});
describe('UndoEnvVarFormat', () => {
it('converts UPPER_SNAKE_CASE back to camelCase', () => {
expect(TaskParameterSerializer.UndoEnvVarFormat('TARGET_PLATFORM')).toBe('targetPlatform');
});
it('handles single word', () => {
expect(TaskParameterSerializer.UndoEnvVarFormat('VERSION')).toBe('version');
});
it('handles multiple underscores', () => {
expect(TaskParameterSerializer.UndoEnvVarFormat('BUILD_PLATFORM_TARGET')).toBe('buildPlatformTarget');
});
});
describe('round-trip conversion', () => {
it('ToEnvVarFormat -> UndoEnvVarFormat returns original for simple camelCase', () => {
const original = 'targetPlatform';
const envVar = TaskParameterSerializer.ToEnvVarFormat(original);
const roundTrip = TaskParameterSerializer.UndoEnvVarFormat(envVar);
expect(roundTrip).toBe(original);
});
it('round-trips multi-word keys', () => {
const original = 'cacheKey';
const envVar = TaskParameterSerializer.ToEnvVarFormat(original);
const roundTrip = TaskParameterSerializer.UndoEnvVarFormat(envVar);
expect(roundTrip).toBe(original);
});
});
describe('uniqBy', () => {
it('removes duplicates by key function', () => {
const items = [
{ name: 'A', value: '1' },
{ name: 'B', value: '2' },
{ name: 'A', value: '3' },
];
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
expect(result).toHaveLength(2);
expect(result[0].value).toBe('1');
expect(result[1].value).toBe('2');
});
it('returns all items when no duplicates', () => {
const items = [
{ name: 'A', value: '1' },
{ name: 'B', value: '2' },
{ name: 'C', value: '3' },
];
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
expect(result).toHaveLength(3);
});
it('handles empty array', () => {
const result = TaskParameterSerializer.uniqBy([], (x) => x.name);
expect(result).toHaveLength(0);
});
it('keeps first occurrence when duplicates exist', () => {
const items = [
{ name: 'KEY', value: 'first' },
{ name: 'KEY', value: 'second' },
{ name: 'KEY', value: 'third' },
];
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
expect(result).toHaveLength(1);
expect(result[0].value).toBe('first');
});
});
describe('blockedParameterNames', () => {
it('contains expected blocked names', () => {
expect(TaskParameterSerializer.blockedParameterNames.has('0')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('length')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('prototype')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('unityVersion')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('CUSTOM_JOB')).toBe(true);
});
it('does not block valid parameter names', () => {
expect(TaskParameterSerializer.blockedParameterNames.has('targetPlatform')).toBe(false);
expect(TaskParameterSerializer.blockedParameterNames.has('buildGuid')).toBe(false);
expect(TaskParameterSerializer.blockedParameterNames.has('cacheKey')).toBe(false);
});
});
describe('readDefaultSecrets', () => {
it('returns an array', () => {
const secrets = TaskParameterSerializer.readDefaultSecrets();
expect(Array.isArray(secrets)).toBe(true);
});
it('includes secrets from environment when present', () => {
const originalSerial = process.env.UNITY_SERIAL;
process.env.UNITY_SERIAL = 'test-serial';
const secrets = TaskParameterSerializer.readDefaultSecrets();
const serialSecret = secrets.find((s) => s.ParameterKey === 'UNITY_SERIAL');
expect(serialSecret).toBeDefined();
expect(serialSecret?.ParameterValue).toBe('test-serial');
if (originalSerial !== undefined) {
process.env.UNITY_SERIAL = originalSerial;
} else {
delete process.env.UNITY_SERIAL;
}
});
it('excludes secrets not in environment', () => {
const originalSerial = process.env.UNITY_SERIAL;
delete process.env.UNITY_SERIAL;
const secrets = TaskParameterSerializer.readDefaultSecrets();
const serialSecret = secrets.find((s) => s.ParameterKey === 'UNITY_SERIAL');
expect(serialSecret).toBeUndefined();
if (originalSerial !== undefined) {
process.env.UNITY_SERIAL = originalSerial;
}
});
});
});

View File

@@ -1,446 +0,0 @@
import fs from 'node:fs';
import * as core from '@actions/core';
import { SecretSourceService, validateSecretKey } from './secret-source-service';
jest.mock('node:fs');
jest.mock('@actions/core', () => ({
setSecret: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
}));
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('SecretSourceService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('validateSecretKey', () => {
it('should accept alphanumeric keys', () => {
expect(validateSecretKey('MY_SECRET_KEY')).toBe('MY_SECRET_KEY');
});
it('should accept keys with hyphens', () => {
expect(validateSecretKey('my-secret-key')).toBe('my-secret-key');
});
it('should accept keys with dots', () => {
expect(validateSecretKey('my.secret.key')).toBe('my.secret.key');
});
it('should accept keys with forward slashes', () => {
expect(validateSecretKey('path/to/secret')).toBe('path/to/secret');
});
it('should accept keys with mixed valid characters', () => {
expect(validateSecretKey('my-app/prod_db.password')).toBe('my-app/prod_db.password');
});
it('should reject keys with semicolons (shell injection)', () => {
expect(() => validateSecretKey('; rm -rf /')).toThrow('Invalid secret key name');
});
it('should reject keys with backticks (command substitution)', () => {
expect(() => validateSecretKey('`whoami`')).toThrow('Invalid secret key name');
});
it('should reject keys with dollar signs (variable expansion)', () => {
expect(() => validateSecretKey('$HOME')).toThrow('Invalid secret key name');
});
it('should reject keys with pipe characters', () => {
expect(() => validateSecretKey('key | cat /etc/passwd')).toThrow('Invalid secret key name');
});
it('should reject keys with ampersands', () => {
expect(() => validateSecretKey('key && echo pwned')).toThrow('Invalid secret key name');
});
it('should reject keys with newlines', () => {
expect(() => validateSecretKey('key\nmalicious')).toThrow('Invalid secret key name');
});
it('should reject keys with quotes', () => {
expect(() => validateSecretKey('"key"')).toThrow('Invalid secret key name');
expect(() => validateSecretKey("'key'")).toThrow('Invalid secret key name');
});
it('should reject keys with parentheses (subshell)', () => {
expect(() => validateSecretKey('$(whoami)')).toThrow('Invalid secret key name');
});
it('should reject empty keys', () => {
expect(() => validateSecretKey('')).toThrow('Invalid secret key name');
});
it('should reject keys with spaces', () => {
expect(() => validateSecretKey('key with spaces')).toThrow('Invalid secret key name');
});
});
describe('isPremadeSource', () => {
it('should return true for aws-secrets-manager', () => {
expect(SecretSourceService.isPremadeSource('aws-secrets-manager')).toBe(true);
});
it('should return true for aws-secret-manager (legacy alias)', () => {
expect(SecretSourceService.isPremadeSource('aws-secret-manager')).toBe(true);
});
it('should return true for aws-parameter-store', () => {
expect(SecretSourceService.isPremadeSource('aws-parameter-store')).toBe(true);
});
it('should return true for gcp-secret-manager', () => {
expect(SecretSourceService.isPremadeSource('gcp-secret-manager')).toBe(true);
});
it('should return true for azure-key-vault', () => {
expect(SecretSourceService.isPremadeSource('azure-key-vault')).toBe(true);
});
it('should return true for hashicorp-vault', () => {
expect(SecretSourceService.isPremadeSource('hashicorp-vault')).toBe(true);
});
it('should return true for hashicorp-vault-kv1', () => {
expect(SecretSourceService.isPremadeSource('hashicorp-vault-kv1')).toBe(true);
});
it('should return true for vault (short alias)', () => {
expect(SecretSourceService.isPremadeSource('vault')).toBe(true);
});
it('should return false for unknown source', () => {
expect(SecretSourceService.isPremadeSource('unknown-source')).toBe(false);
});
});
describe('getAvailableSources', () => {
it('should return all premade source names', () => {
const sources = SecretSourceService.getAvailableSources();
expect(sources).toContain('aws-secrets-manager');
expect(sources).toContain('aws-parameter-store');
expect(sources).toContain('gcp-secret-manager');
expect(sources).toContain('azure-key-vault');
expect(sources).toContain('hashicorp-vault');
expect(sources).toContain('hashicorp-vault-kv1');
expect(sources).toContain('vault');
expect(sources.length).toBeGreaterThanOrEqual(8);
});
});
describe('resolveSource', () => {
it('should resolve premade source by name', () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager');
expect(source).toBeDefined();
expect(source!.name).toBe('aws-secrets-manager');
expect(source!.command).toContain('secretsmanager');
});
it('should resolve custom command with {0} placeholder', () => {
const source = SecretSourceService.resolveSource('vault kv get -field=value secret/{0}');
expect(source).toBeDefined();
expect(source!.name).toBe('custom-command');
expect(source!.command).toContain('{0}');
});
it('should resolve command with spaces as custom command', () => {
const source = SecretSourceService.resolveSource('my-tool get-secret');
expect(source).toBeDefined();
expect(source!.name).toBe('custom-command');
});
it('should return undefined for unknown single-word source', () => {
const source = SecretSourceService.resolveSource('unknown');
expect(source).toBeUndefined();
});
});
describe('fetchSecret', () => {
it('should run the command with {0} replaced by key', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('my-secret-value');
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
const result = await SecretSourceService.fetchSecret(source, 'MY_SECRET');
expect(result).toBe('my-secret-value');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('MY_SECRET'), false, true);
});
it('should parse JSON output when parseOutput is json-field', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue(JSON.stringify({ value: 'extracted-secret' }));
const source = {
name: 'test-source',
command: 'fetch {0}',
parseOutput: 'json-field' as const,
jsonField: 'value',
};
const result = await SecretSourceService.fetchSecret(source, 'KEY');
expect(result).toBe('extracted-secret');
});
it('should fall back to raw output on invalid JSON with json-field mode', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('not-json');
const source = {
name: 'test-source',
command: 'fetch {0}',
parseOutput: 'json-field' as const,
jsonField: 'value',
};
const result = await SecretSourceService.fetchSecret(source, 'KEY');
expect(result).toBe('not-json');
});
it('should return empty string on command failure', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockRejectedValue(new Error('command not found'));
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
const result = await SecretSourceService.fetchSecret(source, 'KEY');
expect(result).toBe('');
});
it('should reject keys with shell injection characters', async () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await expect(SecretSourceService.fetchSecret(source, '; rm -rf /')).rejects.toThrow('Invalid secret key name');
});
it('should reject keys with command substitution', async () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await expect(SecretSourceService.fetchSecret(source, '$(whoami)')).rejects.toThrow('Invalid secret key name');
});
it('should reject keys with backtick command substitution', async () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await expect(SecretSourceService.fetchSecret(source, '`cat /etc/passwd`')).rejects.toThrow(
'Invalid secret key name',
);
});
it('should accept keys with valid path-like patterns', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('secret-value');
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
const result = await SecretSourceService.fetchSecret(source, 'prod/database/password');
expect(result).toBe('secret-value');
});
it('should mask fetched secret values with core.setSecret', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('super-secret-value');
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await SecretSourceService.fetchSecret(source, 'MY_SECRET');
expect(core.setSecret).toHaveBeenCalledWith('super-secret-value');
});
it('should not mask empty secret values', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('');
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await SecretSourceService.fetchSecret(source, 'MY_SECRET');
expect(core.setSecret).not.toHaveBeenCalled();
});
it('should mask JSON-extracted secret values', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue(JSON.stringify({ value: 'json-secret' }));
const source = {
name: 'test-source',
command: 'fetch {0}',
parseOutput: 'json-field' as const,
jsonField: 'value',
};
await SecretSourceService.fetchSecret(source, 'KEY');
expect(core.setSecret).toHaveBeenCalledWith('json-secret');
});
});
describe('fetchFromEnv', () => {
it('should return env var value when set', () => {
process.env.TEST_SECRET_KEY = 'env-value';
const result = SecretSourceService.fetchFromEnv('TEST_SECRET_KEY');
expect(result).toBe('env-value');
delete process.env.TEST_SECRET_KEY;
});
it('should return empty string when env var is not set', () => {
const result = SecretSourceService.fetchFromEnv('NONEXISTENT_KEY_12345');
expect(result).toBe('');
});
it('should mask env var values with core.setSecret', () => {
process.env.TEST_MASK_KEY = 'masked-env-value';
SecretSourceService.fetchFromEnv('TEST_MASK_KEY');
expect(core.setSecret).toHaveBeenCalledWith('masked-env-value');
delete process.env.TEST_MASK_KEY;
});
it('should not mask empty env var values', () => {
const result = SecretSourceService.fetchFromEnv('NONEXISTENT_KEY_99999');
expect(result).toBe('');
expect(core.setSecret).not.toHaveBeenCalled();
});
});
describe('fetchAll', () => {
it('should fetch all keys from env source', async () => {
process.env.KEY_A = 'val-a';
process.env.KEY_B = 'val-b';
const results = await SecretSourceService.fetchAll('env', ['KEY_A', 'KEY_B']);
expect(results.KEY_A).toBe('val-a');
expect(results.KEY_B).toBe('val-b');
delete process.env.KEY_A;
delete process.env.KEY_B;
});
it('should fetch all keys from premade source', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValueOnce('secret-1').mockResolvedValueOnce('secret-2');
const results = await SecretSourceService.fetchAll('aws-parameter-store', ['param1', 'param2']);
expect(results.param1).toBe('secret-1');
expect(results.param2).toBe('secret-2');
expect(OrchestratorSystem.Run).toHaveBeenCalledTimes(2);
});
it('should return empty results for unknown source', async () => {
const results = await SecretSourceService.fetchAll('unknown', ['key1']);
expect(results).toEqual({});
});
});
describe('loadFromYaml', () => {
it('should return empty array when file does not exist', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = SecretSourceService.loadFromYaml('/nonexistent.yml');
expect(result).toEqual([]);
});
it('should parse valid YAML source definitions', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(`
sources:
- name: my-vault
command: 'vault kv get -field=value secret/{0}'
- name: my-api
command: 'curl -s https://api.example.com/{0}'
parseOutput: json-field
jsonField: secret_value
`);
const result = SecretSourceService.loadFromYaml('/sources.yml');
expect(result).toHaveLength(2);
expect(result[0].name).toBe('my-vault');
expect(result[0].command).toBe('vault kv get -field=value secret/{0}');
expect(result[1].name).toBe('my-api');
expect(result[1].parseOutput).toBe('json-field');
expect(result[1].jsonField).toBe('secret_value');
});
it('should handle YAML with single source', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(`
sources:
- name: simple
command: echo {0}
`);
const result = SecretSourceService.loadFromYaml('/simple.yml');
expect(result).toHaveLength(1);
expect(result[0].name).toBe('simple');
});
it('should return empty array on parse error', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});
const result = SecretSourceService.loadFromYaml('/error.yml');
expect(result).toEqual([]);
});
});
describe('premade source commands', () => {
it('aws-secrets-manager uses --query SecretString', () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
expect(source.command).toContain('--query SecretString');
expect(source.command).toContain('--output text');
});
it('aws-parameter-store uses --with-decryption', () => {
const source = SecretSourceService.resolveSource('aws-parameter-store')!;
expect(source.command).toContain('--with-decryption');
expect(source.command).toContain('--query Parameter.Value');
});
it('gcp-secret-manager uses latest version', () => {
const source = SecretSourceService.resolveSource('gcp-secret-manager')!;
expect(source.command).toContain('latest');
});
it('azure-key-vault uses AZURE_VAULT_NAME env var', () => {
const source = SecretSourceService.resolveSource('azure-key-vault')!;
expect(source.command).toContain('$AZURE_VAULT_NAME');
});
it('hashicorp-vault uses vault kv get with VAULT_MOUNT', () => {
const source = SecretSourceService.resolveSource('hashicorp-vault')!;
expect(source.command).toContain('vault kv get');
expect(source.command).toContain('VAULT_MOUNT');
expect(source.command).toContain('-field=value');
});
it('hashicorp-vault-kv1 uses vault read for KV v1', () => {
const source = SecretSourceService.resolveSource('hashicorp-vault-kv1')!;
expect(source.command).toContain('vault read');
expect(source.command).toContain('-field=value');
});
it('vault alias resolves to same command as hashicorp-vault', () => {
const vault = SecretSourceService.resolveSource('vault')!;
const hashicorpVault = SecretSourceService.resolveSource('hashicorp-vault')!;
expect(vault.command).toBe(hashicorpVault.command);
});
});
});

View File

@@ -1,337 +0,0 @@
import fs from 'node:fs';
import * as core from '@actions/core';
import OrchestratorLogger from '../core/orchestrator-logger';
import { OrchestratorSystem } from '../core/orchestrator-system';
/**
* A secret source definition: how to fetch a secret value by key.
*/
export interface SecretSourceDefinition {
name: string;
command: string;
parseOutput?: 'raw' | 'json-field';
jsonField?: string;
}
/**
* Validate that a secret key name contains only safe characters.
* Prevents shell injection when keys are interpolated into commands.
*
* Allowed characters: alphanumeric, hyphens, underscores, dots, forward slashes.
*
* @param key - The secret key name to validate
* @returns The validated key (unchanged)
* @throws Error if the key contains disallowed characters
*/
export function validateSecretKey(key: string): string {
if (!/^[a-zA-Z0-9\-_./]+$/.test(key)) {
throw new Error(
`Invalid secret key name: "${key}". Keys may only contain alphanumeric characters, hyphens, underscores, dots, and forward slashes.`,
);
}
return key;
}
/**
* Mask a secret value so it does not appear in GitHub Actions logs.
* Empty or whitespace-only values are skipped (core.setSecret would be a no-op).
*/
function maskSecretValue(value: string): void {
if (value.trim().length > 0) {
core.setSecret(value);
}
}
/**
* Premade secret sources and custom YAML-based secret source definitions.
*
* Premade sources are string shortcuts that expand to shell commands:
* - `aws-secrets-manager` -- AWS Secrets Manager
* - `aws-parameter-store` -- AWS Systems Manager Parameter Store
* - `gcp-secret-manager` -- Google Cloud Secret Manager
* - `azure-key-vault` -- Azure Key Vault (requires AZURE_VAULT_NAME env var)
* - `hashicorp-vault` -- HashiCorp Vault KV v2 (requires VAULT_ADDR, optionally VAULT_MOUNT)
* - `hashicorp-vault-kv1` -- HashiCorp Vault KV v1 (requires VAULT_ADDR, optionally VAULT_MOUNT)
* - `env` -- Read from environment variables (no shell command needed)
*
* Custom YAML format:
* sources:
* - name: my-vault
* command: 'vault kv get -field=value secret/{0}'
* - name: my-api
* command: 'curl -s https://secrets.example.com/api/{0}'
* parseOutput: json-field
* jsonField: value
*/
export class SecretSourceService {
private static readonly premadeSources: Record<string, SecretSourceDefinition> = {
'aws-secrets-manager': {
name: 'aws-secrets-manager',
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
parseOutput: 'raw',
},
'aws-secret-manager': {
// Alias for backward compatibility (original name in inputPullCommand)
name: 'aws-secret-manager',
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
parseOutput: 'raw',
},
'aws-parameter-store': {
name: 'aws-parameter-store',
command: 'aws ssm get-parameter --name {0} --with-decryption --query Parameter.Value --output text',
parseOutput: 'raw',
},
'gcp-secret-manager': {
name: 'gcp-secret-manager',
command: 'gcloud secrets versions access latest --secret="{0}"',
parseOutput: 'raw',
},
'azure-key-vault': {
name: 'azure-key-vault',
command: 'az keyvault secret show --vault-name "$AZURE_VAULT_NAME" --name {0} --query value --output tsv',
parseOutput: 'raw',
},
'hashicorp-vault': {
// HashiCorp Vault KV v2 (default). Requires VAULT_ADDR env var.
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
// Authentication is handled by VAULT_TOKEN or other Vault auth env vars.
name: 'hashicorp-vault',
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
parseOutput: 'raw',
},
'hashicorp-vault-kv1': {
// HashiCorp Vault KV v1. Requires VAULT_ADDR env var.
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
name: 'hashicorp-vault-kv1',
command: 'vault read -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
parseOutput: 'raw',
},
vault: {
// Short alias for hashicorp-vault (KV v2)
name: 'vault',
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
parseOutput: 'raw',
},
};
/**
* Check if a source name is a known premade source.
*/
static isPremadeSource(sourceName: string): boolean {
return sourceName in SecretSourceService.premadeSources;
}
/**
* Get the list of available premade source names.
*/
static getAvailableSources(): string[] {
return Object.keys(SecretSourceService.premadeSources);
}
/**
* Resolve a source name to a SecretSourceDefinition.
*
* - If the name matches a premade source, returns that definition.
* - If it looks like a shell command (contains spaces or {0}), wraps it as a custom command.
* - Otherwise, returns undefined.
*/
static resolveSource(sourceName: string): SecretSourceDefinition | undefined {
// Check premade sources
if (SecretSourceService.isPremadeSource(sourceName)) {
return SecretSourceService.premadeSources[sourceName];
}
// If it contains a placeholder or spaces, treat it as a raw command
if (sourceName.includes('{0}') || sourceName.includes(' ')) {
return {
name: 'custom-command',
command: sourceName,
parseOutput: 'raw',
};
}
return undefined;
}
/**
* Load custom secret source definitions from a YAML file.
*
* Expected format:
* sources:
* - name: my-source
* command: 'my-tool get-secret {0}'
* - name: my-api
* command: 'curl -s https://api.example.com/secrets/{0}'
* parseOutput: json-field
* jsonField: value
*/
static loadFromYaml(filePath: string): SecretSourceDefinition[] {
if (!fs.existsSync(filePath)) {
OrchestratorLogger.logWarning(`Secret source YAML not found: ${filePath}`);
return [];
}
try {
const content = fs.readFileSync(filePath, 'utf8');
const parsed = SecretSourceService.parseSimpleYaml(content);
return parsed;
} catch (error: any) {
OrchestratorLogger.logWarning(`Failed to parse secret source YAML: ${error.message}`);
return [];
}
}
/**
* Fetch a secret value using the given source definition.
*
* Validates the key against an allowlist pattern before interpolating it
* into the command string to prevent shell injection. The fetched secret
* value is masked via core.setSecret() so it does not leak in logs.
*
* @param source - The secret source definition to use
* @param key - The secret key to fetch
* @returns The secret value, or empty string on failure
*/
static async fetchSecret(source: SecretSourceDefinition, key: string): Promise<string> {
// Validate the key to prevent shell injection
validateSecretKey(key);
const command = source.command.replace(/\{0\}/g, key);
try {
const output = await OrchestratorSystem.Run(command, false, true);
let value: string;
if (source.parseOutput === 'json-field' && source.jsonField) {
try {
const parsed = JSON.parse(output);
value = parsed[source.jsonField] || '';
} catch {
OrchestratorLogger.logWarning(`Failed to parse JSON output from ${source.name} for key ${key}`);
value = output.trim();
}
} else {
value = output.trim();
}
// Mask the secret value so it does not appear in GitHub Actions logs
maskSecretValue(value);
return value;
} catch (error: any) {
OrchestratorLogger.logWarning(`Failed to fetch secret '${key}' from ${source.name}: ${error.message}`);
return '';
}
}
/**
* Fetch a secret from an environment variable. No shell command needed.
* The value is masked via core.setSecret() so it does not leak in logs.
*/
static fetchFromEnv(key: string): string {
const value = process.env[key] || '';
maskSecretValue(value);
return value;
}
/**
* Resolve a source name and fetch all secrets from it.
*
* @param sourceName - Premade source name, shell command, or 'env'
* @param keys - List of secret keys to fetch
* @returns Map of key -> value
*/
static async fetchAll(sourceName: string, keys: string[]): Promise<Record<string, string>> {
const results: Record<string, string> = {};
if (sourceName === 'env') {
for (const key of keys) {
results[key] = SecretSourceService.fetchFromEnv(key);
}
return results;
}
const source = SecretSourceService.resolveSource(sourceName);
if (!source) {
OrchestratorLogger.logWarning(
`Unknown secret source '${sourceName}'. Available sources: ${SecretSourceService.getAvailableSources().join(
', ',
)}`,
);
return results;
}
OrchestratorLogger.log(`Fetching ${keys.length} secret(s) from ${source.name}`);
for (const key of keys) {
results[key] = await SecretSourceService.fetchSecret(source, key);
}
return results;
}
/**
* Simple YAML parser for secret source definitions.
* Handles the specific structure we expect without requiring a YAML library.
*/
private static parseSimpleYaml(content: string): SecretSourceDefinition[] {
const definitions: SecretSourceDefinition[] = [];
const lines = content.split('\n');
let current: Partial<SecretSourceDefinition> | null = null;
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, '');
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) continue;
if (trimmed === '- name:' || trimmed.startsWith('- name:')) {
if (current?.name && current?.command) {
definitions.push(current as SecretSourceDefinition);
}
current = {
name: trimmed
.replace('- name:', '')
.trim()
.replace(/^['"]|['"]$/g, ''),
parseOutput: 'raw',
};
continue;
}
if (current && trimmed.startsWith('command:')) {
current.command = trimmed
.replace('command:', '')
.trim()
.replace(/^['"]|['"]$/g, '');
} else if (current && trimmed.startsWith('parseOutput:')) {
const value = trimmed
.replace('parseOutput:', '')
.trim()
.replace(/^['"]|['"]$/g, '');
current.parseOutput = value as 'raw' | 'json-field';
} else if (current && trimmed.startsWith('jsonField:')) {
current.jsonField = trimmed
.replace('jsonField:', '')
.trim()
.replace(/^['"]|['"]$/g, '');
}
}
if (current?.name && current?.command) {
definitions.push(current as SecretSourceDefinition);
}
return definitions;
}
}