diff --git a/backend/src/services/reconciler.ts b/backend/src/services/reconciler.ts index 4cb1962..efb84a5 100644 --- a/backend/src/services/reconciler.ts +++ b/backend/src/services/reconciler.ts @@ -13,7 +13,7 @@ import fs from 'fs/promises'; import path from 'path'; import { db, type RunnerRow, type RunnerPoolRow } from '../db/index.js'; import { createClientFromCredentialId } from './credentialResolver.js'; -import { cleanupRunnerFiles, isRunnerProcessAlive, stopOrphanedRunner, RUNNERS_DIR } from './runnerManager.js'; +import { cleanupRunnerFiles, isRunnerProcessAlive, stopOrphanedRunner, RUNNERS_DIR, cleanupGlobalBuildCaches } from './runnerManager.js'; import { removeDockerRunner, getContainerStatus } from './dockerRunner.js'; import { ensureWarmRunners } from './autoscaler.js'; @@ -259,6 +259,14 @@ async function reconcileRunnersInternal(): Promise { stats.orphanedRemoved += orphanedDirsRemoved; } + // Clean up global build caches (legacy caches from before per-runner isolation) + // Only runs when no runners are active + try { + await cleanupGlobalBuildCaches(); + } catch (error) { + console.error('[reconciler] Failed to cleanup global build caches:', error); + } + console.log(`[reconciler] Reconciliation complete:`, stats); } diff --git a/backend/src/services/runnerManager.ts b/backend/src/services/runnerManager.ts index 576560b..0223445 100644 --- a/backend/src/services/runnerManager.ts +++ b/backend/src/services/runnerManager.ts @@ -20,6 +20,154 @@ import { createClientFromCredentialId, resolveCredentialById } from './credentia // Runner storage directory export const RUNNERS_DIR = process.env.RUNNERS_DIR || path.join(os.homedir(), '.action-packer', 'runners'); +/** + * Get the cache directory paths for a specific runner. + * Each runner gets its own isolated cache directories to avoid conflicts. + */ +export function getRunnerCachePaths(runnerDir: string): { [key: string]: string } { + const cacheBase = path.join(runnerDir, '_caches'); + return { + cacheBase, + gradleHome: path.join(cacheBase, 'gradle'), + npmCache: path.join(cacheBase, 'npm'), + cocoapodsCache: path.join(cacheBase, 'cocoapods'), + derivedData: path.join(cacheBase, 'DerivedData'), + androidHome: path.join(cacheBase, 'android'), + wrapperBin: path.join(cacheBase, 'bin'), + }; +} + +/** + * xcodebuild wrapper script that automatically adds -derivedDataPath. + * This is installed in each runner's PATH to intercept xcodebuild calls. + */ +const XCODEBUILD_WRAPPER = `#!/bin/bash +# Action Packer xcodebuild wrapper - automatically routes DerivedData to per-runner directory +DERIVED_DATA_PATH="\${RUNNER_DERIVED_DATA_PATH:-}" +REAL_XCODEBUILD="/usr/bin/xcodebuild" + +if [ -z "$DERIVED_DATA_PATH" ]; then + # No custom path set, use real xcodebuild directly + exec "$REAL_XCODEBUILD" "$@" +fi + +# Check if -derivedDataPath is already specified +for arg in "$@"; do + if [ "$arg" = "-derivedDataPath" ]; then + # User already specified it, don't override + exec "$REAL_XCODEBUILD" "$@" + fi +done + +# Add our derived data path +exec "$REAL_XCODEBUILD" -derivedDataPath "$DERIVED_DATA_PATH" "$@" +`; + +/** + * Create cache directories, install wrapper scripts, and return environment variables. + */ +export async function setupRunnerCacheEnv(runnerDir: string): Promise<{ [key: string]: string }> { + const cachePaths = getRunnerCachePaths(runnerDir); + const { platform } = detectPlatform(); + + // Create all cache directories + await Promise.all( + Object.values(cachePaths).map(dir => fs.mkdir(dir, { recursive: true })) + ); + + // Install xcodebuild wrapper on macOS + if (platform === 'darwin') { + const wrapperPath = path.join(cachePaths.wrapperBin, 'xcodebuild'); + await fs.writeFile(wrapperPath, XCODEBUILD_WRAPPER, { mode: 0o755 }); + } + + // Return environment variables that tools will use + return { + // Gradle + GRADLE_USER_HOME: cachePaths.gradleHome, + // npm + npm_config_cache: cachePaths.npmCache, + // CocoaPods + CP_CACHE_DIR: cachePaths.cocoapodsCache, + // Android + ANDROID_USER_HOME: cachePaths.androidHome, + // Xcode DerivedData (used by wrapper) + RUNNER_DERIVED_DATA_PATH: cachePaths.derivedData, + // Prepend wrapper bin to PATH so it intercepts xcodebuild + PATH: `${cachePaths.wrapperBin}:${process.env.PATH || ''}`, + }; +} + +/** + * Clean up all cache directories for a runner. + */ +export async function cleanupRunnerCaches(runnerDir: string): Promise { + const cachePaths = getRunnerCachePaths(runnerDir); + + console.log(`[cleanup] Cleaning caches for runner at ${runnerDir}`); + + try { + await fs.rm(cachePaths.cacheBase, { recursive: true, force: true }); + console.log(`[cleanup] Cleared runner caches`); + } catch (error) { + console.warn(`[cleanup] Could not clean caches:`, error); + } +} + +/** + * Clean up global build caches that may have accumulated from runners + * before per-runner cache isolation was implemented. + * Only runs when no runners are currently active. + */ +export async function cleanupGlobalBuildCaches(): Promise { + const home = os.homedir(); + const { platform } = detectPlatform(); + + // Only clean global caches if no runners are currently running + if (runningProcesses.size > 0) { + console.log(`[cleanup] Skipping global cache cleanup - ${runningProcesses.size} runners active`); + return; + } + + console.log('[cleanup] Cleaning global build caches...'); + + const globalCaches = [ + // Xcode DerivedData (macOS) - the main offender + ...(platform === 'darwin' ? [ + path.join(home, 'Library/Developer/Xcode/DerivedData'), + ] : []), + + // Global npm cache (we now use per-runner) + path.join(home, '.npm/_cacache'), + + // Global Gradle caches (we now use per-runner GRADLE_USER_HOME) + path.join(home, '.gradle/caches'), + path.join(home, '.gradle/daemon'), + + // CocoaPods cache (macOS) + ...(platform === 'darwin' ? [ + path.join(home, 'Library/Caches/CocoaPods'), + ] : []), + + // Android caches + path.join(home, '.android/cache'), + ]; + + for (const cacheDir of globalCaches) { + try { + const stats = await fs.stat(cacheDir).catch(() => null); + if (stats?.isDirectory()) { + console.log(`[cleanup] Clearing global cache: ${cacheDir}`); + await fs.rm(cacheDir, { recursive: true, force: true }); + } + } catch (error) { + console.warn(`[cleanup] Could not clean ${cacheDir}:`, error); + } + } + + console.log('[cleanup] Global build cache cleanup complete'); +} + // Track running processes const runningProcesses = new Map(); @@ -385,14 +533,17 @@ export async function configureRunner( export async function startRunner(runnerId: string, runnerDir: string): Promise { const { platform } = detectPlatform(); const runScript = platform === 'win32' ? 'run.cmd' : './run.sh'; - + + // Set up per-runner cache directories and get environment variables + const cacheEnv = await setupRunnerCacheEnv(runnerDir); + return new Promise((resolve, reject) => { const proc = spawn(runScript, [], { cwd: runnerDir, shell: platform === 'win32', detached: true, stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env }, + env: { ...process.env, ...cacheEnv }, }); // Store the process @@ -472,7 +623,13 @@ export async function startRunner(runnerId: string, runnerDir: string): Promise< } })(); } else { - // Non-ephemeral runners just update status + // Non-ephemeral runners: clean up caches but keep the runner + if (runner.runner_dir) { + cleanupRunnerCaches(runner.runner_dir).catch(err => { + console.error(`[runner] Failed to cleanup caches for ${runnerId}:`, err); + }); + } + if (code === 0) { updateRunnerStatus.run('offline', runnerId); } else { diff --git a/frontend/src/components/PoolManager.test.tsx b/frontend/src/components/PoolManager.test.tsx index f6547f9..011e2e9 100644 --- a/frontend/src/components/PoolManager.test.tsx +++ b/frontend/src/components/PoolManager.test.tsx @@ -7,16 +7,21 @@ import { render, screen, waitFor, fireEvent, within } from '@testing-library/rea import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PoolManager } from './PoolManager'; -const { createPoolMock } = vi.hoisted(() => ({ +const { createPoolMock, updatePoolMock, listPoolsMock } = vi.hoisted(() => ({ createPoolMock: vi.fn().mockResolvedValue({ pool: { id: 'pool-1' }, }), + updatePoolMock: vi.fn().mockResolvedValue({ + pool: { id: 'pool-1' }, + }), + listPoolsMock: vi.fn().mockResolvedValue({ pools: [] }), })); vi.mock('../api', () => ({ poolsApi: { - list: vi.fn().mockResolvedValue({ pools: [] }), + list: () => listPoolsMock(), create: (...args: unknown[]) => createPoolMock(...args), + update: (...args: unknown[]) => updatePoolMock(...args), delete: vi.fn(), enable: vi.fn(), disable: vi.fn(), @@ -188,3 +193,218 @@ describe('PoolManager (architecture selection)', () => { }); }); }); + +describe('PoolManager (edit pool)', () => { + let queryClient: QueryClient; + + const mockPool = { + id: 'pool-1', + name: 'Test Pool', + credential_id: 'cred-1', + credential_name: 'Test PAT', + scope: 'repo' as const, + target: 'owner/repo', + platform: 'darwin' as const, + architecture: 'arm64' as const, + isolation_type: 'native' as const, + labels: ['self-hosted', 'test'], + min_runners: 0, + max_runners: 5, + warm_runners: 2, + idle_timeout_minutes: 10, + enabled: true, + runner_count: 3, + online_count: 2, + busy_count: 1, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + vi.clearAllMocks(); + listPoolsMock.mockResolvedValue({ pools: [mockPool] }); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + }); + + it('opens edit modal when clicking edit button', async () => { + render( + + + + ); + + // Wait for pools to load + await waitFor(() => { + expect(screen.getByText('Test Pool')).toBeInTheDocument(); + }); + + // Click edit button + const editButton = screen.getByTitle('Edit pool'); + fireEvent.click(editButton); + + // Check that edit modal opens with pool name in title + await waitFor(() => { + expect(screen.getByRole('heading', { name: /Edit Pool: Test Pool/i })).toBeInTheDocument(); + }); + }); + + it('displays current pool values in edit form', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Test Pool')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle('Edit pool')); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /Edit Pool/i })).toBeInTheDocument(); + }); + + // Check form values + const nameInput = screen.getByPlaceholderText('my-pool') as HTMLInputElement; + expect(nameInput.value).toBe('Test Pool'); + + const labelsInput = screen.getByPlaceholderText('self-hosted, linux, x64') as HTMLInputElement; + expect(labelsInput.value).toBe('self-hosted, test'); + + // Check scaling values using input type number + const numberInputs = screen.getAllByRole('spinbutton'); + expect(numberInputs.length).toBe(4); // min, max, warm, timeout + }); + + it('shows read-only pool info in edit form', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Test Pool')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle('Edit pool')); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /Edit Pool/i })).toBeInTheDocument(); + }); + + // Check read-only info is displayed + expect(screen.getByText(/Test PAT/)).toBeInTheDocument(); + expect(screen.getByText(/native/)).toBeInTheDocument(); + }); + + it('submits updated values when saving', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Test Pool')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle('Edit pool')); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /Edit Pool/i })).toBeInTheDocument(); + }); + + // Change values + const nameInput = screen.getByPlaceholderText('my-pool'); + fireEvent.change(nameInput, { target: { value: 'Updated Pool' } }); + + const numberInputs = screen.getAllByRole('spinbutton'); + // Change max runners (second input) + fireEvent.change(numberInputs[1], { target: { value: '10' } }); + // Change warm runners (third input) + fireEvent.change(numberInputs[2], { target: { value: '3' } }); + + // Submit form + const modal = screen.getByRole('heading', { name: /Edit Pool/i }).closest('.card'); + fireEvent.click(within(modal as HTMLElement).getByRole('button', { name: /Save Changes/i })); + + await waitFor(() => { + expect(updatePoolMock).toHaveBeenCalledWith( + 'pool-1', + expect.objectContaining({ + name: 'Updated Pool', + maxRunners: 10, + warmRunners: 3, + }) + ); + }); + }); + + it('validates that warm runners cannot exceed max runners', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Test Pool')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle('Edit pool')); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /Edit Pool/i })).toBeInTheDocument(); + }); + + // Set warm runners higher than max + const numberInputs = screen.getAllByRole('spinbutton'); + fireEvent.change(numberInputs[1], { target: { value: '3' } }); // maxRunners = 3 + fireEvent.change(numberInputs[2], { target: { value: '5' } }); // warmRunners = 5 + + // Submit form + const modal = screen.getByRole('heading', { name: /Edit Pool/i }).closest('.card'); + fireEvent.click(within(modal as HTMLElement).getByRole('button', { name: /Save Changes/i })); + + // Check for validation error + await waitFor(() => { + expect(screen.getByText(/Warm runners cannot exceed max runners/i)).toBeInTheDocument(); + }); + + // Update should not be called + expect(updatePoolMock).not.toHaveBeenCalled(); + }); + + it('closes modal when clicking cancel', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Test Pool')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle('Edit pool')); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /Edit Pool/i })).toBeInTheDocument(); + }); + + // Click cancel + const modal = screen.getByRole('heading', { name: /Edit Pool/i }).closest('.card'); + fireEvent.click(within(modal as HTMLElement).getByRole('button', { name: /Cancel/i })); + + // Modal should close + await waitFor(() => { + expect(screen.queryByRole('heading', { name: /Edit Pool/i })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/PoolManager.tsx b/frontend/src/components/PoolManager.tsx index b7805db..51cc80f 100644 --- a/frontend/src/components/PoolManager.tsx +++ b/frontend/src/components/PoolManager.tsx @@ -13,6 +13,7 @@ import { Users, Activity, AlertCircle, + Pencil, } from 'lucide-react'; import { poolsApi, credentialsApi, runnersApi } from '../api'; import type { RunnerPool, IsolationType, Credential, SystemInfo } from '../types'; @@ -269,14 +270,214 @@ function AddPoolForm({ ); } +type EditPoolFormData = { + name: string; + labels: string; + minRunners: number; + maxRunners: number; + warmRunners: number; + idleTimeoutMinutes: number; +}; + +function EditPoolForm({ + pool, + onClose, + onSuccess, +}: { + pool: RunnerPool; + onClose: () => void; + onSuccess: () => void; +}) { + const [formData, setFormData] = useState({ + name: pool.name, + labels: pool.labels.join(', '), + minRunners: pool.min_runners, + maxRunners: pool.max_runners, + warmRunners: pool.warm_runners, + idleTimeoutMinutes: pool.idle_timeout_minutes, + }); + const [error, setError] = useState(null); + + const updateMutation = useMutation({ + mutationFn: (data: Parameters[1]) => + poolsApi.update(pool.id, data), + onSuccess: () => { + onSuccess(); + onClose(); + }, + onError: (err: Error) => { + setError(err.message); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + const labels = formData.labels + .split(',') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + + // Validate + if (formData.minRunners > formData.maxRunners) { + setError('Min runners cannot exceed max runners'); + return; + } + if (formData.warmRunners > formData.maxRunners) { + setError('Warm runners cannot exceed max runners'); + return; + } + + updateMutation.mutate({ + name: formData.name, + labels, + minRunners: formData.minRunners, + maxRunners: formData.maxRunners, + warmRunners: formData.warmRunners, + idleTimeoutMinutes: formData.idleTimeoutMinutes, + }); + }; + + return ( +
+
+

