Skip to content

Commit 902230e

Browse files
committed
project-runner: implement overlay so that rootfs changes persist
1 parent 916b36f commit 902230e

File tree

3 files changed

+160
-3
lines changed

3 files changed

+160
-3
lines changed

src/packages/project-runner/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"depcheck": "pnpx depcheck",
1313
"clean": "rm -rf node_modules dist",
1414
"start": "pnpm exec cocalc-project-runner",
15+
"build-all": "pnpm build && pnpm build-tarball && pnpm build-sea",
1516
"build-tarball": "cd sea && ./build-tarball.sh",
1617
"build-sea": "cd sea && ./build-sea.sh"
1718
},
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { join } from "path";
2+
import { data } from "@cocalc/backend/data";
3+
import { exists } from "@cocalc/backend/misc/async-utils-node";
4+
import { executeCode } from "@cocalc/backend/execute-code";
5+
import { mkdir, rm, writeFile } from "fs/promises";
6+
import { type Configuration } from "./types";
7+
import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
8+
import { replace_all } from "@cocalc/util/misc";
9+
10+
const DEFAULT_IMAGE = "ubuntu:25.04";
11+
12+
const IMAGE_CACHE =
13+
process.env.COCALC_IMAGE_CACHE ?? join(data, "cache", "images");
14+
const PROJECT_ROOTS =
15+
process.env.COCALC_PROJECT_ROOTS ?? join(data, "cache", "project-roots");
16+
17+
export const extractBaseImage = reuseInFlight(async (image: string) => {
18+
const baseImagePath = join(IMAGE_CACHE, image);
19+
const okFile = baseImagePath + ".ok";
20+
if (await exists(okFile)) {
21+
// already exist
22+
return baseImagePath;
23+
}
24+
// pull it -- this takes most of the time.
25+
// It is also important to do this before the unshare below,
26+
// since doing it inside the unshare hits namespace issues.
27+
await executeCode({
28+
timeout: 60 * 60, // in seconds
29+
err_on_exit: true,
30+
command: "podman",
31+
args: [
32+
// ignore_chown_errors=true is needed since otherwise we
33+
// have to make changes to the host system to allow more
34+
// uid's, etc. for complicated images (e.g., sage);
35+
// this is fine since we run everything as root anyways.
36+
"--storage-opt",
37+
"ignore_chown_errors=true",
38+
"pull",
39+
image,
40+
],
41+
});
42+
// TODO: an optimization on COW filesystem if we pull one image
43+
// then pull another with a different tag, would be to start by
44+
// initializing the target path using COW, then 'rsync ... --delete'
45+
// to transform it to the result. This could MASSIVELY save space.
46+
47+
// extract the image
48+
try {
49+
await executeCode({
50+
timeout: 60 * 60, // timeout in seconds
51+
err_on_exit: true,
52+
command: "podman",
53+
args: [
54+
"unshare",
55+
"bash",
56+
"-c",
57+
`
58+
set -ev
59+
mnt="$(podman image mount ${image})"
60+
echo "mounted at: $mnt"
61+
mkdir -p "${baseImagePath}"
62+
rsync -aHx --numeric-ids --delete "$mnt"/ "${baseImagePath}"/
63+
podman image unmount ${image}
64+
`,
65+
],
66+
});
67+
} catch (err) {
68+
// fail -- clean up the mess (hopefully)
69+
try {
70+
await rm(baseImagePath, { force: true, recursive: true, maxRetries: 3 });
71+
await executeCode({ command: "podman", args: ["image", "rm", image] });
72+
} catch {}
73+
throw err;
74+
}
75+
// success!
76+
await writeFile(okFile, "");
77+
// remove the image to save space, in case it isn't used by
78+
// anything else. we will not need it again, since we already
79+
// have a copy of it.
80+
await executeCode({ command: "podman", args: ["image", "rm", image] });
81+
return baseImagePath;
82+
});
83+
84+
function getMergedPath(project_id) {
85+
return join(PROJECT_ROOTS, project_id);
86+
}
87+
88+
function getPaths({ home, image, project_id }) {
89+
const userOverlays = join(home, ".overlay", image);
90+
const upper = join(userOverlays, "upper");
91+
const workdir = join(userOverlays, "workdir");
92+
const merged = getMergedPath(project_id);
93+
return { upper, workdir, merged };
94+
}
95+
96+
function getImage(config) {
97+
return config?.image ?? DEFAULT_IMAGE;
98+
}
99+
100+
export async function mount({
101+
project_id,
102+
home,
103+
config,
104+
}: {
105+
project_id: string;
106+
home: string;
107+
config?: Configuration;
108+
}) {
109+
const image = getImage(config);
110+
const lower = await extractBaseImage(image);
111+
const { upper, workdir, merged } = getPaths({ home, image, project_id });
112+
await mkdir(upper, { recursive: true });
113+
await mkdir(workdir, { recursive: true });
114+
await mkdir(merged, { recursive: true });
115+
116+
await mountOverlayFs({ lower, upper, workdir, merged });
117+
118+
return merged;
119+
}
120+
121+
/*
122+
This would go in sudo for the user to allow just this:
123+
124+
wstein ALL=(ALL) NOPASSWD: /bin/mount -t overlay *, /bin/umount *
125+
*/
126+
127+
export async function unmount(project_id: string) {
128+
const mountpoint = getMergedPath(project_id);
129+
await executeCode({
130+
err_on_exit: true,
131+
command: "sudo",
132+
args: ["umount", mountpoint],
133+
});
134+
}
135+
136+
function escape(path) {
137+
return replace_all(path, ":", `\\:`);
138+
}
139+
140+
async function mountOverlayFs({ upper, workdir, merged, lower }) {
141+
await executeCode({
142+
err_on_exit: true,
143+
command: "sudo",
144+
args: [
145+
"mount",
146+
"-t",
147+
"overlay",
148+
"overlay",
149+
"-o",
150+
`lowerdir=${escape(lower)},upperdir=${escape(upper)},workdir=${escape(workdir)}`,
151+
merged,
152+
],
153+
});
154+
}

