Skip to content

Commit e75294a

Browse files
authored
Merge pull request #17 from depoll/feature/edit-pool-attributes
feat: clean up runners when pool is deleted + bulk runner management
2 parents 957029e + cd810da commit e75294a

4 files changed

Lines changed: 257 additions & 18 deletions

File tree

backend/src/routes/pools.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55

66
import { Router, Request, Response } from 'express';
77
import { v4 as uuidv4 } from 'uuid';
8-
import { db, type RunnerPoolRow, type CredentialRow } from '../db/index.js';
8+
import { db, type RunnerPoolRow, type CredentialRow, type RunnerRow } from '../db/index.js';
99
import { decrypt, generateSecret } from '../utils/index.js';
1010
import { createGitHubClient, type GitHubScope } from '../services/index.js';
1111
import { ensureWarmRunners, getPoolById as getPoolRowById } from '../services/autoscaler.js';
12+
import { removeRunner, stopOrphanedRunner, cleanupRunnerFiles } from '../services/runnerManager.js';
13+
import { removeDockerRunner } from '../services/dockerRunner.js';
1214

1315
export const poolsRouter = Router();
1416

@@ -91,6 +93,8 @@ const updatePoolDockerOptions = db.prepare(`
9193

9294
const deletePoolById = db.prepare('DELETE FROM runner_pools WHERE id = ?');
9395

96+
const deleteRunnerById = db.prepare('DELETE FROM runners WHERE id = ?');
97+
9498
const getCredentialById = db.prepare('SELECT * FROM credentials WHERE id = ?');
9599

96100
const getPoolRunners = db.prepare(`
@@ -335,7 +339,7 @@ poolsRouter.patch('/:id', async (req: Request, res: Response) => {
335339
});
336340

337341
/**
338-
* Delete a pool
342+
* Delete a pool and all its runners
339343
*/
340344
poolsRouter.delete('/:id', async (req: Request, res: Response) => {
341345
try {
@@ -346,9 +350,54 @@ poolsRouter.delete('/:id', async (req: Request, res: Response) => {
346350
return;
347351
}
348352

349-
// Note: Runners belonging to this pool will have pool_id set to NULL
350-
// They won't be deleted automatically
353+
// Get all runners belonging to this pool and clean them up
354+
// Note: There's a small race window where a new runner could be added between this query
355+
// and pool deletion. Such runners would have pool_id set to NULL by ON DELETE SET NULL.
356+
// The UI's orphan detection feature can help identify and clean up such cases if they occur.
357+
const poolRunners = getPoolRunners.all(req.params.id) as RunnerRow[];
358+
359+
console.log(`[pools] Deleting pool ${req.params.id} with ${poolRunners.length} runners`);
360+
361+
// Clean up each runner in parallel (fire-and-forget pattern for speed)
362+
const cleanupPromises = poolRunners.map(async (runner) => {
363+
try {
364+
console.log(`[pools] Cleaning up runner ${runner.name} (${runner.id})`);
365+
366+
if (runner.isolation_type === 'docker') {
367+
await removeDockerRunner(runner.id).catch((err) => {
368+
console.error(`[pools] Failed to remove Docker runner ${runner.id}:`, err);
369+
});
370+
} else {
371+
// For native runners, try the full removal first
372+
// This handles deregistration from GitHub and cleanup
373+
await removeRunner(runner.id).catch(async (err) => {
374+
// If removeRunner fails (e.g., runner already gone), do manual cleanup
375+
console.error(`[pools] removeRunner failed for ${runner.id}, doing manual cleanup:`, err);
376+
await stopOrphanedRunner(runner.id, runner.process_id).catch(() => {});
377+
await cleanupRunnerFiles(runner.runner_dir).catch(() => {});
378+
});
379+
}
380+
381+
// Delete the database record
382+
deleteRunnerById.run(runner.id);
383+
console.log(`[pools] Cleaned up runner ${runner.name}`);
384+
} catch (err) {
385+
console.error(`[pools] Failed to cleanup runner ${runner.name}:`, err);
386+
// Still try to delete the database record
387+
try {
388+
deleteRunnerById.run(runner.id);
389+
} catch (deleteErr) {
390+
console.error(`[pools] Failed to delete runner record ${runner.id} during cleanup:`, deleteErr);
391+
}
392+
}
393+
});
394+
395+
// Wait for all cleanup to complete (with a timeout to avoid hanging)
396+
await Promise.allSettled(cleanupPromises);
397+
398+
// Now delete the pool
351399
deletePoolById.run(req.params.id);
400+
console.log(`[pools] Deleted pool ${req.params.id}`);
352401

353402
res.status(204).send();
354403
} catch (error) {

backend/tests/pools.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,5 +195,50 @@ describe('Pools API', () => {
195195
const getResponse = await request(app).get(`/api/pools/${id}`);
196196
expect(getResponse.status).toBe(404);
197197
});
198+
199+
it('should clean up associated runners when deleting a pool', async () => {
200+
// Create a pool first
201+
const createResponse = await request(app)
202+
.post('/api/pools')
203+
.send({
204+
name: 'test-pool-with-runners',
205+
credentialId: credentialId,
206+
});
207+
208+
const poolId = createResponse.body.pool.id;
209+
210+
// Insert test runners directly into the database (simulating runners that exist)
211+
// Note: These are simplified test runners without runner_dir or process_id.
212+
// The cleanup code handles this gracefully - removeRunner will fail but the
213+
// fallback cleanup and database deletion will still succeed.
214+
const runnerId1 = 'test-runner-1-' + Date.now();
215+
const runnerId2 = 'test-runner-2-' + Date.now();
216+
217+
db.prepare(`
218+
INSERT INTO runners (id, name, status, pool_id, credential_id, isolation_type, ephemeral, platform, architecture, labels)
219+
VALUES (?, ?, 'offline', ?, ?, 'native', 0, 'linux', 'x64', '[]')
220+
`).run(runnerId1, 'test-runner-1', poolId, credentialId);
221+
222+
db.prepare(`
223+
INSERT INTO runners (id, name, status, pool_id, credential_id, isolation_type, ephemeral, platform, architecture, labels)
224+
VALUES (?, ?, 'offline', ?, ?, 'native', 0, 'linux', 'x64', '[]')
225+
`).run(runnerId2, 'test-runner-2', poolId, credentialId);
226+
227+
// Verify runners exist
228+
const runnersBefore = db.prepare('SELECT id FROM runners WHERE pool_id = ?').all(poolId);
229+
expect(runnersBefore.length).toBe(2);
230+
231+
// Delete the pool
232+
const response = await request(app).delete(`/api/pools/${poolId}`);
233+
expect(response.status).toBe(204);
234+
235+
// Verify the pool is deleted
236+
const getPoolResponse = await request(app).get(`/api/pools/${poolId}`);
237+
expect(getPoolResponse.status).toBe(404);
238+
239+
// Verify runners are also deleted (not just orphaned with NULL pool_id)
240+
const runnersAfter = db.prepare('SELECT id FROM runners WHERE id IN (?, ?)').all(runnerId1, runnerId2);
241+
expect(runnersAfter.length).toBe(0);
242+
});
198243
});
199244
});

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"build": "tsc -b && vite build",
99
"lint": "eslint .",
1010
"preview": "vite preview",
11-
"test": "vitest",
11+
"test": "vitest run",
1212
"typecheck": "tsc -b --noEmit"
1313
},
1414
"dependencies": {

frontend/src/components/RunnerManager.tsx

Lines changed: 158 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -232,19 +232,25 @@ function AddRunnerForm({
232232

233233
function RunnerRow({
234234
runner,
235+
selected,
236+
onSelect,
235237
onStart,
236238
onStop,
237239
onSync,
238240
onDelete,
239241
onViewLogs,
240242
}: {
241243
runner: Runner;
244+
selected: boolean;
245+
onSelect: (id: string, selected: boolean) => void;
242246
onStart: (id: string) => void;
243247
onStop: (id: string) => void;
244248
onSync: (id: string) => void;
245249
onDelete: (id: string) => void;
246250
onViewLogs: (runner: Runner) => void;
247251
}) {
252+
const isOrphaned = runner.ephemeral && !runner.pool_id;
253+
248254
const platformIcons: Record<string, string> = {
249255
darwin: '🍎',
250256
linux: '🐧',
@@ -264,12 +270,27 @@ function RunnerRow({
264270
const canViewLogs = runner.isolation_type === 'docker' || runner.runner_dir;
265271

266272
return (
267-
<tr>
273+
<tr className={isOrphaned ? 'bg-yellow-900/20' : ''}>
274+
<td>
275+
<input
276+
type="checkbox"
277+
checked={selected}
278+
onChange={(e) => onSelect(runner.id, e.target.checked)}
279+
className="w-4 h-4 rounded border-forest-600 bg-forest-800 text-forest-400 focus:ring-forest-500"
280+
/>
281+
</td>
268282
<td>
269283
<div className="flex items-center gap-3">
270284
<span className="text-xl">{platformIcons[effectivePlatform] || '💻'}</span>
271285
<div>
272-
<div className="font-medium">{runner.name}</div>
286+
<div className="font-medium flex items-center gap-2">
287+
{runner.name}
288+
{isOrphaned && (
289+
<span className="px-1.5 py-0.5 bg-yellow-700 rounded text-xs text-yellow-200" title="Ephemeral runner with no pool - likely orphaned">
290+
orphaned
291+
</span>
292+
)}
293+
</div>
273294
<div className="text-xs text-muted">{runner.target}</div>
274295
</div>
275296
</div>
@@ -363,6 +384,8 @@ function RunnerRow({
363384
export function RunnerManager() {
364385
const [showAddForm, setShowAddForm] = useState(false);
365386
const [selectedRunnerForLogs, setSelectedRunnerForLogs] = useState<Runner | null>(null);
387+
const [selectedRunnerIds, setSelectedRunnerIds] = useState<Set<string>>(new Set());
388+
const [showOrphanedOnly, setShowOrphanedOnly] = useState(false);
366389
const queryClient = useQueryClient();
367390

368391
const { data: runnersData, isLoading: runnersLoading } = useQuery({
@@ -398,7 +421,10 @@ export function RunnerManager() {
398421

399422
const deleteMutation = useMutation({
400423
mutationFn: runnersApi.delete,
401-
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['runners'] }),
424+
onSuccess: () => {
425+
queryClient.invalidateQueries({ queryKey: ['runners'] });
426+
setSelectedRunnerIds(new Set());
427+
},
402428
});
403429

404430
const handleDelete = (id: string) => {
@@ -410,6 +436,71 @@ export function RunnerManager() {
410436
const runners = runnersData?.runners || [];
411437
const credentials = credentialsData?.credentials || [];
412438

439+
// Filter runners based on orphaned filter
440+
const filteredRunners = showOrphanedOnly
441+
? runners.filter(r => r.ephemeral && !r.pool_id)
442+
: runners;
443+
444+
const orphanedCount = runners.filter(r => r.ephemeral && !r.pool_id).length;
445+
446+
// Selection helpers
447+
const handleSelectRunner = (id: string, selected: boolean) => {
448+
setSelectedRunnerIds(prev => {
449+
const next = new Set(prev);
450+
if (selected) {
451+
next.add(id);
452+
} else {
453+
next.delete(id);
454+
}
455+
return next;
456+
});
457+
};
458+
459+
const handleSelectAll = (selected: boolean) => {
460+
if (selected) {
461+
setSelectedRunnerIds(new Set(filteredRunners.map(r => r.id)));
462+
} else {
463+
setSelectedRunnerIds(new Set());
464+
}
465+
};
466+
467+
const handleBulkDelete = async () => {
468+
const count = selectedRunnerIds.size;
469+
if (count === 0) return;
470+
471+
if (!confirm(`Are you sure you want to delete ${count} runner${count === 1 ? '' : 's'}? This will stop the runners and deregister them from GitHub.`)) {
472+
return;
473+
}
474+
475+
// Delete runners in parallel (could be further optimized with a bulk API endpoint)
476+
const ids = Array.from(selectedRunnerIds);
477+
const results = await Promise.allSettled(
478+
ids.map(id => runnersApi.delete(id))
479+
);
480+
481+
// Collect and report failures
482+
const failedIds: string[] = [];
483+
results.forEach((result, index) => {
484+
if (result.status === 'rejected') {
485+
console.error(`Failed to delete runner ${ids[index]}:`, result.reason);
486+
failedIds.push(ids[index]);
487+
}
488+
});
489+
490+
if (failedIds.length > 0) {
491+
alert(
492+
`Failed to delete ${failedIds.length} runner${failedIds.length === 1 ? '' : 's'}. ` +
493+
'Please check the console for more details and try again.'
494+
);
495+
}
496+
497+
setSelectedRunnerIds(new Set());
498+
queryClient.invalidateQueries({ queryKey: ['runners'] });
499+
};
500+
501+
const allFilteredSelected = filteredRunners.length > 0 && filteredRunners.every(r => selectedRunnerIds.has(r.id));
502+
const someFilteredSelected = filteredRunners.some(r => selectedRunnerIds.has(r.id));
503+
413504
return (
414505
<div className="space-y-6">
415506
{/* Header */}
@@ -451,23 +542,76 @@ export function RunnerManager() {
451542
</p>
452543
</div>
453544
) : (
454-
<div className="card p-0 overflow-hidden">
455-
<table className="table">
456-
<thead>
457-
<tr>
458-
<th>Runner</th>
459-
<th>Status</th>
460-
<th>OS/Arch</th>
461-
<th>Isolation</th>
462-
<th>Labels</th>
545+
<>
546+
{/* Filter and bulk actions bar */}
547+
<div className="card flex items-center justify-between">
548+
<div className="flex items-center gap-4">
549+
<label className="flex items-center gap-2 cursor-pointer">
550+
<input
551+
type="checkbox"
552+
checked={showOrphanedOnly}
553+
onChange={(e) => {
554+
setShowOrphanedOnly(e.target.checked);
555+
setSelectedRunnerIds(new Set());
556+
}}
557+
className="w-4 h-4 rounded border-forest-600 bg-forest-800 text-forest-400 focus:ring-forest-500"
558+
/>
559+
<span className="text-sm">
560+
Show orphaned only
561+
{orphanedCount > 0 && (
562+
<span className="ml-1 px-1.5 py-0.5 bg-yellow-700 rounded text-xs text-yellow-200">
563+
{orphanedCount}
564+
</span>
565+
)}
566+
</span>
567+
</label>
568+
{selectedRunnerIds.size > 0 && (
569+
<span className="text-sm text-muted">
570+
{selectedRunnerIds.size} selected
571+
</span>
572+
)}
573+
</div>
574+
{selectedRunnerIds.size > 0 && (
575+
<button
576+
onClick={handleBulkDelete}
577+
className="btn btn-sm bg-red-700 hover:bg-red-600 text-white"
578+
>
579+
<Trash2 className="h-4 w-4 mr-1" />
580+
Delete Selected ({selectedRunnerIds.size})
581+
</button>
582+
)}
583+
</div>
584+
585+
<div className="card p-0 overflow-hidden">
586+
<table className="table">
587+
<thead>
588+
<tr>
589+
<th className="w-10">
590+
<input
591+
type="checkbox"
592+
checked={allFilteredSelected}
593+
ref={(el) => {
594+
if (el) el.indeterminate = someFilteredSelected && !allFilteredSelected;
595+
}}
596+
onChange={(e) => handleSelectAll(e.target.checked)}
597+
className="w-4 h-4 rounded border-forest-600 bg-forest-800 text-forest-400 focus:ring-forest-500"
598+
/>
599+
</th>
600+
<th>Runner</th>
601+
<th>Status</th>
602+
<th>OS/Arch</th>
603+
<th>Isolation</th>
604+
<th>Labels</th>
463605
<th>Actions</th>
464606
</tr>
465607
</thead>
466608
<tbody>
467-
{runners.map((runner) => (
609+
{filteredRunners.map((runner) => (
468610
<RunnerRow
469611
key={runner.id}
470612
runner={runner}
613+
selected={selectedRunnerIds.has(runner.id)}
614+
onSelect={handleSelectRunner}
471615
onStart={startMutation.mutate}
472616
onStop={stopMutation.mutate}
473617
onSync={syncMutation.mutate}
@@ -478,6 +622,7 @@ export function RunnerManager() {
478622
</tbody>
479623
</table>
480624
</div>
625+
</>
481626
)}
482627

483628
{/* Add Form Modal */}

0 commit comments

Comments
 (0)