feat(btrfs): add CLI and mock drivers with test utilities and interfaces #1

Merged
maxp merged 3 commits from feature/btrfs-driver into main 2025-10-12 15:02:46 +02:00
8 changed files with 632 additions and 0 deletions
Showing only changes of commit c5e31b6c93 - Show all commits

View File

@@ -0,0 +1,180 @@
import {
afterAll,
beforeAll,
beforeEach,
describe,
it,
} from '@std/testing/bdd';
import {
assert,
assertEquals,
assertInstanceOf,
assertRejects,
assertStringIncludes,
} from '@std/assert';
import { join } from '@std/path';
import { configure, getConsoleSink } from '@logtape/logtape';
import { getPrettyFormatter } from '@logtape/pretty';
import { BtrfsError, IBtrfsDriver } from '../interfaces/mod.ts';
import { BtrfsCliDriver } from '../cli_driver.ts';
import {
type BtrfsTestVolume,
createBtrfsTestVolume,
destroyBtrfsTestVolume,
disableQuota,
resetBtrfsTestVolume,
} from '../utils/test_volume.ts';
let vol: BtrfsTestVolume;
let driver: IBtrfsDriver;
await configure({
sinks: {
console: getConsoleSink({
formatter: getPrettyFormatter({
timestamp: 'date-time',
messageColor: '#ffffff',
}),
}),
},
loggers: [
{ category: 'test', lowestLevel: 'trace', sinks: ['console'] },
{ category: ['logtape', 'meta'], sinks: [] },
],
});
describe('BtrfsCliDriver', () => {
beforeAll(async () => {
vol = await createBtrfsTestVolume(512);
driver = new BtrfsCliDriver(true, ['test', 'btrfs_cli_driver']);
});
afterAll(async () => {
await destroyBtrfsTestVolume(vol);
});
beforeEach(async () => {
// Wir starten jeden Test mit leerem FS
await resetBtrfsTestVolume(vol);
});
it('creates a new subvolume', async () => {
const name = 'vol1';
const path = await driver.createSubvolume(name, vol.mountPoint);
const list = await driver.listSubvolumes(vol.mountPoint);
assert(
list.includes(path),
`Expected ${path} to appear in list: ${JSON.stringify(list)}`,
);
});
it('deletes an existing subvolume', async () => {
const name = 'to-delete';
const path = await driver.createSubvolume(name, vol.mountPoint);
await driver.deleteSubvolume(path);
const list = await driver.listSubvolumes(vol.mountPoint);
assert(!list.includes(path), 'Deleted subvolume should not appear');
});
it('throws when deleting non-existent subvolume', async () => {
const bogus = join(vol.mountPoint, 'not-here');
await assertRejects(
() => driver.deleteSubvolume(bogus),
Error,
);
});
it('lists multiple subvolumes correctly', async () => {
const paths = await Promise.all([
driver.createSubvolume('a', vol.mountPoint),
driver.createSubvolume('b', vol.mountPoint),
driver.createSubvolume('c', vol.mountPoint),
]);
const list = await driver.listSubvolumes(vol.mountPoint);
for (const p of paths) {
assert(list.includes(p), `Expected ${p} in list`);
}
});
it('sets compression and quota without throwing', async () => {
const path = await driver.createSubvolume('cfg', vol.mountPoint);
await driver.setCompression(path, 'zstd');
await driver.setQuota(path, '128M');
assert(true); // falls keine Exception geworfen wird
});
it('returns getInfo() = exists:true for valid subvolume', async () => {
const path = await driver.createSubvolume('exists', vol.mountPoint);
const info = await driver.getInfo?.(path);
assert(info?.exists, 'Expected exists=true');
});
it('returns getInfo() = exists:false for invalid path', async () => {
const bogus = join(vol.mountPoint, 'not-real');
const info = await driver.getInfo?.(bogus);
assertEquals(info?.exists, false);
});
});
describe('BtrfsCliDriver (error handling)', () => {
beforeAll(async () => {
vol = await createBtrfsTestVolume(512);
driver = new BtrfsCliDriver(true, ['test', 'btrfs_cli_driver']);
});
afterAll(async () => {
await destroyBtrfsTestVolume(vol);
});
beforeEach(async () => {
await resetBtrfsTestVolume(vol);
});
it('throws BtrfsError with code and details on invalid command', async () => {
const err = await assertRejects(
() =>
// deno-lint-ignore no-explicit-any
(driver as any).constructor.run([
'btrfs',
'subvolume',
'invalid-op',
], true),
BtrfsError,
);
assertInstanceOf(err, BtrfsError);
assert(typeof err.code === 'number', 'Error should have exit code');
assertStringIncludes(err.details ?? '', 'invalid-op');
});
it('throws BtrfsError when setting quota without quota enabled', async () => {
const path = await driver.createSubvolume('quota-test', vol.mountPoint);
await disableQuota(vol.mountPoint);
const err = await assertRejects(
() => driver.setQuota(path, '10M'),
BtrfsError,
);
assertInstanceOf(err, BtrfsError);
assertStringIncludes(err.details ?? '', 'quota');
});
it('handles gracefully when deleting missing subvolume', async () => {
const bogus = join(vol.mountPoint, 'nope');
await assertRejects(
() => driver.deleteSubvolume(bogus),
BtrfsError,
);
});
it('returns false on getInfo for a non-existent path (and no throw)', async () => {
const bogus = join(vol.mountPoint, 'ghost');
const info = await driver.getInfo?.(bogus);
assertEquals(info?.exists, false);
});
});

