|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import shlex |
5 | 6 | from pathlib import Path |
6 | 7 | from typing import Literal |
7 | 8 |
|
|
18 | 19 | _APT = ( |
19 | 20 | f"{_SUDO} env DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get -o Dpkg::Use-Pty=0" |
20 | 21 | ) |
21 | | -_RCLONE_CHECK = "command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone" |
22 | | -_FUSERMOUNT_CHECK = ( |
| 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 = "__FLY_PRESENT__" |
| 30 | +_MISSING = "__FLY_MISSING__" |
| 31 | +_MOUNTED = "__FLY_MOUNTED__" |
| 32 | +_NOT_MOUNTED = "__FLY_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( |
23 | 43 | "command -v fusermount3 >/dev/null 2>&1 || command -v fusermount >/dev/null 2>&1" |
24 | 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") |
25 | 47 | _INSTALL_RCLONE_COMMANDS = ( |
26 | 48 | f"{_APT} update -qq", |
27 | 49 | f"{_APT} install -y -qq curl unzip ca-certificates fuse", |
|
41 | 63 | ) |
42 | 64 |
|
43 | 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 | + |
44 | 71 | async def _ensure_fuse_support(session: BaseSandboxSession) -> None: |
45 | | - kernel = await session.exec( |
46 | | - "sh", |
47 | | - "-lc", |
48 | | - "test -c /dev/fuse && grep -qw fuse /proc/filesystems", |
49 | | - shell=False, |
50 | | - ) |
51 | | - if not kernel.ok(): |
| 72 | + kernel = await session.exec("sh", "-lc", _FUSE_KERNEL_CHECK, shell=False) |
| 73 | + if not _stdout_says(kernel, _PRESENT): |
52 | 74 | raise MountConfigError( |
53 | 75 | message="Fly cloud bucket mounts require FUSE support in the kernel", |
54 | 76 | context={"missing": "fuse"}, |
55 | 77 | ) |
56 | 78 |
|
57 | 79 | fusermount = await session.exec("sh", "-lc", _FUSERMOUNT_CHECK, shell=False) |
58 | | - if not fusermount.ok(): |
59 | | - apt = await session.exec("sh", "-lc", "command -v apt-get >/dev/null 2>&1", shell=False) |
60 | | - if not apt.ok(): |
| 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): |
61 | 83 | raise MountConfigError( |
62 | 84 | message="fusermount is not installed and apt-get is unavailable; " |
63 | 85 | "preinstall the fuse package", |
64 | 86 | context={"package": "fuse"}, |
65 | 87 | ) |
66 | 88 | for command in _INSTALL_FUSE_COMMANDS: |
67 | | - install = await session.exec("sh", "-lc", command, shell=False, timeout=300) |
68 | | - if not install.ok(): |
69 | | - raise MountConfigError( |
70 | | - message="failed to install fuse", |
71 | | - context={"package": "fuse", "exit_code": install.exit_code}, |
72 | | - ) |
| 89 | + await session.exec("sh", "-lc", command, shell=False, timeout=300) |
73 | 90 | recheck = await session.exec("sh", "-lc", _FUSERMOUNT_CHECK, shell=False) |
74 | | - if not recheck.ok(): |
| 91 | + if not _stdout_says(recheck, _PRESENT): |
75 | 92 | raise MountConfigError( |
76 | | - message="fuse was installed but fusermount is still not on PATH", |
| 93 | + message="fuse install attempt completed but fusermount is still not on PATH", |
77 | 94 | context={"package": "fuse"}, |
78 | 95 | ) |
79 | 96 |
|
80 | | - chmod_result = await session.exec("sh", "-lc", _FUSE_ALLOW_OTHER, shell=False, timeout=30) |
81 | | - if not chmod_result.ok(): |
82 | | - raise MountConfigError( |
83 | | - message="failed to make /dev/fuse accessible", |
84 | | - context={"exit_code": chmod_result.exit_code}, |
85 | | - ) |
| 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) |
86 | 102 |
|
87 | 103 |
|
88 | 104 | async def _ensure_rclone(session: BaseSandboxSession) -> None: |
89 | 105 | rclone = await session.exec("sh", "-lc", _RCLONE_CHECK, shell=False) |
90 | | - if rclone.ok(): |
| 106 | + if _stdout_says(rclone, _PRESENT): |
91 | 107 | return |
92 | 108 |
|
93 | | - apt = await session.exec("sh", "-lc", "command -v apt-get >/dev/null 2>&1", shell=False) |
94 | | - if not apt.ok(): |
| 109 | + apt = await session.exec("sh", "-lc", _APT_CHECK, shell=False) |
| 110 | + if not _stdout_says(apt, _PRESENT): |
95 | 111 | raise MountConfigError( |
96 | 112 | message="rclone is not installed and apt-get is unavailable; preinstall rclone", |
97 | 113 | context={"package": "rclone"}, |
98 | 114 | ) |
99 | 115 |
|
100 | 116 | for command in _INSTALL_RCLONE_COMMANDS: |
101 | | - install = await session.exec("sh", "-lc", command, shell=False, timeout=300) |
102 | | - if not install.ok(): |
103 | | - raise MountConfigError( |
104 | | - message="failed to install rclone", |
105 | | - context={"package": "rclone", "exit_code": install.exit_code}, |
106 | | - ) |
| 117 | + await session.exec("sh", "-lc", command, shell=False, timeout=300) |
107 | 118 |
|
108 | 119 | rclone = await session.exec("sh", "-lc", _RCLONE_CHECK, shell=False) |
109 | | - if not rclone.ok(): |
| 120 | + if not _stdout_says(rclone, _PRESENT): |
110 | 121 | raise MountConfigError( |
111 | | - message="rclone was installed but is still not available on PATH", |
| 122 | + message="rclone install attempt completed but rclone is still not on PATH", |
112 | 123 | context={"package": "rclone"}, |
113 | 124 | ) |
114 | 125 |
|
115 | 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 | + |
116 | 154 | async def _default_user_ids(session: BaseSandboxSession) -> tuple[str, str] | None: |
117 | 155 | result = await session.exec("sh", "-lc", "id -u; id -g", shell=False, timeout=30) |
118 | 156 | if not result.ok(): |
@@ -184,7 +222,11 @@ async def activate( |
184 | 222 | await _ensure_fuse_support(session) |
185 | 223 | await _ensure_rclone(session) |
186 | 224 | delegate = await self._delegate_for_session(session) |
187 | | - return await delegate.activate(mount, session, dest, base_dir) |
| 225 | + files = await delegate.activate(mount, session, dest, base_dir) |
| 226 | + if self.pattern.mode == "fuse": |
| 227 | + mount_path = mount._resolve_mount_path(session, dest) |
| 228 | + await _verify_mount_active(session, mount_path) |
| 229 | + return files |
188 | 230 |
|
189 | 231 | async def deactivate( |
190 | 232 | self, |
|
0 commit comments