feat(btrfs): add CLI and mock drivers with test utilities and interfaces
This commit is contained in:
180
src/btrfs/__tests__/cli_driver.test.ts
Normal file
180
src/btrfs/__tests__/cli_driver.test.ts
Normal 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
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/btrfs/utils/mod.ts
Normal file
9
src/btrfs/utils/mod.ts
Normal 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';
|
||||||
115
src/btrfs/utils/test_volume.ts
Normal file
115
src/btrfs/utils/test_volume.ts
Normal 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`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user