159
src/btrfs/cli_driver.ts Normal file
View File

@@ -0,0 +1,159 @@
import { BtrfsError, IBtrfsDriver } from './interfaces/mod.ts';
import { join, resolve } from '@std/path';
import { getLogger, Logger } from '@logtape/logtape';
/**
* CLI-based implementation of IBtrfsDriver.
* Wraps native `btrfs` commands with consistent logging and error handling.
*/
export class BtrfsCliDriver implements IBtrfsDriver {
private readonly log: Logger;
private readonly needSudo: boolean;
constructor(needSudo = false, loggerCategory = ['btrfs', 'cli_driver']) {
this.needSudo = needSudo;
this.log = getLogger(loggerCategory);
}
/** Internal helper to execute a system command and normalize its result. */
private static async run(
cmd: string[],
sudo = true,
allowFailure = false,
): Promise<string> {
const fullCmd = sudo ? ['sudo', ...cmd] : cmd;
const proc = new Deno.Command(fullCmd[0], { args: fullCmd.slice(1) });
const { code, stdout, stderr } = await proc.output();
const out = new TextDecoder().decode(stdout).trim();
const err = new TextDecoder().decode(stderr).trim();
if (code !== 0 && !allowFailure) {
throw new BtrfsError(`Btrfs command failed: ${fullCmd.join(' ')}`, {
code,
details: err || out || 'Unknown error',
});
}
return out;
}
async createSubvolume(name: string, basePath: string): Promise<string> {
this.log.trace(`Creating subvolume ${name} in ${basePath}`);
const path = join(basePath, name);
try {
const response = await BtrfsCliDriver.run(
['btrfs', 'subvolume', 'create', path],
this.needSudo,
);
this.log.trace(`Created subvolume at ${path}: ${response}`);
return path;
} catch (err) {
if (
err instanceof BtrfsError &&
err.details?.includes('File exists')
) {
this.log.error(`Subvolume already exists: ${path}`);
}
throw err;
}
}
async deleteSubvolume(path: string): Promise<void> {
this.log.trace(`Deleting subvolume at ${path}`);
try {
const response = await BtrfsCliDriver.run(
['btrfs', 'subvolume', 'delete', path],
this.needSudo,
);
this.log.trace(`Deleted subvolume at ${path}: ${response}`);
} catch (err) {
if (
err instanceof BtrfsError &&
err.details?.includes('No such file')
) {
this.log.error(`Subvolume not found during delete: ${path}`);
}
throw err;
}
}
async listSubvolumes(basePath: string): Promise<string[]> {
this.log.trace(`Listing subvolumes in ${basePath}`);
try {
const out = await BtrfsCliDriver.run(
['btrfs', 'subvolume', 'list', '-o', basePath],
this.needSudo,
);
this.log.trace(`Subvolumes in ${basePath}:\n${out}`);
return out
.split('\n')
.filter(Boolean)
.map((line) => {
const parts = line.trim().split(/\s+/);
const last = parts[parts.length - 1];
return resolve(basePath, last);
});
} catch (err) {
this.log.error(`Failed to list subvolumes in ${basePath}: ${err}`);
throw err;
}
}
async setCompression(path: string, method: string): Promise<void> {
this.log.trace(`Setting compression on ${path} to ${method}`);
try {
const response = await BtrfsCliDriver.run(
['btrfs', 'property', 'set', path, 'compression', method],
this.needSudo,
);
this.log.trace(`Set compression on ${path}: ${response}`);
} catch (err) {
this.log.error(`Failed to set compression on ${path}: ${err}`);
throw new BtrfsError(`Failed to set compression on ${path}`, {
code: (err as BtrfsError).code,
details: (err as BtrfsError).details,
});
}
}
async setQuota(path: string, limit: string): Promise<void> {
this.log.trace(`Setting quota on ${path} to ${limit}`);
try {
const response = await BtrfsCliDriver.run(
['btrfs', 'qgroup', 'limit', limit, path],
this.needSudo,
);
this.log.trace(`Set quota on ${path}: ${response}`);
} catch (err) {
if (
err instanceof BtrfsError &&
err.details?.includes('quota not enabled')
) {
this.log.error(`Quota not enabled on ${path}`);
}
throw err;
}
}
async getInfo(path: string): Promise<{ exists: boolean }> {
this.log.trace(`Getting info for subvolume at ${path}`);
try {
const res = await BtrfsCliDriver.run(
['btrfs', 'subvolume', 'show', path],
this.needSudo,
true,
);
this.log.trace(`Got info for subvolume at ${path}: ${res}`);
return { exists: res.length > 0 };
} catch (err) {
if (err instanceof BtrfsError) {
this.log.trace(`Subvolume not found: ${path}`);
return { exists: false };
}
throw err;
}
}
}

