|
| 1 | +"""Mount strategy for Sprites sandboxes.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import shlex |
| 6 | +from pathlib import Path |
| 7 | +from typing import Literal |
| 8 | + |
| 9 | +from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase |
| 10 | +from ....sandbox.entries.mounts.patterns import RcloneMountPattern |
| 11 | +from ....sandbox.errors import MountConfigError |
| 12 | +from ....sandbox.materialization import MaterializedFile |
| 13 | +from ....sandbox.session.base_sandbox_session import BaseSandboxSession |
| 14 | + |
| 15 | +# Sprite VMs run as the unprivileged ``sprite`` user with passwordless sudo. |
| 16 | +# ``SpritesSandboxSession.exec`` rejects ``user=`` kwargs, so we prefix privileged |
| 17 | +# commands with ``sudo -n`` instead of escalating through the framework. |
| 18 | +_SUDO = "sudo -n" |
| 19 | +_APT = ( |
| 20 | + f"{_SUDO} env DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get -o Dpkg::Use-Pty=0" |
| 21 | +) |
| 22 | + |
| 23 | +# Detection commands echo a sentinel into stdout based on the *local* shell's |
| 24 | +# evaluation of the conditional. We rely on stdout instead of ``ExecResult.ok()`` |
| 25 | +# because the sprite-env WS control protocol currently drops exec exit codes |
| 26 | +# (the OP_COMPLETE envelope ships ``{"ok": true}`` with no exit-code field, so |
| 27 | +# the Python client defaults to 0 for every command). Stdout sentinels are |
| 28 | +# also more robust against tools that exit non-zero on benign warnings. |
| 29 | +_PRESENT = "__SPRITES_PRESENT__" |
| 30 | +_MISSING = "__SPRITES_MISSING__" |
| 31 | +_MOUNTED = "__SPRITES_MOUNTED__" |
| 32 | +_NOT_MOUNTED = "__SPRITES_NOT_MOUNTED__" |
| 33 | + |
| 34 | + |
| 35 | +def _detect_cmd(condition: str) -> str: |
| 36 | + """Return a shell snippet that prints _PRESENT or _MISSING based on `condition`.""" |
| 37 | + |
| 38 | + return f"if {condition}; then echo {_PRESENT}; else echo {_MISSING}; fi" |
| 39 | + |
| 40 | + |
| 41 | +_RCLONE_CHECK = _detect_cmd("command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone") |
| 42 | +_FUSERMOUNT_CHECK = _detect_cmd( |
| 43 | + "command -v fusermount3 >/dev/null 2>&1 || command -v fusermount >/dev/null 2>&1" |
| 44 | +) |
| 45 | +_FUSE_KERNEL_CHECK = _detect_cmd("test -c /dev/fuse && grep -qw fuse /proc/filesystems") |
| 46 | +_APT_CHECK = _detect_cmd("command -v apt-get >/dev/null 2>&1") |
| 47 | +_INSTALL_RCLONE_COMMANDS = ( |
| 48 | + f"{_APT} update -qq", |
| 49 | + f"{_APT} install -y -qq curl unzip ca-certificates fuse", |
| 50 | + f"curl -fsSL https://rclone.org/install.sh | {_SUDO} bash", |
| 51 | +) |
| 52 | +# fuse package brings ``fusermount`` along — install it together with rclone |
| 53 | +# so the FUSE-mode mount path works out-of-the-box on stock sprite images. |
| 54 | +_INSTALL_FUSE_COMMANDS = ( |
| 55 | + f"{_APT} update -qq", |
| 56 | + f"{_APT} install -y -qq fuse", |
| 57 | +) |
| 58 | +_FUSE_ALLOW_OTHER = ( |
| 59 | + f"{_SUDO} chmod a+rw /dev/fuse && " |
| 60 | + f"{_SUDO} touch /etc/fuse.conf && " |
| 61 | + "(grep -qxF user_allow_other /etc/fuse.conf || " |
| 62 | + f"printf '\\nuser_allow_other\\n' | {_SUDO} tee -a /etc/fuse.conf >/dev/null)" |
| 63 | +) |
| 64 | + |
| 65 | + |
| 66 | +def _stdout_says(result: object, sentinel: str) -> bool: |
| 67 | + stdout = getattr(result, "stdout", b"") or b"" |
| 68 | + return sentinel.encode("ascii") in stdout |
| 69 | + |
| 70 | + |
| 71 | +async def _ensure_fuse_support(session: BaseSandboxSession) -> None: |
| 72 | + kernel = await session.exec("sh", "-lc", _FUSE_KERNEL_CHECK, shell=False) |
| 73 | + if not _stdout_says(kernel, _PRESENT): |
| 74 | + raise MountConfigError( |
| 75 | + message="Sprites cloud bucket mounts require FUSE support in the kernel", |
| 76 | + context={"missing": "fuse"}, |
| 77 | + ) |
| 78 | + |
| 79 | + fusermount = await session.exec("sh", "-lc", _FUSERMOUNT_CHECK, shell=False) |
| 80 | + if not _stdout_says(fusermount, _PRESENT): |
| 81 | + apt = await session.exec("sh", "-lc", _APT_CHECK, shell=False) |
| 82 | + if not _stdout_says(apt, _PRESENT): |
| 83 | + raise MountConfigError( |
| 84 | + message="fusermount is not installed and apt-get is unavailable; " |
| 85 | + "preinstall the fuse package", |
| 86 | + context={"package": "fuse"}, |
| 87 | + ) |
| 88 | + for command in _INSTALL_FUSE_COMMANDS: |
| 89 | + await session.exec("sh", "-lc", command, shell=False, timeout=300) |
| 90 | + recheck = await session.exec("sh", "-lc", _FUSERMOUNT_CHECK, shell=False) |
| 91 | + if not _stdout_says(recheck, _PRESENT): |
| 92 | + raise MountConfigError( |
| 93 | + message="fuse install attempt completed but fusermount is still not on PATH", |
| 94 | + context={"package": "fuse"}, |
| 95 | + ) |
| 96 | + |
| 97 | + # /dev/fuse must be accessible to the unprivileged user and ``user_allow_other`` |
| 98 | + # has to be enabled for ``--allow-other``. Failures here would be surfaced by |
| 99 | + # the rclone mount itself; we don't gate on this exec's exit code because the |
| 100 | + # control-WS protocol drops it. |
| 101 | + await session.exec("sh", "-lc", _FUSE_ALLOW_OTHER, shell=False, timeout=30) |
| 102 | + |
| 103 | + |
| 104 | +async def _ensure_rclone(session: BaseSandboxSession) -> None: |
| 105 | + rclone = await session.exec("sh", "-lc", _RCLONE_CHECK, shell=False) |
| 106 | + if _stdout_says(rclone, _PRESENT): |
| 107 | + return |
| 108 | + |
| 109 | + apt = await session.exec("sh", "-lc", _APT_CHECK, shell=False) |
| 110 | + if not _stdout_says(apt, _PRESENT): |
| 111 | + raise MountConfigError( |
| 112 | + message="rclone is not installed and apt-get is unavailable; preinstall rclone", |
| 113 | + context={"package": "rclone"}, |
| 114 | + ) |
| 115 | + |
| 116 | + for command in _INSTALL_RCLONE_COMMANDS: |
| 117 | + await session.exec("sh", "-lc", command, shell=False, timeout=300) |
| 118 | + |
| 119 | + rclone = await session.exec("sh", "-lc", _RCLONE_CHECK, shell=False) |
| 120 | + if not _stdout_says(rclone, _PRESENT): |
| 121 | + raise MountConfigError( |
| 122 | + message="rclone install attempt completed but rclone is still not on PATH", |
| 123 | + context={"package": "rclone"}, |
| 124 | + ) |
| 125 | + |
| 126 | + |
| 127 | +async def _verify_mount_active(session: BaseSandboxSession, mount_path: Path) -> None: |
| 128 | + """Confirm ``mount_path`` is a live mountpoint after activation. |
| 129 | +
|
| 130 | + Without reliable exit codes from the platform we can't detect a failed |
| 131 | + rclone mount via ``rclone mount``'s return value. Probe the kernel's view |
| 132 | + of the path instead: ``mountpoint -q`` returns 0 iff the path is a mount |
| 133 | + boundary. The shell wraps the conditional and emits a stdout sentinel so |
| 134 | + the verification is transport-independent. ``rclone mount --daemon`` forks |
| 135 | + and the parent returns immediately, so we poll briefly to give the daemon |
| 136 | + time to bind. |
| 137 | + """ |
| 138 | + |
| 139 | + quoted = shlex.quote(str(mount_path)) |
| 140 | + probe_cmd = ( |
| 141 | + f"for _ in 1 2 3 4 5 6 7 8 9 10; do " |
| 142 | + f"if mountpoint -q {quoted}; then echo {_MOUNTED}; exit 0; fi; " |
| 143 | + "sleep 0.5; " |
| 144 | + f"done; echo {_NOT_MOUNTED}" |
| 145 | + ) |
| 146 | + probe = await session.exec("sh", "-lc", probe_cmd, shell=False, timeout=30) |
| 147 | + if not _stdout_says(probe, _MOUNTED): |
| 148 | + raise MountConfigError( |
| 149 | + message="rclone mount completed but the path is not a live mountpoint", |
| 150 | + context={"path": str(mount_path)}, |
| 151 | + ) |
| 152 | + |
| 153 | + # Force rclone to materialize the root directory listing before we hand |
| 154 | + # control back to the caller. Without this, the next ``readdir`` from the |
| 155 | + # agent races the daemon's first listing fetch and can briefly observe an |
| 156 | + # empty directory. The exit code is irrelevant here — we just want the |
| 157 | + # side effect of priming rclone's dir cache. |
| 158 | + await session.exec("sh", "-lc", f"ls {quoted} >/dev/null 2>&1", shell=False, timeout=15) |
| 159 | + |
| 160 | + |
| 161 | +async def _default_user_ids(session: BaseSandboxSession) -> tuple[str, str] | None: |
| 162 | + result = await session.exec("sh", "-lc", "id -u; id -g", shell=False, timeout=30) |
| 163 | + if not result.ok(): |
| 164 | + return None |
| 165 | + |
| 166 | + lines = result.stdout.decode("utf-8", errors="replace").splitlines() |
| 167 | + if len(lines) < 2 or not lines[0].isdigit() or not lines[1].isdigit(): |
| 168 | + return None |
| 169 | + return lines[0], lines[1] |
| 170 | + |
| 171 | + |
| 172 | +def _append_option(args: list[str], option: str, *values: str) -> None: |
| 173 | + if option not in args: |
| 174 | + args.extend([option, *values]) |
| 175 | + |
| 176 | + |
| 177 | +async def _rclone_pattern_for_session( |
| 178 | + session: BaseSandboxSession, |
| 179 | + pattern: RcloneMountPattern, |
| 180 | +) -> RcloneMountPattern: |
| 181 | + if pattern.mode != "fuse": |
| 182 | + return pattern |
| 183 | + |
| 184 | + extra_args = list(pattern.extra_args) |
| 185 | + _append_option(extra_args, "--allow-other") |
| 186 | + user_ids = await _default_user_ids(session) |
| 187 | + if user_ids is not None: |
| 188 | + uid, gid = user_ids |
| 189 | + _append_option(extra_args, "--uid", uid) |
| 190 | + _append_option(extra_args, "--gid", gid) |
| 191 | + |
| 192 | + return pattern.model_copy(update={"extra_args": extra_args}) |
| 193 | + |
| 194 | + |
| 195 | +def _assert_sprites_session(session: BaseSandboxSession) -> None: |
| 196 | + if type(session).__name__ != "SpritesSandboxSession": |
| 197 | + raise MountConfigError( |
| 198 | + message="sprites cloud bucket mounts require a SpritesSandboxSession", |
| 199 | + context={"session_type": type(session).__name__}, |
| 200 | + ) |
| 201 | + |
| 202 | + |
| 203 | +class SpritesCloudBucketMountStrategy(MountStrategyBase): |
| 204 | + """Mount rclone-backed cloud storage in Sprites sandboxes.""" |
| 205 | + |
| 206 | + type: Literal["sprites_cloud_bucket"] = "sprites_cloud_bucket" |
| 207 | + pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse") |
| 208 | + |
| 209 | + def _delegate(self) -> InContainerMountStrategy: |
| 210 | + return InContainerMountStrategy(pattern=self.pattern) |
| 211 | + |
| 212 | + async def _delegate_for_session(self, session: BaseSandboxSession) -> InContainerMountStrategy: |
| 213 | + return InContainerMountStrategy( |
| 214 | + pattern=await _rclone_pattern_for_session(session, self.pattern) |
| 215 | + ) |
| 216 | + |
| 217 | + def validate_mount(self, mount: Mount) -> None: |
| 218 | + self._delegate().validate_mount(mount) |
| 219 | + |
| 220 | + async def activate( |
| 221 | + self, |
| 222 | + mount: Mount, |
| 223 | + session: BaseSandboxSession, |
| 224 | + dest: Path, |
| 225 | + base_dir: Path, |
| 226 | + ) -> list[MaterializedFile]: |
| 227 | + _assert_sprites_session(session) |
| 228 | + if self.pattern.mode == "fuse": |
| 229 | + await _ensure_fuse_support(session) |
| 230 | + await _ensure_rclone(session) |
| 231 | + delegate = await self._delegate_for_session(session) |
| 232 | + files = await delegate.activate(mount, session, dest, base_dir) |
| 233 | + if self.pattern.mode == "fuse": |
| 234 | + mount_path = mount._resolve_mount_path(session, dest) |
| 235 | + await _verify_mount_active(session, mount_path) |
| 236 | + return files |
| 237 | + |
| 238 | + async def deactivate( |
| 239 | + self, |
| 240 | + mount: Mount, |
| 241 | + session: BaseSandboxSession, |
| 242 | + dest: Path, |
| 243 | + base_dir: Path, |
| 244 | + ) -> None: |
| 245 | + _assert_sprites_session(session) |
| 246 | + await self._delegate().deactivate(mount, session, dest, base_dir) |
| 247 | + |
| 248 | + async def teardown_for_snapshot( |
| 249 | + self, |
| 250 | + mount: Mount, |
| 251 | + session: BaseSandboxSession, |
| 252 | + path: Path, |
| 253 | + ) -> None: |
| 254 | + _assert_sprites_session(session) |
| 255 | + await self._delegate().teardown_for_snapshot(mount, session, path) |
| 256 | + |
| 257 | + async def restore_after_snapshot( |
| 258 | + self, |
| 259 | + mount: Mount, |
| 260 | + session: BaseSandboxSession, |
| 261 | + path: Path, |
| 262 | + ) -> None: |
| 263 | + _assert_sprites_session(session) |
| 264 | + if self.pattern.mode == "fuse": |
| 265 | + await _ensure_fuse_support(session) |
| 266 | + await _ensure_rclone(session) |
| 267 | + delegate = await self._delegate_for_session(session) |
| 268 | + await delegate.restore_after_snapshot(mount, session, path) |
| 269 | + |
| 270 | + def build_docker_volume_driver_config( |
| 271 | + self, |
| 272 | + mount: Mount, |
| 273 | + ) -> tuple[str, dict[str, str], bool] | None: |
| 274 | + return None |
| 275 | + |
| 276 | + |
| 277 | +__all__ = [ |
| 278 | + "SpritesCloudBucketMountStrategy", |
| 279 | +] |
0 commit comments