Edit Pool: {pool.name}

+ +
+
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, labels: e.target.value })} + /> +
+ + {/* Read-only info */} +
+

+ Credential:{' '} + {pool.credential_name} ({pool.target}) +

+

+ Isolation:{' '} + {pool.isolation_type} +

+

+ Platform:{' '} + {pool.platform} / {pool.architecture} +

+
+ +
+
+ + + setFormData({ ...formData, minRunners: parseInt(e.target.value) || 0 }) + } + /> +
+
+ + + setFormData({ ...formData, maxRunners: parseInt(e.target.value) || 1 }) + } + /> +
+
+ +
+
+ + + setFormData({ ...formData, warmRunners: parseInt(e.target.value) || 0 }) + } + /> +

Pre-warmed idle runners

+
+
+ + + setFormData({ ...formData, idleTimeoutMinutes: parseInt(e.target.value) || 10 }) + } + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+ ); +} + function PoolCard({ pool, onToggle, onDelete, + onEdit, }: { pool: RunnerPool; onToggle: (id: string, enabled: boolean) => void; onDelete: (id: string) => void; + onEdit: (pool: RunnerPool) => void; }) { return (
@@ -292,6 +493,13 @@ function PoolCard({
+
@@ -473,6 +683,17 @@ export function PoolManager() { }} /> )} + + {/* Edit Form Modal */} + {editingPool && ( + setEditingPool(null)} + onSuccess={() => { + queryClient.invalidateQueries({ queryKey: ['pools'] }); + }} + /> + )} ); }