View File

@@ -0,0 +1,56 @@
/**
* Interface for a Btrfs driver abstraction.
*
* Implementations should wrap btrfs subvolume, compression, and quota operations
* while keeping a promise-based API suitable for Deno.
*/
export interface IBtrfsDriver {
/**
* Create a new Btrfs subvolume.
* @param name Name of the subvolume to create.
* @param basePath Directory under which the subvolume will be created.
* @returns Full absolute path of the created subvolume (basePath + name).
* @throws {BtrfsError} if the operation fails.
*/
createSubvolume(name: string, basePath: string): Promise<string>;
/**
* Delete an existing Btrfs subvolume.
* @param path Full path to the subvolume to delete.
* @throws {BtrfsError} if the operation fails.
*/
deleteSubvolume(path: string): Promise<void>;
/**
* Configure compression for a subvolume or path.
* @param path Full path to the subvolume or directory.
* @param method Compression method (e.g., "zstd", "lz4").
* @throws {BtrfsError} if the operation fails.
*/
setCompression(path: string, method: string): Promise<void>;
/**
* Set a quota/space limit for a subvolume.
* @param path Full path to the subvolume.
* @param limit Human-readable size (e.g., "10G", "512M").
* @throws {BtrfsError} if the operation fails.
*/
setQuota(path: string, limit: string): Promise<void>;
/**
* List subvolumes beneath a base directory.
* @param basePath Directory to scan for subvolumes.
* @returns A promise resolving to a list of subvolume paths.
* @throws {BtrfsError} if the operation fails.
*/
listSubvolumes(basePath: string): Promise<string[]>;
/**
* Optional check for subvolume existence or metadata.
* @param path Path to check.
* @returns A promise resolving to minimal info ({ exists: boolean }) plus optional metadata.
*/
getInfo?(
path: string,
): Promise<{ exists: boolean; [key: string]: unknown }>;
}

View File

