Skip to content

Commit 353aecd

Browse files
Merge branch 'master' into store-purchase-course
2 parents e730f95 + 654505e commit 353aecd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+3468
-5992
lines changed

src/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,10 @@
1818
"version-check": "pip3 install typing_extensions mypy || pip3 install --break-system-packages typing_extensions mypy && ./workspaces.py version-check && mypy scripts/check_npm_packages.py",
1919
"test-parallel": "unset DEBUG && pnpm run version-check && cd packages && pnpm run -r --parallel test",
2020
"test": "unset DEBUG && pnpm run depcheck && pnpm run version-check && ./workspaces.py test",
21-
"test-github-ci": "unset DEBUG && pnpm run depcheck && pnpm run version-check && ./workspaces.py test --exclude=jupyter --retries=1",
21+
"test-github-ci": "unset DEBUG && pnpm run depcheck && pnpm run version-check && ./workspaces.py test --exclude=jupyter,file-server --retries=1",
2222
"depcheck": "cd packages && pnpm run -r --parallel depcheck",
2323
"prettier-all": "cd packages/",
2424
"local-ci": "./scripts/ci.sh",
25-
"conat-server": "cd packages/server && pnpm conat-server",
2625
"conat-connections": "cd packages/backend && pnpm conat-connections",
2726
"conat-watch": "cd packages/backend && pnpm conat-watch",
2827
"conat-inventory": "cd packages/backend && pnpm conat-inventory"

