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:
frostebite
2026-03-05 08:46:22 +00:00
parent a0c79bd657
commit cf3478c8ec
3 changed files with 646 additions and 1 deletions

View File

@@ -320,7 +320,9 @@ inputs:
required: false
default: ''
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.'
lfsTransferAgentArgs:
required: false

View File

@@ -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');
});
});
});

View File

@@ -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;
}
}