@@ -0,0 +1,21 @@
/**
* Error thrown by Btrfs driver implementations on operation failure.
*
* Implementations may extend this to include exit codes or stderr output.
*/
export class BtrfsError extends Error {
/** Optional underlying error code or process exit code. */
readonly code?: number | string;
/** Optional raw stderr or diagnostic message from the system. */
readonly details?: string;
constructor(
message: string,
options?: { code?: number | string; details?: string },
) {
super(message);
this.name = 'BtrfsError';
this.code = options?.code;
this.details = options?.details;
}
}

View File

@@ -0,0 +1,4 @@
// DO NOT EDIT! This file is automatically generated.
export type { IBtrfsDriver } from './driver.ts';
export { BtrfsError } from './errors.ts';

88
src/btrfs/mock_driver.ts Normal file
View File

@@ -0,0 +1,88 @@
// deno-lint-ignore-file require-await
import { IBtrfsDriver } from './interfaces/mod.ts';
import { join, resolve } from '@std/path';
import { getLogger, Logger } from '@logtape/logtape';
/**
* In-memory mock implementation of IBtrfsDriver.
*
* Behaves like the real BtrfsCliDriver but stores subvolumes and metadata
* in plain JavaScript objects instead of touching the filesystem.
*/
export class MockBtrfsDriver implements IBtrfsDriver {
private readonly log: Logger;
/** Internal simulated subvolume registry */
private readonly volumes = new Map<
string,
{ compression?: string; quota?: string }
>();
constructor(_needSudo = false, loggerCategory = ['btrfs', 'mock_driver']) {
this.log = getLogger(loggerCategory);
}
/** Simulate creation of a subvolume under a base path. */
async createSubvolume(name: string, basePath: string): Promise<string> {
const path = resolve(join(basePath, name));
this.log.trace(`(mock) Creating subvolume at ${path}`);
if (this.volumes.has(path)) {
throw new Error(`(mock) Subvolume already exists: ${path}`);
}
this.volumes.set(path, {});
return path;
}
/** Simulate deletion of a subvolume. */
async deleteSubvolume(path: string): Promise<void> {
const resolved = resolve(path);
this.log.trace(`(mock) Deleting subvolume at ${resolved}`);
if (!this.volumes.delete(resolved)) {
throw new Error(`(mock) Subvolume not found: ${resolved}`);
}
}
/** Return all simulated subvolumes under a base path. */
async listSubvolumes(basePath: string): Promise<string[]> {
const prefix = resolve(basePath);
const result = [...this.volumes.keys()].filter((v) =>
v.startsWith(prefix)
);
this.log.trace(
`(mock) Listing subvolumes under ${prefix}: ${result.length} found`,
);
return result;
}
/** Simulate setting a compression property. */
async setCompression(path: string, method: string): Promise<void> {
const resolved = resolve(path);
this.log.trace(`(mock) Setting compression=${method} on ${resolved}`);
const vol = this.volumes.get(resolved);
if (!vol) throw new Error(`(mock) Subvolume not found: ${resolved}`);
vol.compression = method;
}
/** Simulate applying a quota. */
async setQuota(path: string, limit: string): Promise<void> {
const resolved = resolve(path);
this.log.trace(`(mock) Setting quota=${limit} on ${resolved}`);
const vol = this.volumes.get(resolved);
if (!vol) throw new Error(`(mock) Subvolume not found: ${resolved}`);
vol.quota = limit;
}
/** Return simulated metadata for a subvolume. */
async getInfo(
path: string,
): Promise<{ exists: boolean; [key: string]: unknown }> {
const resolved = resolve(path);
const vol = this.volumes.get(resolved);
const exists = !!vol;
this.log.trace(`(mock) getInfo(${resolved}) => exists=${exists}`);
return exists ? { exists, ...vol } : { exists };
}
}

9
src/btrfs/utils/mod.ts Normal file
View File

@@ -0,0 +1,9 @@
// DO NOT EDIT! This file is automatically generated.
export {
createBtrfsTestVolume,
destroyBtrfsTestVolume,
disableQuota,
resetBtrfsTestVolume,
} from './test_volume.ts';
export type { BtrfsTestVolume } from './test_volume.ts';

