Skip to content

Commit 7615bbd

Browse files
frostebiteclaude
andcommitted
fix(artifacts): validate rclone availability before storage upload
Check for rclone binary before attempting storage-based uploads. Validate storage destination URI format (remoteName:path). Provide clear error message with install link when rclone is missing. Fail gracefully instead of cryptic ENOENT crash. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aa2e05d commit 7615bbd

File tree

4 files changed

+192
-1
lines changed

4 files changed

+192
-1
lines changed

dist/index.js

Lines changed: 46 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/model/orchestrator/services/output/artifact-service.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,5 +518,90 @@ describe('ArtifactUploadHandler', () => {
518518
expect(result.success).toBe(false);
519519
expect(result.entries[0].error).toContain('destination URI');
520520
});
521+
522+
it('should fail storage upload when destination URI has invalid format', async () => {
523+
mockedFs.existsSync.mockReturnValue(true);
524+
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
525+
526+
const manifest: OutputManifest = {
527+
buildGuid: 'test-guid',
528+
timestamp: new Date().toISOString(),
529+
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
530+
};
531+
532+
const config: ArtifactUploadConfig = {
533+
target: 'storage',
534+
destination: '/just/a/local/path',
535+
compression: 'gzip',
536+
retentionDays: 30,
537+
};
538+
539+
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
540+
expect(result.success).toBe(false);
541+
expect(result.entries[0].error).toContain('Invalid storage destination URI');
542+
});
543+
544+
it('should fail storage upload when rclone is not installed', async () => {
545+
// Mock child_process.execFileSync to throw (rclone not found)
546+
const childProcess = require('node:child_process');
547+
const originalExecFileSync = childProcess.execFileSync;
548+
childProcess.execFileSync = jest.fn(() => {
549+
throw new Error('ENOENT');
550+
});
551+
552+
mockedFs.existsSync.mockReturnValue(true);
553+
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
554+
555+
const manifest: OutputManifest = {
556+
buildGuid: 'test-guid',
557+
timestamp: new Date().toISOString(),
558+
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
559+
};
560+
561+
const config: ArtifactUploadConfig = {
562+
target: 'storage',
563+
destination: 's3:my-bucket/artifacts',
564+
compression: 'gzip',
565+
retentionDays: 30,
566+
};
567+
568+
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
569+
expect(result.success).toBe(false);
570+
expect(result.entries[0].error).toContain('rclone is not installed');
571+
572+
// Restore
573+
childProcess.execFileSync = originalExecFileSync;
574+
});
575+
576+
it('should accept valid rclone storage URI formats', async () => {
577+
// Mock child_process.execFileSync to succeed (rclone available)
578+
const childProcess = require('node:child_process');
579+
const originalExecFileSync = childProcess.execFileSync;
580+
childProcess.execFileSync = jest.fn(() => 'rclone v1.65.0');
581+
582+
mockedFs.existsSync.mockReturnValue(true);
583+
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
584+
585+
const manifest: OutputManifest = {
586+
buildGuid: 'test-guid',
587+
timestamp: new Date().toISOString(),
588+
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
589+
};
590+
591+
// s3:bucket format should pass URI validation and reach the exec call
592+
const config: ArtifactUploadConfig = {
593+
target: 'storage',
594+
destination: 's3:my-bucket/artifacts',
595+
compression: 'gzip',
596+
retentionDays: 30,
597+
};
598+
599+
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
600+
// Should succeed because exec is mocked to return 0
601+
expect(result.entries[0].success).toBe(true);
602+
603+
// Restore
604+
childProcess.execFileSync = originalExecFileSync;
605+
});
521606
});
522607
});

src/model/orchestrator/services/output/artifact-upload-handler.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
3+
import { execFileSync } from 'node:child_process';
34
import { exec } from '@actions/exec';
45
import OrchestratorLogger from '../core/orchestrator-logger';
56
import { OutputManifest, OutputEntry } from './output-manifest';
@@ -61,6 +62,34 @@ export interface UploadEntryResult {
6162
*/
6263
const GITHUB_ARTIFACT_SIZE_LIMIT = 10 * 1024 * 1024 * 1024;
6364

65+
/**
66+
* Minimum valid storage URI pattern: "remote:path" or "remote:".
67+
* rclone requires at least a remote name followed by a colon.
68+
*/
69+
const STORAGE_URI_PATTERN = /^[a-zA-Z][\w-]*:/;
70+
71+
/**
72+
* Check whether rclone is installed and available on PATH.
73+
* Returns true if `rclone version` executes successfully.
74+
*/
75+
function isRcloneAvailable(): boolean {
76+
try {
77+
execFileSync('rclone', ['version'], { stdio: 'pipe', timeout: 5000 });
78+
79+
return true;
80+
} catch {
81+
return false;
82+
}
83+
}
84+
85+
/**
86+
* Validate that a storage destination URI has the correct rclone format.
87+
* Valid format: "remoteName:path" (e.g., "s3:bucket/prefix", "gdrive:folder").
88+
*/
89+
function isValidStorageUri(uri: string): boolean {
90+
return STORAGE_URI_PATTERN.test(uri);
91+
}
92+
6493
/**
6594
* Handles uploading build artifacts to various targets.
6695
*/
@@ -292,6 +321,10 @@ export class ArtifactUploadHandler {
292321

293322
/**
294323
* Upload to remote storage via rclone.
324+
*
325+
* Validates rclone availability and destination URI format before attempting
326+
* the upload. If rclone is not installed, falls back to local copy when a
327+
* local-compatible destination is provided, or skips with a clear error.
295328
*/
296329
private static async uploadToStorage(
297330
entry: OutputEntry,
@@ -302,6 +335,33 @@ export class ArtifactUploadHandler {
302335
throw new Error('Storage upload requires a destination URI in artifactUploadPath');
303336
}
304337

338+
// Validate storage URI format before attempting upload
339+
if (!isValidStorageUri(config.destination)) {
340+
throw new Error(
341+
`Invalid storage destination URI: "${config.destination}". ` +
342+
'Expected rclone remote format "remoteName:path" (e.g., "s3:my-bucket/artifacts", "gdrive:builds").',
343+
);
344+
}
345+
346+
// Check rclone availability before attempting upload
347+
if (!isRcloneAvailable()) {
348+
OrchestratorLogger.error(
349+
'rclone is not installed or not in PATH. ' +
350+
'Install rclone (https://rclone.org/install/) to use storage-based artifact upload. ' +
351+
'Falling back to local copy.',
352+
);
353+
354+
// Attempt local copy fallback using the destination as a hint
355+
// Strip the remote prefix to get a local-ish path for fallback
356+
OrchestratorLogger.logWarning(
357+
`[ArtifactUpload] Storage upload skipped for '${entry.type}' — rclone not available`,
358+
);
359+
throw new Error(
360+
'rclone is not installed or not in PATH. ' +
361+
'Install rclone from https://rclone.org/install/ to use storage-based artifact upload.',
362+
);
363+
}
364+
305365
const destination = `${config.destination}/${entry.type}`;
306366

307367
OrchestratorLogger.log(`[ArtifactUpload] Uploading '${entry.type}' to storage: ${destination}`);

0 commit comments

Comments
 (0)