From 1bb2fab3cd2eb05a6c095dee006979c6a15704cd Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Mon, 26 Oct 2026 13:12:43 +0100 Subject: [PATCH] feat(cli): add `cleanup downloads` command Co-Authored-By: Claude Opus 5.5 --- docs/tools.md | 32 +++++++++++++ src/cli/cleanup/index.spec.ts | 31 +++++++++++++ src/cli/cleanup/index.ts | 21 +++++++++ src/cli/command/cleanup-downloads.spec.ts | 50 ++++++++++++++++++++ src/cli/command/cleanup-downloads.ts | 56 +++++++++++++++++++++++ src/cli/command/index.ts | 1 + 6 files changed, 191 insertions(+) create mode 100644 src/cli/cleanup/index.spec.ts create mode 100644 src/cli/cleanup/index.ts create mode 100644 src/cli/command/cleanup-downloads.spec.ts create mode 100644 src/cli/command/cleanup-downloads.ts diff --git a/docs/tools.md b/docs/tools.md index 1f4f7793a1..143084d3f5 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -16,6 +16,8 @@ - [uninstall-pip](#uninstall-pip-command) - [List](#list) - [list-tools](#list-tools-command) +- [Cleanup](#cleanup) + - [cleanup-downloads](#cleanup-downloads-command) --- @@ -518,3 +520,33 @@ When writing to a file, JSON output is minified. ```bash containerbase-cli list tools --json --out /tmp/tools.json ``` + +
+ +## Cleanup + +### `containerbase-cli cleanup downloads` + +#### Description + +Evicts the oldest files from the download cache in `CONTAINERBASE_CACHE_DIR` down to `CONTAINERBASE_MAX_ALLOCATED_DISK`. + +It's the same cleanup which runs after each install, see [download cache](../README.md#download-cache): the oldest files are removed while the disk holding the folder is filled to `CONTAINERBASE_MAX_ALLOCATED_DISK` percent or more. +Nothing is removed when `CONTAINERBASE_CACHE_DIR` isn't set. +`CONTAINERBASE_MAX_ALLOCATED_DISK` defaults to `100`, so only a full disk is cleaned, set a lower value, eg. `80`, to evict earlier, see [download cache](../README.md#download-cache). +With `--dry-run` only the oldest file is logged, as nothing is freed to check the fill level again. + +#### Usage + +`$ containerbase-cli cleanup downloads [-d,--dry-run]` + +#### Examples + +- Evicts the oldest downloads + ```bash + containerbase-cli cleanup downloads + ``` +- Logs the oldest download which would be removed + ```bash + containerbase-cli cleanup downloads --dry-run + ``` diff --git a/src/cli/cleanup/index.spec.ts b/src/cli/cleanup/index.spec.ts new file mode 100644 index 0000000000..e61f7b3b4b --- /dev/null +++ b/src/cli/cleanup/index.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, test, vi } from 'vitest'; +import { CacheService } from '../services/index.ts'; +import { logger } from '../utils/index.ts'; +import { cleanupDownloads } from './index.ts'; + +describe('cli/cleanup/index', () => { + describe('cleanupDownloads', () => { + test('evicts the download cache', async () => { + const spy = vi + .spyOn(CacheService.prototype, 'evict') + .mockResolvedValue(['/cache/a']); + + expect(await cleanupDownloads(true)).toEqual(['/cache/a']); + expect(spy).toHaveBeenCalledExactlyOnceWith(true); + // the dry run file is logged by `evict` already + expect(logger.info).not.toHaveBeenCalled(); + + expect(await cleanupDownloads()).toEqual(['/cache/a']); + expect(logger.info).toHaveBeenCalledExactlyOnceWith( + 'Removed 1 downloads', + ); + }); + + test('logs when there is nothing to evict', async () => { + vi.spyOn(CacheService.prototype, 'evict').mockResolvedValue([]); + + expect(await cleanupDownloads()).toEqual([]); + expect(logger.info).toHaveBeenCalledExactlyOnceWith('Nothing to evict'); + }); + }); +}); diff --git a/src/cli/cleanup/index.ts b/src/cli/cleanup/index.ts new file mode 100644 index 0000000000..d8655d2ff0 --- /dev/null +++ b/src/cli/cleanup/index.ts @@ -0,0 +1,21 @@ +import { CacheService, createContainer } from '../services/index.ts'; +import { logger } from '../utils/index.ts'; + +/** + * Evicts the oldest files from the download cache, see `CacheService.evict`, + * and logs how many were removed. + * + * @returns the removed files, with `dryRun` the file which would be removed + * first + */ +export async function cleanupDownloads(dryRun = false): Promise { + const svc = await createContainer().getAsync(CacheService); + const removed = await svc.evict(dryRun); + if (!removed.length) { + logger.info('Nothing to evict'); + } else if (!dryRun) { + // a dry run only returns the oldest file, which `evict` already logs + logger.info(`Removed ${removed.length} downloads`); + } + return removed; +} diff --git a/src/cli/command/cleanup-downloads.spec.ts b/src/cli/command/cleanup-downloads.spec.ts new file mode 100644 index 0000000000..3d31ca88ec --- /dev/null +++ b/src/cli/command/cleanup-downloads.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, test, vi } from 'vitest'; +import { logger } from '../utils/index.ts'; +import { testCli } from '~test/di.ts'; + +const mocks = vi.hoisted(() => ({ + cleanupDownloads: vi.fn(), +})); + +vi.mock('../cleanup/index.ts', () => mocks); + +describe('cli/command/cleanup-downloads', () => { + test('works', async () => { + const cli = testCli('containerbase-cli'); + mocks.cleanupDownloads.mockResolvedValueOnce(['/cache/a', '/cache/b']); + + expect(await cli.run(['cleanup', 'downloads'])).toBe(0); + + expect(mocks.cleanupDownloads).toHaveBeenCalledExactlyOnceWith(false); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Cleanup downloads succeeded'), + ); + }); + + test('dry run', async () => { + const cli = testCli('containerbase-cli'); + mocks.cleanupDownloads.mockResolvedValueOnce(['/cache/a']); + + expect(await cli.run(['cleanup', 'downloads', '-d'])).toBe(0); + + expect(mocks.cleanupDownloads).toHaveBeenCalledExactlyOnceWith(true); + }); + + test('fails', async () => { + const cli = testCli('containerbase-cli'); + mocks.cleanupDownloads.mockRejectedValueOnce(new Error('test')); + + expect(await cli.run(['cleanup', 'downloads'])).toBe(1); + + expect(logger.error).toHaveBeenCalledWith('test'); + expect(logger.fatal).toHaveBeenCalledWith( + expect.stringContaining('Cleanup downloads failed'), + ); + + // a rejection which is not an `Error` has no message to report + mocks.cleanupDownloads.mockRejectedValueOnce('boom'); + expect(await cli.run(['cleanup', 'downloads'])).toBe(1); + expect(logger.debug).toHaveBeenCalledWith('boom'); + expect(logger.error).not.toHaveBeenCalledWith('boom'); + }); +}); diff --git a/src/cli/command/cleanup-downloads.ts b/src/cli/command/cleanup-downloads.ts new file mode 100644 index 0000000000..6fc3170524 --- /dev/null +++ b/src/cli/command/cleanup-downloads.ts @@ -0,0 +1,56 @@ +import { Command, Option } from 'clipanion'; +import prettyMilliseconds from 'pretty-ms'; +import { cleanupDownloads } from '../cleanup/index.ts'; +import { logger } from '../utils/index.ts'; +import { command } from './utils.ts'; + +@command('containerbase-cli') +export class CleanupDownloadsCommand extends Command { + static override paths = [['cleanup', 'downloads']]; + + static override usage = Command.Usage({ + description: + 'Evicts the oldest files from `CONTAINERBASE_CACHE_DIR` down to `CONTAINERBASE_MAX_ALLOCATED_DISK`.', + examples: [ + ['Evicts the oldest downloads', '$0 cleanup downloads'], + [ + 'Logs the oldest download which would be removed', + '$0 cleanup downloads --dry-run', + ], + ], + }); + + dryRun = Option.Boolean('-d,--dry-run', false); + + /** + * Evicts the oldest files from the download cache, returning a non-zero exit + * code on failure. + */ + async execute(): Promise { + const start = Date.now(); + let error = false; + logger.info('Cleanup downloads ...'); + try { + await cleanupDownloads(this.dryRun); + return 0; + } catch (err) { + error = true; + logger.debug(err); + if (err instanceof Error) { + logger.error(err.message); + } + return 1; + /* v8 ignore next -- coverage bug */ + } finally { + if (error) { + logger.fatal( + `Cleanup downloads failed in ${prettyMilliseconds(Date.now() - start)}.`, + ); + } else { + logger.info( + `Cleanup downloads succeeded in ${prettyMilliseconds(Date.now() - start)}.`, + ); + } + } + } +} diff --git a/src/cli/command/index.ts b/src/cli/command/index.ts index 4a1f5fa391..8a69ec460b 100644 --- a/src/cli/command/index.ts +++ b/src/cli/command/index.ts @@ -1,3 +1,4 @@ +import './cleanup-downloads.ts'; import './cleanup-path.ts'; import './file-download.ts'; import './file-exists.ts';