View File

@@ -0,0 +1,115 @@
/**
* Utilities to create, mount, unmount, and clean Btrfs test volumes.
* Designed for use in Deno integration tests.
*/
import { join } from '@std/path';
import { getLogger } from '@logtape/logtape';
const log = getLogger(['test', 'btrfs-test-volume']);
/**
* Represents an active temporary Btrfs test volume.
*/
export interface BtrfsTestVolume {
/** Base temporary directory for the image and mount point. */
readonly tmpDir: string;
/** Full path to the loopback image file. */
readonly imgPath: string;
/** Mount point directory for the Btrfs filesystem. */
readonly mountPoint: string;
}
/**
* Executes a system command and throws on non-zero exit.
*/
async function run(cmd: string, args: string[]) {
const proc = new Deno.Command(cmd, { args });
const res = await proc.output();
if (res.code !== 0) {
const err = new TextDecoder().decode(res.stderr);
log.error(`[CMD] ${cmd} ${args.join(' ')} failed: ${err.trim()}`);
throw new Error(`[${cmd}] failed: ${err.trim()}`);
}
}
/**
* Creates and mounts a temporary Btrfs volume under /tmp (or system temp dir).
*
* @param sizeMB Size of the image in megabytes (default: 512)
* @returns BtrfsTestVolume describing paths.
*/
export async function createBtrfsTestVolume(
sizeMB = 512,
): Promise<BtrfsTestVolume> {
const tmpDir = await Deno.makeTempDir({ prefix: 'btrfs-test-' });
const imgPath = join(tmpDir, 'btrfs.img');
const mountPoint = join(tmpDir, 'mnt');
log.info(`[BTRFS] Creating test volume at ${tmpDir} (${sizeMB}MB)`);
// Create and format the image
await run('truncate', ['-s', `${sizeMB}M`, imgPath]);
await run('mkfs.btrfs', ['-f', imgPath]);
// Mount it via loop device
await Deno.mkdir(mountPoint);
await run('sudo', ['mount', '-o', 'loop', imgPath, mountPoint]);
// Enable quotas (required for quota operations)
await run('sudo', ['btrfs', 'quota', 'enable', mountPoint]);
log.info(`[BTRFS] Mounted at ${mountPoint}`);
return { tmpDir, imgPath, mountPoint };
}
/**
* Unmounts and removes a previously created test volume.
*/
export async function destroyBtrfsTestVolume(
vol: BtrfsTestVolume,
): Promise<void> {
log.info(`[BTRFS] Unmounting ${vol.mountPoint}`);
try {
await run('sudo', ['umount', vol.mountPoint]);
} catch (e) {
if (e instanceof Error) {
log.warn(`[WARN] umount failed: ${e.message}`);
} else {
log.warn(`[WARN] umount failed: ${String(e)}`);
}
}
log.info(`[BTRFS] Cleaning up ${vol.tmpDir}`);
await Deno.remove(vol.tmpDir, { recursive: true });
}
/**
* Recreates the filesystem within an existing test volume.
* Equivalent to wiping all subvolumes (fresh mkfs.btrfs on the same image).
*
* This is faster than deleting and remounting everything manually.
*/
export async function resetBtrfsTestVolume(
vol: BtrfsTestVolume,
): Promise<void> {
log.info(`[BTRFS] Resetting test volume`);
// Unmount, reformat, remount
await run('sudo', ['umount', vol.mountPoint]);
await run('mkfs.btrfs', ['-f', vol.imgPath]);
await run('sudo', ['mount', '-o', 'loop', vol.imgPath, vol.mountPoint]);
await run('sudo', ['btrfs', 'quota', 'enable', vol.mountPoint]);
log.info(`[BTRFS] Volume reinitialized`);
}
/**
* Disables quota on a mounted Btrfs filesystem.
* Useful for error tests
*/
export async function disableQuota(mountPoint: string): Promise<void> {
log.info(`[BTRFS] Disabling quota on ${mountPoint}`);
await run('sudo', ['btrfs', 'quota', 'disable', mountPoint]);
log.info(`[BTRFS] Quota disabled`);
}