src/packages/backend/get-listing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
/*
7-
Server directory listing through the HTTP server and Websocket API.
7+
This is used by backends to serve directory listings to clients:
88
99
{files:[..., {size:?,name:?,mtime:?,isdir:?}]}
1010

src/packages/backend/logger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ function initTransports() {
128128
// Similar as in debug source code, except I stuck a timestamp
129129
// at the beginning, which I like... except also aware of
130130
// non-printf formatting.
131-
const line = `${new Date().toISOString()}: ${myFormat(...args)}\n`;
131+
const line = `${new Date().toISOString()} (${process.pid}):${myFormat(...args)}\n`;
132132

133133
if (transports.console) {
134134
// the console transport:
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/*
2+
BTRFS Filesystem
3+
4+
DEVELOPMENT:
5+
6+
Start node, then:
7+
8+
DEBUG="cocalc:*file-server*" DEBUG_CONSOLE=yes node
9+
10+
a = require('@cocalc/file-server/btrfs'); fs = await a.filesystem({device:'/tmp/btrfs.img', formatIfNeeded:true, mount:'/mnt/btrfs', uid:293597964})
11+
12+
*/
13+
14+
import refCache from "@cocalc/util/refcache";
15+
import { mkdirp, btrfs, sudo } from "./util";
16+
import { join } from "path";
17+
import { Subvolumes } from "./subvolumes";
18+
import { mkdir } from "fs/promises";
19+
import { exists } from "@cocalc/backend/misc/async-utils-node";
20+
import { executeCode } from "@cocalc/backend/execute-code";
21+
22+
// default size of btrfs filesystem if creating an image file.
23+
const DEFAULT_FILESYSTEM_SIZE = "10G";
24+
25+
// default for newly created subvolumes
26+
export const DEFAULT_SUBVOLUME_SIZE = "1G";
27+
28+
const MOUNT_ERROR = "wrong fs type, bad option, bad superblock";
29+
30+
export interface Options {
31+
// the underlying block device.
32+
// If this is a file (or filename) ending in .img, then it's a sparse file mounted as a loopback device.
33+
// If this starts with "/dev" then it is a raw block device.
34+
device: string;
35+
// formatIfNeeded -- DANGEROUS! if true, format the device or image,
36+
// if it doesn't mount with an error containing "wrong fs type,
37+
// bad option, bad superblock". Never use this in production. Useful
38+
// for testing and dev.
39+
formatIfNeeded?: boolean;
40+
// where the btrfs filesystem is mounted
41+
mount: string;
42+
43+
// default size of newly created subvolumes
44+
defaultSize?: string | number;
45+
defaultFilesystemSize?: string | number;
46+
}
47+
48+
export class Filesystem {
49+
public readonly opts: Options;
50+
public readonly bup: string;
51+
public readonly subvolumes: Subvolumes;
52+
53+
constructor(opts: Options) {
54+
opts = {
55+
defaultSize: DEFAULT_SUBVOLUME_SIZE,
56+
defaultFilesystemSize: DEFAULT_FILESYSTEM_SIZE,
57+
...opts,
58+
};
59+
this.opts = opts;
60+
this.bup = join(this.opts.mount, "bup");
61+
this.subvolumes = new Subvolumes(this);
62+
}
63+
64+
init = async () => {
65+
await mkdirp([this.opts.mount]);
66+
await this.initDevice();
67+
await this.mountFilesystem();
68+
await btrfs({
69+
args: ["quota", "enable", "--simple", this.opts.mount],
70+
});
71+
await this.initBup();
72+
};
73+
74+
unmount = async () => {
75+
await sudo({
76+
command: "umount",
77+
args: [this.opts.mount],
78+
err_on_exit: true,
79+
});
80+
};
81+
82+
close = () => {};
83+
84+
private initDevice = async () => {
85+
if (!isImageFile(this.opts.device)) {
86+
// raw block device -- nothing to do
87+
return;
88+
}
89+
if (!(await exists(this.opts.device))) {
90+
await sudo({
91+
command: "truncate",
92+
args: ["-s", `${this.opts.defaultFilesystemSize}`, this.opts.device],
93+
});
94+
}
95+
};
96+
97+
info = async (): Promise<{ [field: string]: string }> => {
98+
const { stdout } = await btrfs({
99+
args: ["subvolume", "show", this.opts.mount],
100+
});
101+
const obj: { [field: string]: string } = {};
102+
for (const x of stdout.split("\n")) {
103+
const i = x.indexOf(":");
104+
if (i == -1) continue;
105+
obj[x.slice(0, i).trim()] = x.slice(i + 1).trim();
106+
}
107+
return obj;
108+
};
109+
110+
private mountFilesystem = async () => {
111+
try {
112+
await this.info();
113+
// already mounted
114+
return;
115+
} catch {}
116+
const { stderr, exit_code } = await this._mountFilesystem();
117+
if (exit_code) {
118+
if (stderr.includes(MOUNT_ERROR)) {
119+
if (this.opts.formatIfNeeded) {
120+
await this.formatDevice();
121+
const { stderr, exit_code } = await this._mountFilesystem();
122+
if (exit_code) {
123+
throw Error(stderr);
124+
} else {
125+
return;
126+
}
127+
}
128+
}
129+
throw Error(stderr);
130+
}
131+
};
132+
133+
private formatDevice = async () => {
134+
await sudo({ command: "mkfs.btrfs", args: [this.opts.device] });
135+
};
136+
137+
private _mountFilesystem = async () => {
138+
const args: string[] = isImageFile(this.opts.device) ? ["-o", "loop"] : [];
139+
args.push(
140+
"-o",
141+
"compress=zstd",
142+
"-o",
143+
"noatime",
144+
"-o",
145+
"space_cache=v2",
146+
"-o",
147+
"autodefrag",
148+
this.opts.device,
149+
"-t",
150+
"btrfs",
151+
this.opts.mount,
152+
);
153+
{
154+
const { stderr, exit_code } = await sudo({
155+
command: "mount",
156+
args,
157+
err_on_exit: false,
158+
});
159+
if (exit_code) {
160+
return { stderr, exit_code };
161+
}
162+
}
163+
const { stderr, exit_code } = await sudo({
164+
command: "chown",
165+
args: [
166+
`${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`,
167+
this.opts.mount,
168+
],
169+
err_on_exit: false,
170+
});
171+
return { stderr, exit_code };
172+
};
173+
174+
private initBup = async () => {
175+
if (!(await exists(this.bup))) {
176+
await mkdir(this.bup);
177+
}
178+
await executeCode({
179+
command: "bup",
180+
args: ["init"],
181+
env: { BUP_DIR: this.bup },
182+
});
183+
};
184+
}
185+
186+
function isImageFile(name: string) {
187+
if (name.startsWith("/dev")) {
188+
return false;
189+
}
190+
// TODO: could probably check os for a device with given name?
191+
return name.endsWith(".img");
192+
}
193+
194+
const cache = refCache<Options & { noCache?: boolean }, Filesystem>({
195+
name: "btrfs-filesystems",
196+
createObject: async (options: Options) => {
197+
const filesystem = new Filesystem(options);
198+
await filesystem.init();
199+
return filesystem;
200+
},
201+
});
202+
203+
export async function filesystem(
204+
options: Options & { noCache?: boolean },
205+
): Promise<Filesystem> {
206+
return await cache(options);
207+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { filesystem } from "./filesystem";
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { type SubvolumeSnapshots } from "./subvolume-snapshots";
2+
import getLogger from "@cocalc/backend/logger";
3+
4+
const logger = getLogger("file-server:btrfs:snapshots");
5+
6+
const DATE_REGEXP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
7+
8+
// Lengths of time in minutes to keep snapshots
9+
// (code below assumes these are listed in ORDER from shortest to longest)
10+
export const SNAPSHOT_INTERVALS_MS = {
11+
frequent: 15 * 1000 * 60,
12+
daily: 60 * 24 * 1000 * 60,
13+
weekly: 60 * 24 * 7 * 1000 * 60,
14+
monthly: 60 * 24 * 7 * 4 * 1000 * 60,
15+
};
16+
17+
// How many of each type of snapshot to retain
18+
export const DEFAULT_SNAPSHOT_COUNTS = {
19+
frequent: 24,
20+
daily: 14,
21+
weekly: 7,
22+
monthly: 4,
23+
} as SnapshotCounts;
24+
25+
export interface SnapshotCounts {
26+
frequent: number;
27+
daily: number;
28+
weekly: number;
29+
monthly: number;
30+
}
31+
32+
export async function updateRollingSnapshots({
33+
snapshots,
34+
counts,
35+
}: {
36+
snapshots: SubvolumeSnapshots;
37+
counts?: Partial<SnapshotCounts>;
38+
}) {
39+
counts = { ...DEFAULT_SNAPSHOT_COUNTS, ...counts };
40+
41+
const changed = await snapshots.hasUnsavedChanges();
42+
logger.debug("updateRollingSnapshots", {
43+
name: snapshots.subvolume.name,
44+
counts,
45+
changed,
46+
});
47+
if (!changed) {
48+
// definitely no data written since most recent snapshot, so nothing to do
49+
return;
50+
}
51+
52+
// get exactly the iso timestamp snapshot names:
53+
const snapshotNames = (await snapshots.ls())
54+
.map((x) => x.name)
55+
.filter((name) => DATE_REGEXP.test(name));
56+
snapshotNames.sort();
57+
if (snapshotNames.length > 0) {
58+
const age = Date.now() - new Date(snapshotNames.slice(-1)[0]).valueOf();
59+
for (const key in SNAPSHOT_INTERVALS_MS) {
60+
if (counts[key]) {
61+
if (age < SNAPSHOT_INTERVALS_MS[key]) {
62+
// no need to snapshot since there is already a sufficiently recent snapshot
63+
logger.debug("updateRollingSnapshots: no need to snapshot", {
64+
name: snapshots.subvolume.name,
65+
});
66+
return;
67+
}
68+
// counts[key] nonzero and snapshot is old enough so we'll be making a snapshot
69+
break;
70+
}
71+
}
72+
}
73+
74+
// make a new snapshot
75+
const name = new Date().toISOString();
76+
await snapshots.create(name);
77+
// delete extra snapshots
78+
snapshotNames.push(name);
79+
const toDelete = snapshotsToDelete({ counts, snapshots: snapshotNames });
80+
for (const expired of toDelete) {
81+
try {
82+
await snapshots.delete(expired);
83+
} catch {
84+
// some snapshots can't be deleted, e.g., they were used for the last send.
85+
}
86+
}
87+
}
88+
89+
function snapshotsToDelete({ counts, snapshots }): string[] {
90+
if (snapshots.length == 0) {
91+
// nothing to do
92+
return [];
93+
}
94+
95+
// sorted from BIGGEST to smallest
96+
const times = snapshots.map((x) => new Date(x).valueOf());
97+
times.reverse();
98+
const save = new Set<number>();
99+
for (const type in counts) {
100+
const count = counts[type];
101+
const length_ms = SNAPSHOT_INTERVALS_MS[type];
102+
103+
// Pick the first count newest snapshots at intervals of length
104+
// length_ms milliseconds.
105+
let n = 0,
106+
i = 0,
107+
last_tm = 0;
108+
while (n < count && i < times.length) {
109+
const tm = times[i];
110+
if (!last_tm || tm <= last_tm - length_ms) {
111+
save.add(tm);
112+
last_tm = tm;
113+
n += 1; // found one more
114+
}
115+
i += 1; // move to next snapshot
116+
}
117+
}
118+
return snapshots.filter((x) => !save.has(new Date(x).valueOf()));
119+
}

0 commit comments

Comments
 (0)