Skip to content

Commit 4e2563a

Browse files
committed
btrfs: add more filesystem support
1 parent caac6dd commit 4e2563a

File tree

5 files changed

+221
-10
lines changed

5 files changed

+221
-10
lines changed

src/packages/file-server/btrfs/subvolume.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,33 @@ A subvolume
44

55
import { type Filesystem, DEFAULT_SUBVOLUME_SIZE } from "./filesystem";
66
import refCache from "@cocalc/util/refcache";
7-
import { readFile, writeFile, unlink } from "node:fs/promises";
7+
import {
8+
appendFile,
9+
chmod,
10+
cp,
11+
copyFile,
12+
link,
13+
readFile,
14+
realpath,
15+
rename,
16+
rm,
17+
rmdir,
18+
mkdir,
19+
stat,
20+
symlink,
21+
truncate,
22+
writeFile,
23+
unlink,
24+
utimes,
25+
watch,
26+
} from "node:fs/promises";
827
import { exists, isdir, listdir, mkdirp, sudo } from "./util";
928
import { join, normalize } from "path";
1029
import { updateRollingSnapshots, type SnapshotCounts } from "./snapshots";
1130
import { DirectoryListingEntry } from "@cocalc/util/types";
1231
import getListing from "@cocalc/backend/get-listing";
1332
import getLogger from "@cocalc/backend/logger";
33+
import { exists as pathExists } from "@cocalc/backend/misc/async-utils-node";
1434

1535
export const SNAPSHOTS = ".snapshots";
1636
const SEND_SNAPSHOT_PREFIX = "send-";
@@ -104,10 +124,73 @@ export class Subvolume {
104124
return await writeFile(this.normalize(path), data);
105125
};
106126

127+
appendFile = async (path: string, data: string | Buffer, encoding?) => {
128+
path = normalize(path);
129+
return await appendFile(this.normalize(path), data, encoding);
130+
};
131+
107132
unlink = async (path: string) => {
108133
await unlink(this.normalize(path));
109134
};
110135

136+
stat = async (path: string) => {
137+
return await stat(this.normalize(path));
138+
};
139+
140+
exists = async (path: string) => {
141+
return await pathExists(this.normalize(path));
142+
};
143+
144+
// hard link
145+
link = async (existingPath: string, newPath: string) => {
146+
return await link(this.normalize(existingPath), this.normalize(newPath));
147+
};
148+
149+
symlink = async (target: string, path: string) => {
150+
return await symlink(this.normalize(target), this.normalize(path));
151+
};
152+
153+
realpath = async (path: string) => {
154+
const x = await realpath(this.normalize(path));
155+
return x.slice(this.path.length + 1);
156+
};
157+
158+
rename = async (oldPath: string, newPath: string) => {
159+
await rename(this.normalize(oldPath), this.normalize(newPath));
160+
};
161+
162+
utimes = async (
163+
path: string,
164+
atime: number | string | Date,
165+
mtime: number | string | Date,
166+
) => {
167+
await utimes(this.normalize(path), atime, mtime);
168+
};
169+
170+
watch = (filename: string, options?) => {
171+
return watch(this.normalize(filename), options);
172+
};
173+
174+
truncate = async (path: string, len?: number) => {
175+
await truncate(this.normalize(path), len);
176+
};
177+
178+
copyFile = async (src: string, dest: string) => {
179+
await copyFile(this.normalize(src), this.normalize(dest));
180+
};
181+
182+
cp = async (src: string, dest: string, options?) => {
183+
await cp(this.normalize(src), this.normalize(dest), options);
184+
};
185+
186+
chmod = async (path: string, mode: string | number) => {
187+
await chmod(this.normalize(path), mode);
188+
};
189+
190+
mkdir = async (path: string, options?) => {
191+
await mkdir(this.normalize(path), options);
192+
};
193+
111194
rsync = async ({
112195
src,
113196
target,
@@ -135,6 +218,14 @@ export class Subvolume {
135218
});
136219
};
137220

