mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-16 13:06:53 -07:00
feat(lfs): add built-in elastic-git-storage support with auto-install
First-class support for elastic-git-storage as a custom LFS transfer agent. When lfsTransferAgent is set to "elastic-git-storage" (or "elastic-git-storage@v1.0.0" for a specific version), the service automatically finds or installs the agent from GitHub releases, then configures it via git config. Supports version pinning via @version suffix in the agent value, eliminating the need for a separate version parameter. Platform and architecture detection handles linux/darwin/windows on amd64/arm64. 37 unit tests covering detection, PATH lookup, installation, version parsing, and configuration delegation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -320,7 +320,9 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
description:
|
description:
|
||||||
'Path to a custom Git LFS transfer agent executable (e.g. elastic-git-storage). When set, the agent is registered
|
'Custom Git LFS transfer agent. Set to "elastic-git-storage" for built-in support (auto-installs
|
||||||
|
from GitHub releases). Append @version for a specific release (e.g. "elastic-git-storage@v1.0.0").
|
||||||
|
Or provide a path to any custom transfer agent executable. When set, the agent is registered
|
||||||
via git config before LFS operations.'
|
via git config before LFS operations.'
|
||||||
lfsTransferAgentArgs:
|
lfsTransferAgentArgs:
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { ElasticGitStorageService } from './elastic-git-storage-service';
|
||||||
|
|
||||||
|
jest.mock('node:fs');
|
||||||
|
jest.mock('node:os');
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock('./lfs-agent-service', () => ({
|
||||||
|
LfsAgentService: {
|
||||||
|
configure: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFs = fs as jest.Mocked<typeof fs>;
|
||||||
|
const mockOs = os as jest.Mocked<typeof os>;
|
||||||
|
|
||||||
|
describe('ElasticGitStorageService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseAgentValue', () => {
|
||||||
|
it('should parse name without version', () => {
|
||||||
|
const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage');
|
||||||
|
expect(result.name).toBe('elastic-git-storage');
|
||||||
|
expect(result.version).toBe('latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse name@version', () => {
|
||||||
|
const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage@v1.0.0');
|
||||||
|
expect(result.name).toBe('elastic-git-storage');
|
||||||
|
expect(result.version).toBe('v1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse name@latest', () => {
|
||||||
|
const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage@latest');
|
||||||
|
expect(result.name).toBe('elastic-git-storage');
|
||||||
|
expect(result.version).toBe('latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle trailing @ as latest', () => {
|
||||||
|
const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage@');
|
||||||
|
expect(result.name).toBe('elastic-git-storage');
|
||||||
|
expect(result.version).toBe('latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle whitespace', () => {
|
||||||
|
const result = ElasticGitStorageService.parseAgentValue(' elastic-git-storage@v2.0.0 ');
|
||||||
|
expect(result.name).toBe('elastic-git-storage');
|
||||||
|
expect(result.version).toBe('v2.0.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isElasticGitStorage', () => {
|
||||||
|
it('should match exact name', () => {
|
||||||
|
expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match with .exe extension', () => {
|
||||||
|
expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage.exe')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match with @version suffix', () => {
|
||||||
|
expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage@v1.0.0')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match with @latest suffix', () => {
|
||||||
|
expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage@latest')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match forward-slash path', () => {
|
||||||
|
expect(ElasticGitStorageService.isElasticGitStorage('/usr/local/bin/elastic-git-storage')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match backslash path', () => {
|
||||||
|
expect(ElasticGitStorageService.isElasticGitStorage('C:\\tools\\elastic-git-storage')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match path with .exe', () => {
|
||||||
|
expect(ElasticGitStorageService.isElasticGitStorage('C:\\tools\\elastic-git-storage.exe')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-insensitive', () => {
|
||||||
|
expect(ElasticGitStorageService.isElasticGitStorage('Elastic-Git-Storage')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle whitespace', () => {
|
||||||
|
expect(ElasticGitStorageService.isElasticGitStorage(' elastic-git-storage ')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match other agent names', () => {
|
||||||
|
expect(ElasticGitStorageService.isElasticGitStorage('lfs-folderstore')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty string', () => {
|
||||||
|
expect(ElasticGitStorageService.isElasticGitStorage('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match partial names', () => {
|
||||||
|
expect(ElasticGitStorageService.isElasticGitStorage('my-elastic-git-storage-v2')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findInstalled', () => {
|
||||||
|
it('should find on PATH via which/where', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
OrchestratorSystem.Run.mockResolvedValue('/usr/local/bin/elastic-git-storage\n');
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await ElasticGitStorageService.findInstalled();
|
||||||
|
expect(result).toBe('/usr/local/bin/elastic-git-storage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use where on windows', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('win32');
|
||||||
|
OrchestratorSystem.Run.mockResolvedValue('C:\\tools\\elastic-git-storage.exe\n');
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await ElasticGitStorageService.findInstalled();
|
||||||
|
expect(result).toBe('C:\\tools\\elastic-git-storage.exe');
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('where'),
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check common install locations when not on PATH', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
mockOs.homedir.mockReturnValue('/home/runner');
|
||||||
|
OrchestratorSystem.Run.mockRejectedValue(new Error('not found'));
|
||||||
|
(mockFs.existsSync as jest.Mock)
|
||||||
|
.mockReturnValueOnce(false) // RUNNER_TOOL_CACHE
|
||||||
|
.mockReturnValueOnce(true); // /usr/local/bin
|
||||||
|
|
||||||
|
const result = await ElasticGitStorageService.findInstalled();
|
||||||
|
expect(result).toBe('/usr/local/bin/elastic-git-storage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string when not found anywhere', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
mockOs.homedir.mockReturnValue('/home/runner');
|
||||||
|
OrchestratorSystem.Run.mockRejectedValue(new Error('not found'));
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = await ElasticGitStorageService.findInstalled();
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check windows-specific locations on win32', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('win32');
|
||||||
|
mockOs.homedir.mockReturnValue('C:\\Users\\runner');
|
||||||
|
OrchestratorSystem.Run.mockRejectedValue(new Error('not found'));
|
||||||
|
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
process.env.LOCALAPPDATA = 'C:\\Users\\runner\\AppData\\Local';
|
||||||
|
process.env.RUNNER_TOOL_CACHE = '';
|
||||||
|
|
||||||
|
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
|
||||||
|
return p.includes('AppData');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await ElasticGitStorageService.findInstalled();
|
||||||
|
expect(result).toContain('elastic-git-storage.exe');
|
||||||
|
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('install', () => {
|
||||||
|
it('should download correct binary for linux amd64', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
mockOs.arch.mockReturnValue('x64');
|
||||||
|
mockOs.tmpdir.mockReturnValue('/tmp');
|
||||||
|
OrchestratorSystem.Run.mockResolvedValue('');
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await ElasticGitStorageService.install('latest');
|
||||||
|
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('elastic-git-storage_linux_amd64'),
|
||||||
|
);
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('chmod +x'),
|
||||||
|
);
|
||||||
|
expect(result).toContain('elastic-git-storage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should download correct binary for darwin arm64', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('darwin');
|
||||||
|
mockOs.arch.mockReturnValue('arm64');
|
||||||
|
mockOs.tmpdir.mockReturnValue('/tmp');
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||||
|
|
||||||
|
await ElasticGitStorageService.install('v1.2.0');
|
||||||
|
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('elastic-git-storage_darwin_arm64'),
|
||||||
|
);
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('v1.2.0'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should download .exe for windows', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('win32');
|
||||||
|
mockOs.arch.mockReturnValue('x64');
|
||||||
|
mockOs.tmpdir.mockReturnValue('C:\\temp');
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||||
|
|
||||||
|
await ElasticGitStorageService.install('latest');
|
||||||
|
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('elastic-git-storage_windows_amd64.exe'),
|
||||||
|
);
|
||||||
|
// Should NOT chmod on windows
|
||||||
|
expect(OrchestratorSystem.Run).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('chmod'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use RUNNER_TOOL_CACHE for install dir when available', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
mockOs.arch.mockReturnValue('x64');
|
||||||
|
OrchestratorSystem.Run.mockResolvedValue('');
|
||||||
|
|
||||||
|
const originalCache = process.env.RUNNER_TOOL_CACHE;
|
||||||
|
process.env.RUNNER_TOOL_CACHE = '/opt/hostedtoolcache';
|
||||||
|
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await ElasticGitStorageService.install('latest');
|
||||||
|
expect(result).toContain('hostedtoolcache');
|
||||||
|
|
||||||
|
if (originalCache === undefined) {
|
||||||
|
delete process.env.RUNNER_TOOL_CACHE;
|
||||||
|
} else {
|
||||||
|
process.env.RUNNER_TOOL_CACHE = originalCache;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use latest release URL when version is latest', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
mockOs.arch.mockReturnValue('x64');
|
||||||
|
mockOs.tmpdir.mockReturnValue('/tmp');
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||||
|
|
||||||
|
await ElasticGitStorageService.install('latest');
|
||||||
|
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/releases/latest/download/'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use tagged release URL when version is specified', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
mockOs.arch.mockReturnValue('x64');
|
||||||
|
mockOs.tmpdir.mockReturnValue('/tmp');
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||||
|
|
||||||
|
await ElasticGitStorageService.install('v2.0.0');
|
||||||
|
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/releases/download/v2.0.0/'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string on download failure', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
mockOs.arch.mockReturnValue('x64');
|
||||||
|
mockOs.tmpdir.mockReturnValue('/tmp');
|
||||||
|
OrchestratorSystem.Run.mockRejectedValue(new Error('curl failed'));
|
||||||
|
|
||||||
|
const result = await ElasticGitStorageService.install('latest');
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string if binary not found after download', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
mockOs.arch.mockReturnValue('x64');
|
||||||
|
mockOs.tmpdir.mockReturnValue('/tmp');
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = await ElasticGitStorageService.install('latest');
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ensureAndConfigure', () => {
|
||||||
|
it('should use existing installation if found', async () => {
|
||||||
|
const { LfsAgentService } = require('./lfs-agent-service');
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
mockOs.homedir.mockReturnValue('/home/runner');
|
||||||
|
// findInstalled returns a result
|
||||||
|
OrchestratorSystem.Run.mockResolvedValue('/usr/local/bin/elastic-git-storage\n');
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await ElasticGitStorageService.ensureAndConfigure(
|
||||||
|
'latest',
|
||||||
|
'--verbose',
|
||||||
|
['/mnt/lfs'],
|
||||||
|
'/repo',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('/usr/local/bin/elastic-git-storage');
|
||||||
|
expect(LfsAgentService.configure).toHaveBeenCalledWith(
|
||||||
|
'/usr/local/bin/elastic-git-storage',
|
||||||
|
'--verbose',
|
||||||
|
['/mnt/lfs'],
|
||||||
|
'/repo',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should install when not found and configure', async () => {
|
||||||
|
const { LfsAgentService } = require('./lfs-agent-service');
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
mockOs.arch.mockReturnValue('x64');
|
||||||
|
mockOs.homedir.mockReturnValue('/home/runner');
|
||||||
|
mockOs.tmpdir.mockReturnValue('/tmp');
|
||||||
|
|
||||||
|
// findInstalled finds nothing
|
||||||
|
OrchestratorSystem.Run
|
||||||
|
.mockRejectedValueOnce(new Error('not found')) // which
|
||||||
|
.mockResolvedValueOnce('') // curl download
|
||||||
|
.mockResolvedValueOnce(''); // chmod
|
||||||
|
|
||||||
|
(mockFs.existsSync as jest.Mock)
|
||||||
|
.mockReturnValueOnce(false) // RUNNER_TOOL_CACHE
|
||||||
|
.mockReturnValueOnce(false) // /usr/local/bin
|
||||||
|
.mockReturnValueOnce(false) // ~/.local/bin
|
||||||
|
.mockReturnValueOnce(true); // after install
|
||||||
|
|
||||||
|
const result = await ElasticGitStorageService.ensureAndConfigure(
|
||||||
|
'v1.0.0',
|
||||||
|
'',
|
||||||
|
[],
|
||||||
|
'/repo',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain('elastic-git-storage');
|
||||||
|
expect(LfsAgentService.configure).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string when install fails', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
mockOs.arch.mockReturnValue('x64');
|
||||||
|
mockOs.homedir.mockReturnValue('/home/runner');
|
||||||
|
mockOs.tmpdir.mockReturnValue('/tmp');
|
||||||
|
|
||||||
|
// findInstalled finds nothing
|
||||||
|
OrchestratorSystem.Run.mockRejectedValue(new Error('not found'));
|
||||||
|
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = await ElasticGitStorageService.ensureAndConfigure(
|
||||||
|
'latest',
|
||||||
|
'',
|
||||||
|
[],
|
||||||
|
'/repo',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default version when empty string passed', async () => {
|
||||||
|
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||||
|
mockOs.platform.mockReturnValue('linux');
|
||||||
|
mockOs.arch.mockReturnValue('x64');
|
||||||
|
mockOs.homedir.mockReturnValue('/home/runner');
|
||||||
|
mockOs.tmpdir.mockReturnValue('/tmp');
|
||||||
|
|
||||||
|
// findInstalled finds nothing
|
||||||
|
OrchestratorSystem.Run
|
||||||
|
.mockRejectedValueOnce(new Error('not found'))
|
||||||
|
.mockResolvedValueOnce('')
|
||||||
|
.mockResolvedValueOnce('');
|
||||||
|
|
||||||
|
(mockFs.existsSync as jest.Mock)
|
||||||
|
.mockReturnValueOnce(false)
|
||||||
|
.mockReturnValueOnce(false)
|
||||||
|
.mockReturnValueOnce(false)
|
||||||
|
.mockReturnValueOnce(true);
|
||||||
|
|
||||||
|
await ElasticGitStorageService.ensureAndConfigure('', '', [], '/repo');
|
||||||
|
|
||||||
|
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/releases/latest/download/'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constants', () => {
|
||||||
|
it('should have correct repo owner', () => {
|
||||||
|
expect(ElasticGitStorageService.REPO_OWNER).toBe('frostebite');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct repo name', () => {
|
||||||
|
expect(ElasticGitStorageService.REPO_NAME).toBe('elastic-git-storage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct agent name', () => {
|
||||||
|
expect(ElasticGitStorageService.AGENT_NAME).toBe('elastic-git-storage');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { OrchestratorSystem } from '../core/orchestrator-system';
|
||||||
|
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||||
|
import { LfsAgentService } from './lfs-agent-service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in support for elastic-git-storage, a custom Git LFS transfer agent
|
||||||
|
* that supports multiple storage backends (local filesystem, WebDAV, rclone remotes).
|
||||||
|
*
|
||||||
|
* When `lfsTransferAgent` is set to 'elastic-git-storage', this service:
|
||||||
|
* 1. Checks if the agent is already installed on the system
|
||||||
|
* 2. If not, downloads it from the GitHub release page
|
||||||
|
* 3. Makes it executable
|
||||||
|
* 4. Delegates to LfsAgentService for git config setup
|
||||||
|
*
|
||||||
|
* @see https://github.com/frostebite/elastic-git-storage
|
||||||
|
*/
|
||||||
|
export class ElasticGitStorageService {
|
||||||
|
static readonly REPO_OWNER = 'frostebite';
|
||||||
|
static readonly REPO_NAME = 'elastic-git-storage';
|
||||||
|
static readonly AGENT_NAME = 'elastic-git-storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an agent value that may include a version suffix.
|
||||||
|
* Supports formats like:
|
||||||
|
* - 'elastic-git-storage' → { name: 'elastic-git-storage', version: 'latest' }
|
||||||
|
* - 'elastic-git-storage@v1.0.0' → { name: 'elastic-git-storage', version: 'v1.0.0' }
|
||||||
|
* - 'elastic-git-storage@latest' → { name: 'elastic-git-storage', version: 'latest' }
|
||||||
|
*/
|
||||||
|
static parseAgentValue(agentValue: string): { name: string; version: string } {
|
||||||
|
const trimmed = agentValue.trim();
|
||||||
|
const atIndex = trimmed.indexOf('@');
|
||||||
|
if (atIndex > 0) {
|
||||||
|
return {
|
||||||
|
name: trimmed.substring(0, atIndex),
|
||||||
|
version: trimmed.substring(atIndex + 1) || 'latest',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: trimmed, version: 'latest' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given lfsTransferAgent value refers to elastic-git-storage.
|
||||||
|
* Matches the exact name (without path) or a path ending in the agent name.
|
||||||
|
* Also matches 'elastic-git-storage@version' format.
|
||||||
|
*/
|
||||||
|
static isElasticGitStorage(agentValue: string): boolean {
|
||||||
|
if (!agentValue) return false;
|
||||||
|
|
||||||
|
const { name } = ElasticGitStorageService.parseAgentValue(agentValue);
|
||||||
|
const normalized = name.trim().toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalized === 'elastic-git-storage' ||
|
||||||
|
normalized === 'elastic-git-storage.exe' ||
|
||||||
|
normalized.endsWith('/elastic-git-storage') ||
|
||||||
|
normalized.endsWith('\\elastic-git-storage') ||
|
||||||
|
normalized.endsWith('/elastic-git-storage.exe') ||
|
||||||
|
normalized.endsWith('\\elastic-git-storage.exe')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the full path to the elastic-git-storage executable.
|
||||||
|
*
|
||||||
|
* Search order:
|
||||||
|
* 1. The provided path (if it's a full path and exists)
|
||||||
|
* 2. $PATH lookup via `which` / `where`
|
||||||
|
* 3. Known install locations ($RUNNER_TOOL_CACHE, /usr/local/bin, ~/.local/bin)
|
||||||
|
*
|
||||||
|
* @returns Full path to the executable, or empty string if not found
|
||||||
|
*/
|
||||||
|
static async findInstalled(): Promise<string> {
|
||||||
|
// Check PATH
|
||||||
|
try {
|
||||||
|
const whichCmd = os.platform() === 'win32' ? 'where elastic-git-storage' : 'which elastic-git-storage';
|
||||||
|
const result = await OrchestratorSystem.Run(whichCmd, false, true);
|
||||||
|
const foundPath = result.trim().split('\n')[0].trim();
|
||||||
|
if (foundPath && fs.existsSync(foundPath)) {
|
||||||
|
return foundPath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not on PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check common install locations
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.env.RUNNER_TOOL_CACHE || '', 'elastic-git-storage', 'elastic-git-storage'),
|
||||||
|
'/usr/local/bin/elastic-git-storage',
|
||||||
|
path.join(os.homedir(), '.local', 'bin', 'elastic-git-storage'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (os.platform() === 'win32') {
|
||||||
|
candidates.push(
|
||||||
|
path.join(process.env.RUNNER_TOOL_CACHE || '', 'elastic-git-storage', 'elastic-git-storage.exe'),
|
||||||
|
path.join(process.env.LOCALAPPDATA || '', 'elastic-git-storage', 'elastic-git-storage.exe'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate && fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install elastic-git-storage from GitHub releases.
|
||||||
|
*
|
||||||
|
* @param version - Version to install (e.g., 'v1.0.0', 'latest')
|
||||||
|
* @returns Path to the installed executable
|
||||||
|
*/
|
||||||
|
static async install(version: string = 'latest'): Promise<string> {
|
||||||
|
const platform = os.platform();
|
||||||
|
const arch = os.arch();
|
||||||
|
|
||||||
|
const osName = platform === 'win32' ? 'windows' : platform === 'darwin' ? 'darwin' : 'linux';
|
||||||
|
const archName = arch === 'x64' ? 'amd64' : arch === 'arm64' ? 'arm64' : 'amd64';
|
||||||
|
const ext = platform === 'win32' ? '.exe' : '';
|
||||||
|
|
||||||
|
const installDir = process.env.RUNNER_TOOL_CACHE
|
||||||
|
? path.join(process.env.RUNNER_TOOL_CACHE, 'elastic-git-storage')
|
||||||
|
: path.join(os.tmpdir(), 'elastic-git-storage');
|
||||||
|
|
||||||
|
const binaryName = `elastic-git-storage${ext}`;
|
||||||
|
const installPath = path.join(installDir, binaryName);
|
||||||
|
|
||||||
|
OrchestratorLogger.log(`[ElasticGitStorage] Installing to ${installPath}`);
|
||||||
|
|
||||||
|
// Create install directory
|
||||||
|
fs.mkdirSync(installDir, { recursive: true });
|
||||||
|
|
||||||
|
// Build download URL
|
||||||
|
const releaseTag = version === 'latest' ? 'latest' : version;
|
||||||
|
const assetName = `elastic-git-storage_${osName}_${archName}${ext}`;
|
||||||
|
|
||||||
|
let downloadUrl: string;
|
||||||
|
if (releaseTag === 'latest') {
|
||||||
|
downloadUrl = `https://github.com/${ElasticGitStorageService.REPO_OWNER}/${ElasticGitStorageService.REPO_NAME}/releases/latest/download/${assetName}`;
|
||||||
|
} else {
|
||||||
|
downloadUrl = `https://github.com/${ElasticGitStorageService.REPO_OWNER}/${ElasticGitStorageService.REPO_NAME}/releases/download/${releaseTag}/${assetName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
OrchestratorLogger.log(`[ElasticGitStorage] Downloading from ${downloadUrl}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await OrchestratorSystem.Run(`curl -fsSL -o "${installPath}" "${downloadUrl}"`);
|
||||||
|
|
||||||
|
if (platform !== 'win32') {
|
||||||
|
await OrchestratorSystem.Run(`chmod +x "${installPath}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify installation
|
||||||
|
if (!fs.existsSync(installPath)) {
|
||||||
|
throw new Error(`Binary not found after download at ${installPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
OrchestratorLogger.log(`[ElasticGitStorage] Successfully installed ${releaseTag} to ${installPath}`);
|
||||||
|
|
||||||
|
return installPath;
|
||||||
|
} catch (error: any) {
|
||||||
|
OrchestratorLogger.logWarning(
|
||||||
|
`[ElasticGitStorage] Failed to install: ${error.message}. Continuing without elastic-git-storage.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure elastic-git-storage is available (find or install) and configure it.
|
||||||
|
*
|
||||||
|
* @param version - Version to install if not found ('latest' or a tag like 'v1.0.0')
|
||||||
|
* @param agentArgs - Additional arguments to pass to the agent
|
||||||
|
* @param storagePaths - Storage paths for the agent
|
||||||
|
* @param repoPath - Path to the git repository
|
||||||
|
* @returns Path to the configured executable, or empty string if setup failed
|
||||||
|
*/
|
||||||
|
static async ensureAndConfigure(
|
||||||
|
version: string,
|
||||||
|
agentArgs: string,
|
||||||
|
storagePaths: string[],
|
||||||
|
repoPath: string,
|
||||||
|
): Promise<string> {
|
||||||
|
OrchestratorLogger.log(`[ElasticGitStorage] Setting up elastic-git-storage (version: ${version || 'latest'})`);
|
||||||
|
|
||||||
|
// Try to find existing installation
|
||||||
|
let agentPath = await ElasticGitStorageService.findInstalled();
|
||||||
|
|
||||||
|
if (agentPath) {
|
||||||
|
OrchestratorLogger.log(`[ElasticGitStorage] Found existing installation at ${agentPath}`);
|
||||||
|
} else {
|
||||||
|
OrchestratorLogger.log(`[ElasticGitStorage] Not found on system, installing...`);
|
||||||
|
agentPath = await ElasticGitStorageService.install(version || 'latest');
|
||||||
|
|
||||||
|
if (!agentPath) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to LfsAgentService for git config setup
|
||||||
|
await LfsAgentService.configure(agentPath, agentArgs, storagePaths, repoPath);
|
||||||
|
|
||||||
|
return agentPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user