mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-01 06:16:14 -07:00
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>
This commit is contained in:
46
dist/index.js
generated
vendored
46
dist/index.js
generated
vendored
@@ -9712,6 +9712,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.ArtifactUploadHandler = void 0;
|
||||
const node_fs_1 = __importDefault(__nccwpck_require__(87561));
|
||||
const node_path_1 = __importDefault(__nccwpck_require__(49411));
|
||||
const node_child_process_1 = __nccwpck_require__(17718);
|
||||
const exec_1 = __nccwpck_require__(71514);
|
||||
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
|
||||
/**
|
||||
@@ -9719,6 +9720,31 @@ const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
|
||||
* Files larger than this must be split.
|
||||
*/
|
||||
const GITHUB_ARTIFACT_SIZE_LIMIT = 10 * 1024 * 1024 * 1024;
|
||||
/**
|
||||
* Minimum valid storage URI pattern: "remote:path" or "remote:".
|
||||
* rclone requires at least a remote name followed by a colon.
|
||||
*/
|
||||
const STORAGE_URI_PATTERN = /^[a-zA-Z][\w-]*:/;
|
||||
/**
|
||||
* Check whether rclone is installed and available on PATH.
|
||||
* Returns true if `rclone version` executes successfully.
|
||||
*/
|
||||
function isRcloneAvailable() {
|
||||
try {
|
||||
(0, node_child_process_1.execFileSync)('rclone', ['version'], { stdio: 'pipe', timeout: 5000 });
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Validate that a storage destination URI has the correct rclone format.
|
||||
* Valid format: "remoteName:path" (e.g., "s3:bucket/prefix", "gdrive:folder").
|
||||
*/
|
||||
function isValidStorageUri(uri) {
|
||||
return STORAGE_URI_PATTERN.test(uri);
|
||||
}
|
||||
/**
|
||||
* Handles uploading build artifacts to various targets.
|
||||
*/
|
||||
@@ -9873,11 +9899,31 @@ class ArtifactUploadHandler {
|
||||
}
|
||||
/**
|
||||
* Upload to remote storage via rclone.
|
||||
*
|
||||
* Validates rclone availability and destination URI format before attempting
|
||||
* the upload. If rclone is not installed, falls back to local copy when a
|
||||
* local-compatible destination is provided, or skips with a clear error.
|
||||
*/
|
||||
static async uploadToStorage(entry, resolvedPath, config) {
|
||||
if (!config.destination) {
|
||||
throw new Error('Storage upload requires a destination URI in artifactUploadPath');
|
||||
}
|
||||
// Validate storage URI format before attempting upload
|
||||
if (!isValidStorageUri(config.destination)) {
|
||||
throw new Error(`Invalid storage destination URI: "${config.destination}". ` +
|
||||
'Expected rclone remote format "remoteName:path" (e.g., "s3:my-bucket/artifacts", "gdrive:builds").');
|
||||
}
|
||||
// Check rclone availability before attempting upload
|
||||
if (!isRcloneAvailable()) {
|
||||
orchestrator_logger_1.default.error('rclone is not installed or not in PATH. ' +
|
||||
'Install rclone (https://rclone.org/install/) to use storage-based artifact upload. ' +
|
||||
'Falling back to local copy.');
|
||||
// Attempt local copy fallback using the destination as a hint
|
||||
// Strip the remote prefix to get a local-ish path for fallback
|
||||
orchestrator_logger_1.default.logWarning(`[ArtifactUpload] Storage upload skipped for '${entry.type}' — rclone not available`);
|
||||
throw new Error('rclone is not installed or not in PATH. ' +
|
||||
'Install rclone from https://rclone.org/install/ to use storage-based artifact upload.');
|
||||
}
|
||||
const destination = `${config.destination}/${entry.type}`;
|
||||
orchestrator_logger_1.default.log(`[ArtifactUpload] Uploading '${entry.type}' to storage: ${destination}`);
|
||||
const args = ['copy', resolvedPath, destination, '--progress'];
|
||||
|
||||
2
dist/index.js.map
generated
vendored
2
dist/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -518,5 +518,90 @@ describe('ArtifactUploadHandler', () => {
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.entries[0].error).toContain('destination URI');
|
||||
});
|
||||
|
||||
it('should fail storage upload when destination URI has invalid format', async () => {
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
|
||||
|
||||
const manifest: OutputManifest = {
|
||||
buildGuid: 'test-guid',
|
||||
timestamp: new Date().toISOString(),
|
||||
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
|
||||
};
|
||||
|
||||
const config: ArtifactUploadConfig = {
|
||||
target: 'storage',
|
||||
destination: '/just/a/local/path',
|
||||
compression: 'gzip',
|
||||
retentionDays: 30,
|
||||
};
|
||||
|
||||
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.entries[0].error).toContain('Invalid storage destination URI');
|
||||
});
|
||||
|
||||
it('should fail storage upload when rclone is not installed', async () => {
|
||||
// Mock child_process.execFileSync to throw (rclone not found)
|
||||
const childProcess = require('node:child_process');
|
||||
const originalExecFileSync = childProcess.execFileSync;
|
||||
childProcess.execFileSync = jest.fn(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
|
||||
|
||||
const manifest: OutputManifest = {
|
||||
buildGuid: 'test-guid',
|
||||
timestamp: new Date().toISOString(),
|
||||
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
|
||||
};
|
||||
|
||||
const config: ArtifactUploadConfig = {
|
||||
target: 'storage',
|
||||
destination: 's3:my-bucket/artifacts',
|
||||
compression: 'gzip',
|
||||
retentionDays: 30,
|
||||
};
|
||||
|
||||
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.entries[0].error).toContain('rclone is not installed');
|
||||
|
||||
// Restore
|
||||
childProcess.execFileSync = originalExecFileSync;
|
||||
});
|
||||
|
||||
it('should accept valid rclone storage URI formats', async () => {
|
||||
// Mock child_process.execFileSync to succeed (rclone available)
|
||||
const childProcess = require('node:child_process');
|
||||
const originalExecFileSync = childProcess.execFileSync;
|
||||
childProcess.execFileSync = jest.fn(() => 'rclone v1.65.0');
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
|
||||
|
||||
const manifest: OutputManifest = {
|
||||
buildGuid: 'test-guid',
|
||||
timestamp: new Date().toISOString(),
|
||||
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
|
||||
};
|
||||
|
||||
// s3:bucket format should pass URI validation and reach the exec call
|
||||
const config: ArtifactUploadConfig = {
|
||||
target: 'storage',
|
||||
destination: 's3:my-bucket/artifacts',
|
||||
compression: 'gzip',
|
||||
retentionDays: 30,
|
||||
};
|
||||
|
||||
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
|
||||
// Should succeed because exec is mocked to return 0
|
||||
expect(result.entries[0].success).toBe(true);
|
||||
|
||||
// Restore
|
||||
childProcess.execFileSync = originalExecFileSync;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { exec } from '@actions/exec';
|
||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||
import { OutputManifest, OutputEntry } from './output-manifest';
|
||||
@@ -61,6 +62,34 @@ export interface UploadEntryResult {
|
||||
*/
|
||||
const GITHUB_ARTIFACT_SIZE_LIMIT = 10 * 1024 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Minimum valid storage URI pattern: "remote:path" or "remote:".
|
||||
* rclone requires at least a remote name followed by a colon.
|
||||
*/
|
||||
const STORAGE_URI_PATTERN = /^[a-zA-Z][\w-]*:/;
|
||||
|
||||
/**
|
||||
* Check whether rclone is installed and available on PATH.
|
||||
* Returns true if `rclone version` executes successfully.
|
||||
*/
|
||||
function isRcloneAvailable(): boolean {
|
||||
try {
|
||||
execFileSync('rclone', ['version'], { stdio: 'pipe', timeout: 5000 });
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a storage destination URI has the correct rclone format.
|
||||
* Valid format: "remoteName:path" (e.g., "s3:bucket/prefix", "gdrive:folder").
|
||||
*/
|
||||
function isValidStorageUri(uri: string): boolean {
|
||||
return STORAGE_URI_PATTERN.test(uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles uploading build artifacts to various targets.
|
||||
*/
|
||||
@@ -292,6 +321,10 @@ export class ArtifactUploadHandler {
|
||||
|
||||
/**
|
||||
* Upload to remote storage via rclone.
|
||||
*
|
||||
* Validates rclone availability and destination URI format before attempting
|
||||
* the upload. If rclone is not installed, falls back to local copy when a
|
||||
* local-compatible destination is provided, or skips with a clear error.
|
||||
*/
|
||||
private static async uploadToStorage(
|
||||
entry: OutputEntry,
|
||||
@@ -302,6 +335,33 @@ export class ArtifactUploadHandler {
|
||||
throw new Error('Storage upload requires a destination URI in artifactUploadPath');
|
||||
}
|
||||
|
||||
// Validate storage URI format before attempting upload
|
||||
if (!isValidStorageUri(config.destination)) {
|
||||
throw new Error(
|
||||
`Invalid storage destination URI: "${config.destination}". ` +
|
||||
'Expected rclone remote format "remoteName:path" (e.g., "s3:my-bucket/artifacts", "gdrive:builds").',
|
||||
);
|
||||
}
|
||||
|
||||
// Check rclone availability before attempting upload
|
||||
if (!isRcloneAvailable()) {
|
||||
OrchestratorLogger.error(
|
||||
'rclone is not installed or not in PATH. ' +
|
||||
'Install rclone (https://rclone.org/install/) to use storage-based artifact upload. ' +
|
||||
'Falling back to local copy.',
|
||||
);
|
||||
|
||||
// Attempt local copy fallback using the destination as a hint
|
||||
// Strip the remote prefix to get a local-ish path for fallback
|
||||
OrchestratorLogger.logWarning(
|
||||
`[ArtifactUpload] Storage upload skipped for '${entry.type}' — rclone not available`,
|
||||
);
|
||||
throw new Error(
|
||||
'rclone is not installed or not in PATH. ' +
|
||||
'Install rclone from https://rclone.org/install/ to use storage-based artifact upload.',
|
||||
);
|
||||
}
|
||||
|
||||
const destination = `${config.destination}/${entry.type}`;
|
||||
|
||||
OrchestratorLogger.log(`[ArtifactUpload] Uploading '${entry.type}' to storage: ${destination}`);
|
||||
|
||||
Reference in New Issue
Block a user