diff --git a/src/model/orchestrator/providers/ansible/ansible-provider.test.ts b/src/model/orchestrator/providers/ansible/ansible-provider.test.ts index 9f83e6f9..cb42fabd 100644 --- a/src/model/orchestrator/providers/ansible/ansible-provider.test.ts +++ b/src/model/orchestrator/providers/ansible/ansible-provider.test.ts @@ -2,9 +2,17 @@ import AnsibleProvider from '.'; import BuildParameters from '../../../build-parameters'; import { OrchestratorSystem } from '../../services/core/orchestrator-system'; import OrchestratorLogger from '../../services/core/orchestrator-logger'; +import * as core from '@actions/core'; jest.mock('../../services/core/orchestrator-system'); jest.mock('../../services/core/orchestrator-logger'); +jest.mock('@actions/core', () => ({ + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + setOutput: jest.fn(), + getInput: jest.fn(() => ''), +})); const mockRun = OrchestratorSystem.Run as jest.MockedFunction; const mockLog = OrchestratorLogger.log as jest.MockedFunction; @@ -52,16 +60,19 @@ describe('AnsibleProvider', () => { }); describe('setupWorkflow', () => { - it('verifies ansible binary is available and inventory exists', async () => { + it('verifies ansible binary, ansible-playbook binary, and inventory exist', async () => { mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible --version + mockRun.mockResolvedValueOnce('/usr/bin/ansible-playbook'); // ansible-playbook check mockRun.mockResolvedValueOnce(''); // test -e inventory await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []); - expect(mockRun).toHaveBeenCalledTimes(2); + expect(mockRun).toHaveBeenCalledTimes(3); expect(mockRun.mock.calls[0][0]).toContain('ansible --version'); - expect(mockRun.mock.calls[1][0]).toContain('test -e "/etc/ansible/hosts"'); + expect(mockRun.mock.calls[1][0]).toContain('ansible-playbook'); + expect(mockRun.mock.calls[2][0]).toContain('test -e "/etc/ansible/hosts"'); expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ansible')); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ansible-playbook binary verified')); }); it('throws when inventory is not configured', async () => { @@ -81,8 +92,20 @@ describe('AnsibleProvider', () => { ); }); + it('throws when ansible-playbook binary is not found', async () => { + mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible version OK + mockRun.mockRejectedValueOnce(new Error('command not found')); // ansible-playbook missing + + await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow( + 'ansible-playbook not found on PATH', + ); + + expect(core.error).toHaveBeenCalledWith('ansible-playbook not found. Install Ansible or ensure it is in PATH.'); + }); + it('throws when inventory file does not exist', async () => { mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible version OK + mockRun.mockResolvedValueOnce('/usr/bin/ansible-playbook'); // ansible-playbook OK mockRun.mockRejectedValueOnce(new Error('test -e failed')); // inventory missing await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow( @@ -125,9 +148,9 @@ describe('AnsibleProvider', () => { const params = createBuildParameters({ ansiblePlaybook: '' }); provider = new AnsibleProvider(params); - await expect( - provider.runTaskInWorkflow('guid-nopb', 'img', 'cmd', '/m', '/w', [], []), - ).rejects.toThrow('ansiblePlaybook is required'); + await expect(provider.runTaskInWorkflow('guid-nopb', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'ansiblePlaybook is required', + ); }); it('passes environment variables as extra-vars in snake_case', async () => { @@ -170,9 +193,7 @@ describe('AnsibleProvider', () => { await provider.runTaskInWorkflow('guid-badjson', 'img', 'cmd', '/m', '/w', [], []); - expect(mockLogWarning).toHaveBeenCalledWith( - expect.stringContaining('Failed to parse ansibleExtraVars'), - ); + expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Failed to parse ansibleExtraVars')); }); it('includes vault password file flag when configured', async () => { @@ -215,9 +236,9 @@ describe('AnsibleProvider', () => { const execError = new Error('UNREACHABLE! Host unreachable'); mockRun.mockRejectedValueOnce(execError); - await expect( - provider.runTaskInWorkflow('guid-hostfail', 'img', 'cmd', '/m', '/w', [], []), - ).rejects.toThrow('UNREACHABLE'); + await expect(provider.runTaskInWorkflow('guid-hostfail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'UNREACHABLE', + ); expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Playbook failed')); }); diff --git a/src/model/orchestrator/providers/ansible/index.ts b/src/model/orchestrator/providers/ansible/index.ts index b41ec1bb..ec7794d3 100644 --- a/src/model/orchestrator/providers/ansible/index.ts +++ b/src/model/orchestrator/providers/ansible/index.ts @@ -1,3 +1,4 @@ +import * as core from '@actions/core'; import BuildParameters from '../../../build-parameters'; import { OrchestratorSystem } from '../../services/core/orchestrator-system'; import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable'; @@ -53,6 +54,15 @@ class AnsibleProvider implements ProviderInterface { throw new Error(`Ansible not found on PATH: ${error.message || error}`); } + // Verify ansible-playbook binary exists (may be separate from ansible) + try { + await OrchestratorSystem.Run('command -v ansible-playbook || which ansible-playbook || where ansible-playbook'); + OrchestratorLogger.log(`[Ansible] ansible-playbook binary verified`); + } catch (error: any) { + core.error('ansible-playbook not found. Install Ansible or ensure it is in PATH.'); + throw new Error(`ansible-playbook not found on PATH: ${error.message || error}`); + } + // Verify inventory exists try { await OrchestratorSystem.Run(`test -e "${this.inventory}"`); diff --git a/src/model/orchestrator/providers/github-actions/github-actions-provider.test.ts b/src/model/orchestrator/providers/github-actions/github-actions-provider.test.ts index 952fb5b5..3c1f88ee 100644 --- a/src/model/orchestrator/providers/github-actions/github-actions-provider.test.ts +++ b/src/model/orchestrator/providers/github-actions/github-actions-provider.test.ts @@ -2,9 +2,17 @@ import GitHubActionsProvider from '.'; import BuildParameters from '../../../build-parameters'; import { OrchestratorSystem } from '../../services/core/orchestrator-system'; import OrchestratorLogger from '../../services/core/orchestrator-logger'; +import * as core from '@actions/core'; jest.mock('../../services/core/orchestrator-system'); jest.mock('../../services/core/orchestrator-logger'); +jest.mock('@actions/core', () => ({ + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + setOutput: jest.fn(), + getInput: jest.fn(() => ''), +})); const mockRun = OrchestratorSystem.Run as jest.MockedFunction; const mockLog = OrchestratorLogger.log as jest.MockedFunction; @@ -185,9 +193,9 @@ describe('GitHubActionsProvider', () => { mockRun.mockRejectedValueOnce(new Error('not found')); } - await expect( - provider.runTaskInWorkflow('guid-timeout', 'img', 'cmd', '/m', '/w', [], []), - ).rejects.toThrow('Workflow run did not start within'); + await expect(provider.runTaskInWorkflow('guid-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'Workflow run did not start within', + ); }); it('throws when workflow run fails with non-success conclusion', async () => { @@ -195,9 +203,9 @@ describe('GitHubActionsProvider', () => { mockRun.mockResolvedValueOnce(JSON.stringify({ id: 300, status: 'in_progress' })); // run appears mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'failure' })); // fails - await expect( - provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], []), - ).rejects.toThrow('Workflow run failed with conclusion: failure'); + await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'Workflow run failed with conclusion: failure', + ); }); it('returns fallback message when log fetch fails', async () => { @@ -217,9 +225,39 @@ describe('GitHubActionsProvider', () => { mockRun.mockResolvedValueOnce(JSON.stringify({ id: 500, status: 'in_progress' })); // run mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'cancelled' })); // cancelled - await expect( - provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], []), - ).rejects.toThrow('Workflow run failed with conclusion: cancelled'); + await expect(provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'Workflow run failed with conclusion: cancelled', + ); + }); + + it('throws timeout error when polling exceeds maximum duration', async () => { + // Save real Date.now + const realDateNow = Date.now; + let callCount = 0; + + // dispatch succeeds + mockRun.mockResolvedValueOnce(''); + // run appears + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 600, status: 'in_progress' })); + // Status always returns in_progress + mockRun.mockImplementation(() => Promise.resolve(JSON.stringify({ status: 'in_progress' }))); + + // First call returns normal time, subsequent calls simulate 5 hours elapsed + Date.now = () => { + callCount++; + if (callCount <= 2) return realDateNow.call(Date); + return realDateNow.call(Date) + 14_400_001; // 4 hours + 1ms + }; + + try { + await expect(provider.runTaskInWorkflow('guid-poll-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'did not complete within 4 hours', + ); + + expect(core.error).toHaveBeenCalledWith(expect.stringContaining('did not complete within 4 hours')); + } finally { + Date.now = realDateNow; + } }); }); diff --git a/src/model/orchestrator/providers/github-actions/index.ts b/src/model/orchestrator/providers/github-actions/index.ts index 6ac8d86e..20c2a67a 100644 --- a/src/model/orchestrator/providers/github-actions/index.ts +++ b/src/model/orchestrator/providers/github-actions/index.ts @@ -1,3 +1,4 @@ +import * as core from '@actions/core'; import BuildParameters from '../../../build-parameters'; import { OrchestratorSystem } from '../../services/core/orchestrator-system'; import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable'; @@ -7,6 +8,8 @@ import OrchestratorSecret from '../../options/orchestrator-secret'; import { ProviderResource } from '../provider-resource'; import { ProviderWorkflow } from '../provider-workflow'; +const MAX_POLLING_DURATION_MS = 14_400_000; // 4 hours + /** * GitHub Actions provider — triggers builds as workflow_dispatch events * on a target repository via the GitHub API. @@ -131,9 +134,20 @@ class GitHubActionsProvider implements ProviderInterface { throw new Error(`Workflow run did not start within ${maxAttempts * 10}s`); } - // Poll until completion and stream logs + // Poll until completion and stream logs (with maximum duration guard) let status = 'in_progress'; + const pollingStartTime = Date.now(); + const runUrl = `https://github.com/${this.repo}/actions/runs/${this.runId}`; + while (status === 'in_progress' || status === 'queued') { + const elapsedMs = Date.now() - pollingStartTime; + if (elapsedMs >= MAX_POLLING_DURATION_MS) { + const hours = Math.round(MAX_POLLING_DURATION_MS / 3_600_000); + const message = `GitHub Actions workflow did not complete within ${hours} hours. Run URL: ${runUrl}`; + core.error(message); + throw new Error(message); + } + await new Promise((resolve) => setTimeout(resolve, 15_000)); try { @@ -160,6 +174,9 @@ class GitHubActionsProvider implements ProviderInterface { if (error.message && error.message.includes('conclusion')) { throw error; } + if (error.message && error.message.includes('did not complete within')) { + throw error; + } OrchestratorLogger.logWarning(`[GitHubActions] Status check error: ${error.message || error}`); } } diff --git a/src/model/orchestrator/providers/gitlab-ci/gitlab-ci-provider.test.ts b/src/model/orchestrator/providers/gitlab-ci/gitlab-ci-provider.test.ts index 943d890b..348d72b6 100644 --- a/src/model/orchestrator/providers/gitlab-ci/gitlab-ci-provider.test.ts +++ b/src/model/orchestrator/providers/gitlab-ci/gitlab-ci-provider.test.ts @@ -2,9 +2,17 @@ import GitLabCIProvider from '.'; import BuildParameters from '../../../build-parameters'; import { OrchestratorSystem } from '../../services/core/orchestrator-system'; import OrchestratorLogger from '../../services/core/orchestrator-logger'; +import * as core from '@actions/core'; jest.mock('../../services/core/orchestrator-system'); jest.mock('../../services/core/orchestrator-logger'); +jest.mock('@actions/core', () => ({ + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + setOutput: jest.fn(), + getInput: jest.fn(() => ''), +})); const mockRun = OrchestratorSystem.Run as jest.MockedFunction; const mockLog = OrchestratorLogger.log as jest.MockedFunction; @@ -177,18 +185,18 @@ describe('GitLabCIProvider', () => { mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5003, status: 'pending' })); mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'failed' })); - await expect( - provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], []), - ).rejects.toThrow('Pipeline 5003 finished with status: failed'); + await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'Pipeline 5003 finished with status: failed', + ); }); it('throws when pipeline is canceled', async () => { mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5004, status: 'pending' })); mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'canceled' })); - await expect( - provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], []), - ).rejects.toThrow('Pipeline 5004 finished with status: canceled'); + await expect(provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'Pipeline 5004 finished with status: canceled', + ); }); it('handles job log fetch failures gracefully', async () => { @@ -229,6 +237,33 @@ describe('GitLabCIProvider', () => { expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Status check error')); }); + + it('throws timeout error when polling exceeds maximum duration', async () => { + const realDateNow = Date.now; + let callCount = 0; + + // Trigger pipeline succeeds + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5008, status: 'running' })); + // Status always returns running + mockRun.mockImplementation(() => Promise.resolve(JSON.stringify({ status: 'running' }))); + + // After first call, simulate 5 hours elapsed + Date.now = () => { + callCount++; + if (callCount <= 1) return realDateNow.call(Date); + return realDateNow.call(Date) + 14_400_001; // 4 hours + 1ms + }; + + try { + await expect(provider.runTaskInWorkflow('guid-poll-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'did not complete within 4 hours', + ); + + expect(core.error).toHaveBeenCalledWith(expect.stringContaining('did not complete within 4 hours')); + } finally { + Date.now = realDateNow; + } + }); }); describe('cleanupWorkflow', () => { diff --git a/src/model/orchestrator/providers/gitlab-ci/index.ts b/src/model/orchestrator/providers/gitlab-ci/index.ts index 388bc177..ad8256dc 100644 --- a/src/model/orchestrator/providers/gitlab-ci/index.ts +++ b/src/model/orchestrator/providers/gitlab-ci/index.ts @@ -1,3 +1,4 @@ +import * as core from '@actions/core'; import BuildParameters from '../../../build-parameters'; import { OrchestratorSystem } from '../../services/core/orchestrator-system'; import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable'; @@ -7,6 +8,8 @@ import OrchestratorSecret from '../../options/orchestrator-secret'; import { ProviderResource } from '../provider-resource'; import { ProviderWorkflow } from '../provider-workflow'; +const MAX_POLLING_DURATION_MS = 14_400_000; // 4 hours + /** * GitLab CI provider — triggers builds as GitLab CI pipelines * via the GitLab API. @@ -100,11 +103,21 @@ class GitLabCIProvider implements ProviderInterface { throw new Error(`Failed to trigger pipeline: ${error.message || error}`); } - // Poll until completion + // Poll until completion (with maximum duration guard) let status = 'pending'; const terminalStatuses = new Set(['success', 'failed', 'canceled', 'skipped']); + const pollingStartTime = Date.now(); + const pipelineUrl = `${this.apiUrl}/${this.projectId}/-/pipelines/${this.pipelineId}`; while (!terminalStatuses.has(status)) { + const elapsedMs = Date.now() - pollingStartTime; + if (elapsedMs >= MAX_POLLING_DURATION_MS) { + const hours = Math.round(MAX_POLLING_DURATION_MS / 3_600_000); + const message = `GitLab CI pipeline did not complete within ${hours} hours. Pipeline URL: ${pipelineUrl}`; + core.error(message); + throw new Error(message); + } + await new Promise((resolve) => setTimeout(resolve, 15_000)); try { diff --git a/src/model/orchestrator/providers/remote-powershell/index.ts b/src/model/orchestrator/providers/remote-powershell/index.ts index 7c9f7751..ace6a8e8 100644 --- a/src/model/orchestrator/providers/remote-powershell/index.ts +++ b/src/model/orchestrator/providers/remote-powershell/index.ts @@ -148,11 +148,17 @@ class RemotePowershellProvider implements ProviderInterface { } // WinRM (default) - const credentialPart = this.credential - ? `-Credential (New-Object PSCredential('${this.credential.split(':')[0]}', (ConvertTo-SecureString '${ - this.credential.split(':')[1] - }' -AsPlainText -Force)))` - : ''; + // Split on the FIRST colon only — passwords may contain colons + let credentialPart = ''; + if (this.credential) { + const colonIndex = this.credential.indexOf(':'); + if (colonIndex === -1) { + throw new Error('remotePowershellCredential must be in "username:password" format (no colon found)'); + } + const user = this.credential.substring(0, colonIndex); + const pass = this.credential.substring(colonIndex + 1); + credentialPart = `-Credential (New-Object PSCredential('${user}', (ConvertTo-SecureString '${pass}' -AsPlainText -Force)))`; + } return `pwsh -NoProfile -NonInteractive -Command "Invoke-Command -ComputerName '${this.host}' ${credentialPart} -ScriptBlock { ${escapedScript} }"`; } diff --git a/src/model/orchestrator/providers/remote-powershell/remote-powershell-provider.test.ts b/src/model/orchestrator/providers/remote-powershell/remote-powershell-provider.test.ts index be56de5a..f7b17c51 100644 --- a/src/model/orchestrator/providers/remote-powershell/remote-powershell-provider.test.ts +++ b/src/model/orchestrator/providers/remote-powershell/remote-powershell-provider.test.ts @@ -140,9 +140,7 @@ describe('RemotePowershellProvider', () => { it('includes secrets in the remote script block', async () => { mockRun.mockResolvedValueOnce('output'); - const secrets = [ - { ParameterKey: 'key1', EnvironmentVariable: 'SECRET_KEY', ParameterValue: 'secret-val-123' }, - ]; + const secrets = [{ ParameterKey: 'key1', EnvironmentVariable: 'SECRET_KEY', ParameterValue: 'secret-val-123' }]; await provider.runTaskInWorkflow('guid-sec', 'img', 'build-cmd', '/m', '/w', [], secrets as any); @@ -178,13 +176,38 @@ describe('RemotePowershellProvider', () => { const execError = new Error('Remote execution failed: access denied'); mockRun.mockRejectedValueOnce(execError); - await expect( - provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], []), - ).rejects.toThrow('Remote execution failed'); + await expect(provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'Remote execution failed', + ); expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Task failed')); }); + it('preserves passwords containing colons when splitting credentials', async () => { + const params = createBuildParameters({ + remotePowershellCredential: 'admin:P@ss:w0rd:with:colons!', + }); + provider = new RemotePowershellProvider(params); + mockRun.mockResolvedValueOnce('output'); + + await provider.runTaskInWorkflow('guid-colon', 'img', 'cmd', '/m', '/w', [], []); + + const command = mockRun.mock.calls[0][0]; + expect(command).toContain("PSCredential('admin'"); + expect(command).toContain("ConvertTo-SecureString 'P@ss:w0rd:with:colons!'"); + }); + + it('throws when credential has no colon separator', async () => { + const params = createBuildParameters({ + remotePowershellCredential: 'nocolonhere', + }); + provider = new RemotePowershellProvider(params); + + await expect(provider.runTaskInWorkflow('guid-badcred', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'username:password', + ); + }); + it('sets working directory in the remote script', async () => { mockRun.mockResolvedValueOnce('output');