feat(btrfs): add CLI and mock drivers with test utilities and interfaces
Co-authored-by: Max P. <mail@0xMax42.io> Co-committed-by: Max P. <mail@0xMax42.io>
This commit is contained in:
@@ -12,7 +12,7 @@ on:
|
||||
- reopened
|
||||
|
||||
jobs:
|
||||
build:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -2,5 +2,8 @@
|
||||
"exportall.config.folderListener": [
|
||||
"/src/btrfs/interfaces",
|
||||
"/src/btrfs/utils"
|
||||
],
|
||||
"exportall.config.relExclusion": [
|
||||
"/src/btrfs"
|
||||
]
|
||||
}
|
||||
186
src/btrfs/__tests__/cli_driver.test.ts
Normal file
186
src/btrfs/__tests__/cli_driver.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
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,
|
||||
hasLoopDevices,
|
||||
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: [] },
|
||||
],
|
||||
});
|
||||
|
||||
if (!(await hasLoopDevices())) {
|
||||
console.warn('Skipping BtrfsCliDriver tests: No loop devices available');
|
||||
Deno.exit(0);
|
||||
}
|
||||
|
||||
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
159
src/btrfs/cli_driver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/btrfs/interfaces/driver.ts
Normal file
56
src/btrfs/interfaces/driver.ts
Normal 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 }>;
|
||||
}
|
||||
21
src/btrfs/interfaces/errors.ts
Normal file
21
src/btrfs/interfaces/errors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
4
src/btrfs/interfaces/mod.ts
Normal file
4
src/btrfs/interfaces/mod.ts
Normal 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
88
src/btrfs/mock_driver.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
5
src/btrfs/mod.ts
Normal file
5
src/btrfs/mod.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// DO NOT EDIT! This file is automatically generated.
|
||||
|
||||
export { BtrfsCliDriver } from './cli_driver.ts';
|
||||
export { BtrfsError } from './interfaces/mod.ts';
|
||||
export type { IBtrfsDriver } from './interfaces/mod.ts';
|
||||
10
src/btrfs/utils/mod.ts
Normal file
10
src/btrfs/utils/mod.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// DO NOT EDIT! This file is automatically generated.
|
||||
|
||||
export {
|
||||
createBtrfsTestVolume,
|
||||
destroyBtrfsTestVolume,
|
||||
disableQuota,
|
||||
hasLoopDevices,
|
||||
resetBtrfsTestVolume,
|
||||
} from './test_volume.ts';
|
||||
export type { BtrfsTestVolume } from './test_volume.ts';
|
||||
126
src/btrfs/utils/test_volume.ts
Normal file
126
src/btrfs/utils/test_volume.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current system has loop devices available.
|
||||
* Used to skip tests when running in environments without /dev/loop-control.
|
||||
*/
|
||||
export async function hasLoopDevices(): Promise<boolean> {
|
||||
const { code } = await new Deno.Command('sh', {
|
||||
args: ['-c', 'test -e /dev/loop-control && exit 0 || exit 1'],
|
||||
}).output();
|
||||
return code === 0;
|
||||
}
|
||||
Reference in New Issue
Block a user