|
| 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 | +} |
0 commit comments