src/packages/project-runner/run/podman.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,13 @@ import { getCoCalcMounts, COCALC_SRC } from "./mounts";
1515
import { mountHome, setQuota } from "./filesystem";
1616
import { executeCode } from "@cocalc/backend/execute-code";
1717
import { join } from "path";
18+
import * as rootFilesystem from "./overlay";
1819

1920
const logger = getLogger("project-runner:podman");
2021
const children: { [project_id: string]: any } = {};
2122

2223
const GRACE_PERIOD = 2000;
2324

24-
const DEFAULT_IMAGE = "ubuntu:25.04";
25-
2625
export async function start({
2726
project_id,
2827
config,
@@ -40,6 +39,7 @@ export async function start({
4039
}
4140

4241
const home = await mountHome(project_id);
42+
const rootfs = await rootFilesystem.mount({ project_id, home, config });
4343
await mkdir(home, { recursive: true });
4444
await ensureConfFilesExists(home);
4545
const env = getEnvironment({
@@ -60,6 +60,7 @@ export async function start({
6060
}
6161

6262
const args: string[] = ["run", "--rm", "--network=host", "--user=0:0"];
63+
6364
const cmd = "podman";
6465
const script = join(COCALC_SRC, "/packages/project/bin/cocalc-project.js");
6566

@@ -75,7 +76,7 @@ export async function start({
7576
args.push("-e", `${name}=${env[name]}`);
7677
}
7778

78-
args.push(config?.image ?? DEFAULT_IMAGE);
79+
args.push("--rootfs", rootfs);
7980
args.push(nodePath);
8081
args.push(script, "--init", "project_init.sh");
8182

@@ -110,6 +111,7 @@ export async function stop({ project_id }) {
110111
]);
111112
} catch {}
112113
delete children[project_id];
114+
await rootFilesystem.unmount(project_id);
113115
}
114116
}
115117

0 commit comments

Comments
 (0)