Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- [List](#list)
- [list-tools](#list-tools-command)
- [Cleanup](#cleanup)
- [cleanup](#cleanup-command)
- [cleanup-cache](#cleanup-cache-command)
- [cleanup-downloads](#cleanup-downloads-command)
- [cleanup-tool](#cleanup-tool-command)
Expand Down Expand Up @@ -527,6 +528,39 @@ When writing to a file, JSON output is minified.

## Cleanup

### `containerbase-cli cleanup` <a name="cleanup-command"></a>

#### Description <a name="Description-cleanup"></a>

Runs all cleanups in this order:

1. [`cleanup tool`](#cleanup-tool-command) removes the unlinked tools
1. [`cleanup cache`](#cleanup-cache-command) removes the known tool caches
1. [`cleanup downloads`](#cleanup-downloads-command) evicts the oldest downloads

`CONTAINERBASE_MAX_ALLOCATED_DISK` defaults to `100`, so downloads are only evicted on a full disk, set a lower value, eg. `80`, to evict earlier, see [download cache](../README.md#download-cache).

When a step fails, it's logged and the next step still runs.
The command fails at the end when any step failed.
It fails right away, without running any step, when the tools can't be loaded, eg. because the containerbase folders aren't writable.

#### Usage <a name="Usage-cleanup"></a>

`$ containerbase-cli cleanup [-d,--dry-run]`

#### Examples <a name="Examples-cleanup"></a>

- Runs all cleanups
```bash
containerbase-cli cleanup
```
- Logs what all cleanups would remove
```bash
containerbase-cli cleanup --dry-run
```

<br>

### `containerbase-cli cleanup cache` <a name="cleanup-cache-command"></a>

#### Description <a name="Description-cleanup-cache"></a>
Expand Down
94 changes: 93 additions & 1 deletion src/cli/cleanup/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@ import {
VersionService,
createContainer,
} from '../services/index.ts';
import { NotSupported } from '../utils/codes.ts';
import { logger, pathExists } from '../utils/index.ts';
import { cleanupCache, cleanupDownloads, cleanupTools } from './index.ts';
import { CleanupCacheService } from './cleanup-cache.service.ts';
import { CleanupToolService } from './cleanup-tool.service.ts';
import {
cleanupAll,
cleanupCache,
cleanupDownloads,
cleanupTools,
} from './index.ts';
import { ensurePaths, rootPath } from '~test/path.ts';

vi.mock('del');
Expand Down Expand Up @@ -137,4 +145,88 @@ describe('cli/cleanup/index', () => {
expect(logger.info).toHaveBeenCalledExactlyOnceWith('Nothing to evict');
});
});

describe('cleanupAll', () => {
test('runs all cleanups in order', async () => {
const tools = vi
.spyOn(CleanupToolService.prototype, 'cleanupTools')
.mockResolvedValue();
const cache = vi
.spyOn(CleanupCacheService.prototype, 'cleanupCache')
.mockResolvedValue([]);
const downloads = vi
.spyOn(CacheService.prototype, 'evict')
.mockResolvedValue([]);
// called once per install container
const containers = vi.spyOn(PathService.prototype, 'findLegacyTools');

expect(await cleanupAll(true)).toBe(0);

expect(containers).toHaveBeenCalledOnce();
expect(tools).toHaveBeenCalledExactlyOnceWith(true);
expect(cache).toHaveBeenCalledExactlyOnceWith(true);
expect(downloads).toHaveBeenCalledExactlyOnceWith(true);
// the per step summaries are logged once
expect(vi.mocked(logger.info).mock.calls).toEqual([
['Cleanup tool ...'],
['Cleanup cache ...'],
['Dry run: would remove 0 cache paths'],
['Cleanup downloads ...'],
['Nothing to evict'],
]);
});

test('continues after a failed step', async () => {
vi.spyOn(CleanupToolService.prototype, 'cleanupTools').mockResolvedValue(
NotSupported,
);
const err = new Error('cache failed');
vi.spyOn(CleanupCacheService.prototype, 'cleanupCache').mockRejectedValue(
err,
);
const downloads = vi
.spyOn(CacheService.prototype, 'evict')
.mockResolvedValue([]);

expect(await cleanupAll(true)).toBe(NotSupported);

expect(downloads).toHaveBeenCalledOnce();
expect(logger.warn).toHaveBeenCalledWith(
{ code: NotSupported },
'Cleanup tool failed',
);
expect(logger.warn).toHaveBeenCalledWith({ err }, 'Cleanup cache failed');
});

test('returns 1 when a step threw', async () => {
vi.spyOn(
CleanupToolService.prototype,
'cleanupTools',
).mockResolvedValue();
vi.spyOn(CleanupCacheService.prototype, 'cleanupCache').mockResolvedValue(
[],
);
vi.spyOn(CacheService.prototype, 'evict').mockRejectedValue(
new Error('evict failed'),
);

expect(await cleanupAll(true)).toBe(1);
});

test('fails without running a step when the container cannot be created', async () => {
vi.spyOn(VersionService.prototype, 'getTypes').mockRejectedValueOnce(
new Error('db failed'),
);
const tools = vi.spyOn(CleanupToolService.prototype, 'cleanupTools');
const cache = vi.spyOn(CleanupCacheService.prototype, 'cleanupCache');
const downloads = vi.spyOn(CacheService.prototype, 'evict');

await expect(cleanupAll(true)).rejects.toThrow('db failed');

expect(tools).not.toHaveBeenCalled();
expect(cache).not.toHaveBeenCalled();
expect(downloads).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
});
});
});
72 changes: 64 additions & 8 deletions src/cli/cleanup/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Container } from 'inversify';
import {
prepareInstallContainer,
prepareUninstallContainer,
Expand All @@ -7,31 +8,48 @@ import { logger } from '../utils/index.ts';
import { CleanupCacheService } from './cleanup-cache.service.ts';
import { CleanupToolService } from './cleanup-tool.service.ts';

/** Binds the cleanup services to an install container. */
function bindCleanupServices(container: Container): Container {
container.bind(CleanupCacheService).toSelf();
container.bind(CleanupToolService).toSelf();
return container;
}

/**
* Uninstalls all unlinked tool versions, see
* `CleanupToolService.cleanupTools`. Generic install services are registered
* for every installed `gem`, `npm` or `pip` package.
*
* @param container a container from `prepareUninstallContainer` with the
* cleanup services to reuse, else a new one is created
* @throws when the containerbase folders are not writable, except for a dry
* run
*/
export async function cleanupTools(dryRun = false): Promise<number | void> {
const container = await prepareUninstallContainer(dryRun);
container.bind(CleanupToolService).toSelf();
const svc = await container.getAsync(CleanupToolService);
export async function cleanupTools(
dryRun = false,
container?: Container,
): Promise<number | void> {
const svc = await (
container ?? bindCleanupServices(await prepareUninstallContainer(dryRun))
).getAsync(CleanupToolService);
return svc.cleanupTools(dryRun);
}

/**
* Removes the known tool caches, see `CleanupCacheService.cleanupCache`, and
* logs how many were removed.
*
* @param container an install container with the cleanup services to reuse,
* else a new one is created
* @returns the removed paths, with `dryRun` the paths which would be removed
*/
export async function cleanupCache(dryRun = false): Promise<string[]> {
const container = await prepareInstallContainer();
container.bind(CleanupCacheService).toSelf();
const svc = await container.getAsync(CleanupCacheService);
export async function cleanupCache(
dryRun = false,
container?: Container,
): Promise<string[]> {
const svc = await (
container ?? bindCleanupServices(await prepareInstallContainer())
).getAsync(CleanupCacheService);
const removed = await svc.cleanupCache(dryRun);
logger.info(
dryRun
Expand Down Expand Up @@ -59,3 +77,41 @@ export async function cleanupDownloads(dryRun = false): Promise<string[]> {
}
return removed;
}

/**
* Runs all cleanups in order: the unlinked tools, the tool caches and the
* download cache, see `cleanupTools`, `cleanupCache` and `cleanupDownloads`.
* A failing step is logged and the next one still runs. The tool and cache
* cleanups share one install container, created before any step runs.
*
* @returns the exit code of the first failed step, `1` when it threw, else `0`
* @throws when the install container can't be created, eg. because the
* containerbase folders are not writable, then no step runs
*/
export async function cleanupAll(dryRun = false): Promise<number> {
const container = bindCleanupServices(
await prepareUninstallContainer(dryRun),
);
const steps: [string, () => Promise<unknown>][] = [
['tool', () => cleanupTools(dryRun, container)],
['cache', () => cleanupCache(dryRun, container)],
['downloads', () => cleanupDownloads(dryRun)],
];
let code = 0;
for (const [name, step] of steps) {
logger.info(`Cleanup ${name} ...`);
let res: unknown;
try {
res = await step();
} catch (err) {
logger.warn({ err }, `Cleanup ${name} failed`);
code ||= 1;
continue;
}
if (typeof res === 'number' && res) {
logger.warn({ code: res }, `Cleanup ${name} failed`);
code ||= res;
}
}
return code;
}
86 changes: 86 additions & 0 deletions src/cli/command/cleanup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, test, vi } from 'vitest';
import { logger } from '../utils/index.ts';
import { testCli } from '~test/di.ts';

const mocks = vi.hoisted(() => ({
cleanupAll: vi.fn(),
cleanupCache: vi.fn(),
cleanupDownloads: vi.fn(),
cleanupTools: vi.fn(),
}));

const del = vi.hoisted(() => ({ deleteAsync: vi.fn() }));

vi.mock('../cleanup/index.ts', () => mocks);
vi.mock('del', () => del);

describe('cli/command/cleanup', () => {
test('works', async () => {
const cli = testCli('containerbase-cli');
mocks.cleanupAll.mockResolvedValueOnce(0);

expect(await cli.run(['cleanup'])).toBe(0);

expect(mocks.cleanupAll).toHaveBeenCalledExactlyOnceWith(false);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Cleanup succeeded'),
);
});

test('dry run', async () => {
const cli = testCli('containerbase-cli');
mocks.cleanupAll.mockResolvedValueOnce(0);

expect(await cli.run(['cleanup', '--dry-run'])).toBe(0);

expect(mocks.cleanupAll).toHaveBeenCalledExactlyOnceWith(true);
});

test('fails', async () => {
const cli = testCli('containerbase-cli');
mocks.cleanupAll.mockResolvedValueOnce(2);

expect(await cli.run(['cleanup'])).toBe(2);

expect(logger.fatal).toHaveBeenCalledWith(
expect.stringContaining('Cleanup failed'),
);
});

test('fails when the cleanups cannot start', async () => {
const cli = testCli('containerbase-cli');
mocks.cleanupAll.mockRejectedValueOnce(new Error('not writable'));

expect(await cli.run(['cleanup'])).toBe(1);

expect(logger.error).toHaveBeenCalledWith('not writable');
expect(logger.fatal).toHaveBeenCalledWith(
expect.stringContaining('Cleanup failed'),
);

// a rejection which is not an `Error` has no message to report
mocks.cleanupAll.mockRejectedValueOnce('boom');
expect(await cli.run(['cleanup'])).toBe(1);
expect(logger.debug).toHaveBeenCalledWith('boom');
expect(logger.error).not.toHaveBeenCalledWith('boom');
});

test('keeps the sub commands', async () => {
const cli = testCli('containerbase-cli');
mocks.cleanupCache.mockResolvedValue([]);
mocks.cleanupDownloads.mockResolvedValue([]);

expect(await cli.run(['cleanup', 'cache'])).toBe(0);
expect(await cli.run(['cleanup', 'downloads'])).toBe(0);
expect(await cli.run(['cleanup', 'tool'])).toBe(0);
expect(await cli.run(['cleanup', 'path', '/tmp/a'])).toBe(0);

expect(mocks.cleanupCache).toHaveBeenCalledExactlyOnceWith(false);
expect(mocks.cleanupDownloads).toHaveBeenCalledExactlyOnceWith(false);
expect(mocks.cleanupTools).toHaveBeenCalledExactlyOnceWith(false);
expect(del.deleteAsync).toHaveBeenCalledExactlyOnceWith(['/tmp/a'], {
dot: true,
});
expect(mocks.cleanupAll).not.toHaveBeenCalled();
});
});
51 changes: 51 additions & 0 deletions src/cli/command/cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Command, Option } from 'clipanion';
import prettyMilliseconds from 'pretty-ms';
import { cleanupAll } from '../cleanup/index.ts';
import { logger } from '../utils/index.ts';
import { command } from './utils.ts';

@command('containerbase-cli')
export class CleanupCommand extends Command {
static override paths = [['cleanup']];

static override usage = Command.Usage({
description:
'Removes the unlinked tools and the tool caches, and evicts the oldest downloads.',
examples: [
['Runs all cleanups', '$0 cleanup'],
['Logs what all cleanups would remove', '$0 cleanup --dry-run'],
],
});

dryRun = Option.Boolean('-d,--dry-run', false);

/**
* Runs the tool, cache and downloads cleanups, returning a non-zero exit
* code when any of them failed or they could not start.
*/
async execute(): Promise<number | void> {
const start = Date.now();
let res = 1;
try {
res = await cleanupAll(this.dryRun);
return res;
} catch (err) {
logger.debug(err);
if (err instanceof Error) {
logger.error(err.message);
}
return res;
/* v8 ignore next -- coverage bug */
} finally {
if (res) {
logger.fatal(
`Cleanup failed in ${prettyMilliseconds(Date.now() - start)}.`,
);
} else {
logger.info(
`Cleanup succeeded in ${prettyMilliseconds(Date.now() - start)}.`,
);
}
}
}
}
Loading
Loading