diff --git a/src/btrfs/__tests__/cli_driver.test.ts b/src/btrfs/__tests__/cli_driver.test.ts new file mode 100644 index 0000000..6ee16a3 --- /dev/null +++ b/src/btrfs/__tests__/cli_driver.test.ts @@ -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); + }); +}); diff --git a/src/btrfs/cli_driver.ts b/src/btrfs/cli_driver.ts new file mode 100644 index 0000000..24c4954 --- /dev/null +++ b/src/btrfs/cli_driver.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/src/btrfs/interfaces/driver.ts b/src/btrfs/interfaces/driver.ts new file mode 100644 index 0000000..0ebc319 --- /dev/null +++ b/src/btrfs/interfaces/driver.ts @@ -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; + + /** + * Delete an existing Btrfs subvolume. + * @param path Full path to the subvolume to delete. + * @throws {BtrfsError} if the operation fails. + */ + deleteSubvolume(path: string): Promise; + + /** + * 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; + + /** + * 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; + + /** + * 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; + + /** + * 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 }>; +} diff --git a/src/btrfs/interfaces/errors.ts b/src/btrfs/interfaces/errors.ts new file mode 100644 index 0000000..7f025f1 --- /dev/null +++ b/src/btrfs/interfaces/errors.ts @@ -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; + } +} diff --git a/src/btrfs/interfaces/mod.ts b/src/btrfs/interfaces/mod.ts new file mode 100644 index 0000000..e5d9503 --- /dev/null +++ b/src/btrfs/interfaces/mod.ts @@ -0,0 +1,4 @@ +// DO NOT EDIT! This file is automatically generated. + +export type { IBtrfsDriver } from './driver.ts'; +export { BtrfsError } from './errors.ts'; diff --git a/src/btrfs/mock_driver.ts b/src/btrfs/mock_driver.ts new file mode 100644 index 0000000..a327a74 --- /dev/null +++ b/src/btrfs/mock_driver.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/btrfs/utils/mod.ts b/src/btrfs/utils/mod.ts new file mode 100644 index 0000000..fa6b317 --- /dev/null +++ b/src/btrfs/utils/mod.ts @@ -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'; diff --git a/src/btrfs/utils/test_volume.ts b/src/btrfs/utils/test_volume.ts new file mode 100644 index 0000000..a1ead06 --- /dev/null +++ b/src/btrfs/utils/test_volume.ts @@ -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 { + 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 { + 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 { + 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 { + log.info(`[BTRFS] Disabling quota on ${mountPoint}`); + await run('sudo', ['btrfs', 'quota', 'disable', mountPoint]); + log.info(`[BTRFS] Quota disabled`); +}