221+
rmdir = async (path: string, options?) => {
222+
await rmdir(this.normalize(path), options);
223+
};
224+
225+
rm = async (path: string, options?) => {
226+
await rm(this.normalize(path), options);
227+
};
228+
138229
/////////////
139230
// QUOTA
140231
/////////////

src/packages/file-server/btrfs/test/filesystem-stress.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import { before, after, fs } from "./setup";
2-
import { writeFile } from "fs/promises";
3-
import { join } from "path";
42

53
beforeAll(before);
64

@@ -45,7 +43,7 @@ describe("stress operations with subvolumes", () => {
4543
it("write a file to each volume", async () => {
4644
for (const name of await fs.list()) {
4745
const vol = await fs.subvolume(name);
48-
await writeFile(join(vol.path, "a.txt"), "hi");
46+
await vol.writeFile("a.txt", "hi");
4947
}
5048
});
5149

src/packages/file-server/btrfs/test/filesystem.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { before, after, fs } from "./setup";
22
import { isValidUUID } from "@cocalc/util/misc";
3-
import { readFile, writeFile } from "fs/promises";
4-
import { join } from "path";
53

64
beforeAll(before);
75

@@ -63,16 +61,16 @@ describe("operations with subvolumes", () => {
6361
it("rsync an actual file", async () => {
6462
const sagemath = await fs.subvolume("sagemath");
6563
const cython = await fs.subvolume("cython");
66-
await writeFile(join(sagemath.path, "README.md"), "hi");
64+
await sagemath.writeFile("README.md", "hi");
6765
await fs.rsync({ src: "sagemath", target: "cython" });
68-
const copy = await readFile(join(cython.path, "README.md"), "utf8");
66+
const copy = await cython.readFile("README.md", "utf8");
6967
expect(copy).toEqual("hi");
7068
});
7169

7270
it("clone a subvolume with contents", async () => {
7371
await fs.cloneSubvolume("cython", "pyrex");
7472
const pyrex = await fs.subvolume("pyrex");
75-
const clone = await readFile(join(pyrex.path, "README.md"), "utf8");
73+
const clone = await pyrex.readFile("README.md", "utf8");
7674
expect(clone).toEqual("hi");
7775
});
7876
});

src/packages/file-server/btrfs/test/setup.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ async function ensureMoreLoops() {
3535
export async function before() {
3636
try {
3737
const command = `umount ${join(tmpdir(), TEMP_PREFIX)}*/mnt`;
38-
// attempt to unmount any mounts left from previous runs
38+
// attempt to unmount any mounts left from previous runs.
39+
// TODO: this could impact runs in parallel
3940
await sudo({ command, bash: true });
4041
} catch {}
4142
await ensureMoreLoops();

src/packages/file-server/btrfs/test/subvolume.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,129 @@ describe("setting and getting quota of a subvolume", () => {
4949
});
5050
});
5151

52+
describe("the filesystem operations", () => {
53+
let vol;
54+
55+
it("creates a volume and get empty listing", async () => {
56+
vol = await fs.subvolume("fs");
57+
expect(await vol.ls("")).toEqual([]);
58+
});
59+
60+
it("error listing non-existent path", async () => {
61+
vol = await fs.subvolume("fs");
62+
expect(async () => {
63+
await vol.ls("no-such-path");
64+
}).rejects.toThrow("ENOENT");
65+
});
66+
67+
it("creates a text file to it", async () => {
68+
await vol.writeFile("a.txt", "hello");
69+
const ls = await vol.ls("");
70+
expect(ls).toEqual([{ name: "a.txt", mtime: ls[0].mtime, size: 5 }]);
71+
});
72+
73+
it("read the file we just created as utf8", async () => {
74+
expect(await vol.readFile("a.txt", "utf8")).toEqual("hello");
75+
});
76+
77+
it("read the file we just created as a binary buffer", async () => {
78+
expect(await vol.readFile("a.txt")).toEqual(Buffer.from("hello"));
79+
});
80+
81+
it("stat the file we just created", async () => {
82+
const s = await vol.stat("a.txt");
83+
expect(s.size).toBe(5);
84+
expect(Math.abs(s.mtimeMs - Date.now())).toBeLessThan(60_000);
85+
});
86+
87+
let origStat;
88+
it("snapshot filesystem and see file is in snapshot", async () => {
89+
await vol.createSnapshot("snap");
90+
const s = await vol.ls(vol.snapshotPath("snap"));
91+
expect(s).toEqual([{ name: "a.txt", mtime: s[0].mtime, size: 5 }]);
92+
93+
const stat = await vol.stat("a.txt");
94+
origStat = stat;
95+
expect(stat.mtimeMs / 1000).toBeCloseTo(s[0].mtime);
96+
});
97+
98+
it("unlink (delete) our file", async () => {
99+
await vol.unlink("a.txt");
100+
expect(await vol.ls("")).toEqual([]);
101+
});
102+
103+
it("snapshot still exists", async () => {
104+
expect(await vol.exists(vol.snapshotPath("snap", "a.txt")));
105+
});
106+
107+
it("copy file from snapshot and note it has the same mode as before (so much nicer than what happens with zfs)", async () => {
108+
await vol.copyFile(vol.snapshotPath("snap", "a.txt"), "a.txt");
109+
const stat = await vol.stat("a.txt");
110+
expect(stat.mode).toEqual(origStat.mode);
111+
});
112+
113+
it("create and copy a folder", async () => {
114+
await vol.mkdir("my-folder");
115+
await vol.writeFile("my-folder/foo.txt", "foo");
116+
await vol.cp("my-folder", "folder2", { recursive: true });
117+
expect(await vol.readFile("folder2/foo.txt", "utf8")).toEqual("foo");
118+
});
119+
120+
it("append to a file", async () => {
121+
await vol.writeFile("b.txt", "hell");
122+
await vol.appendFile("b.txt", "-o");
123+
expect(await vol.readFile("b.txt", "utf8")).toEqual("hell-o");
124+
});
125+
126+
it("make a file readonly, then change it back", async () => {
127+
await vol.writeFile("c.txt", "hi");
128+
await vol.chmod("c.txt", "444");
129+
expect(async () => {
130+
await vol.appendFile("c.txt", " there");
131+
}).rejects.toThrow("EACCES");
132+
await vol.chmod("c.txt", "666");
133+
await vol.appendFile("c.txt", " there");
134+
});
135+
136+
it("realpath of a symlink", async () => {
137+
await vol.writeFile("real.txt", "i am real");
138+
await vol.symlink("real.txt", "link.txt");
139+
expect(await vol.realpath("link.txt")).toBe("real.txt");
140+
});
141+
142+
it("watch for changes", async () => {
143+
await vol.writeFile("w.txt", "hi");
144+
const ac = new AbortController();
145+
const { signal } = ac;
146+
const watcher = vol.watch("w.txt", { signal });
147+
vol.appendFile("w.txt", " there");
148+
const { value, done } = await watcher.next();
149+
expect(done).toBe(false);
150+
expect(value).toEqual({ eventType: "change", filename: "w.txt" });
151+
ac.abort();
152+
153+
expect(async () => {
154+
await watcher.next();
155+
}).rejects.toThrow("aborted");
156+
});
157+
158+
it("rename a file", async () => {
159+
await vol.writeFile("old", "hi");
160+
await vol.rename("old", "new");
161+
expect(await vol.readFile("new", "utf8")).toEqual("hi");
162+
});
163+
164+
it("create and remove a directory", async () => {
165+
await vol.mkdir("path");
166+
await vol.rmdir("path");
167+
});
168+
169+
it("create a directory recursively and remove", async () => {
170+
await vol.mkdir("path/to/stuff", { recursive: true });
171+
await vol.rm("path", { recursive: true });
172+
});
173+
});
174+
52175
describe("test snapshots", () => {
53176
let vol;
54177
it("creates a volume and write a file to it", async () => {

0 commit comments

Comments
 (0)