fcloud-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fcloud/SKILL.md +1185 -0
- fcloud/__init__.py +85 -0
- fcloud/__main__.py +150 -0
- fcloud/_direct_bridge.py +1645 -0
- fcloud/_legacy_env.py +14 -0
- fcloud/cli/__init__.py +23 -0
- fcloud/cli/attach.py +180 -0
- fcloud/cli/common.py +382 -0
- fcloud/cli/console.py +227 -0
- fcloud/cli/context.py +161 -0
- fcloud/cli/exec_cmd.py +201 -0
- fcloud/cli/files.py +280 -0
- fcloud/cli/hardware.py +83 -0
- fcloud/cli/help.py +150 -0
- fcloud/cli/interactive.py +104 -0
- fcloud/cli/job.py +300 -0
- fcloud/cli/main.py +140 -0
- fcloud/cli/migration.py +276 -0
- fcloud/cli/mount.py +150 -0
- fcloud/cli/output.py +17 -0
- fcloud/cli/processes.py +360 -0
- fcloud/cli/registry.py +28 -0
- fcloud/cli/run.py +318 -0
- fcloud/cli/sessions.py +592 -0
- fcloud/cli/setup.py +44 -0
- fcloud/cli/ssh.py +255 -0
- fcloud/cli/sweep.py +578 -0
- fcloud/cli/sweep_harvest.py +187 -0
- fcloud/cli/sweep_watch.py +104 -0
- fcloud/cli/volume.py +317 -0
- fcloud/cli/wait.py +314 -0
- fcloud/cli_args.py +348 -0
- fcloud/client.py +632 -0
- fcloud/client_projects.py +52 -0
- fcloud/client_sessions.py +111 -0
- fcloud/client_volumes.py +80 -0
- fcloud/config.py +404 -0
- fcloud/direct.py +535 -0
- fcloud/errors.py +102 -0
- fcloud/fileset.py +155 -0
- fcloud/image.py +483 -0
- fcloud/job.py +249 -0
- fcloud/providers/__init__.py +5 -0
- fcloud/providers/requests_http.py +59 -0
- fcloud/py.typed +0 -0
- fcloud/session.py +442 -0
- fcloud/setup_cmd.py +213 -0
- fcloud/shell.py +558 -0
- fcloud/sweeps.py +557 -0
- fcloud/tunnel.py +251 -0
- fcloud/types.py +302 -0
- fcloud/v2_connect.py +928 -0
- fcloud/version.py +33 -0
- fcloud/volume_wait.py +131 -0
- fcloud/volumes.py +204 -0
- fcloud_sdk-0.1.0.dist-info/METADATA +234 -0
- fcloud_sdk-0.1.0.dist-info/RECORD +63 -0
- fcloud_sdk-0.1.0.dist-info/WHEEL +5 -0
- fcloud_sdk-0.1.0.dist-info/entry_points.txt +3 -0
- fcloud_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
- fcloud_sdk-0.1.0.dist-info/licenses/NOTICE +4 -0
- fcloud_sdk-0.1.0.dist-info/top_level.txt +2 -0
- foom/__init__.py +36 -0
fcloud/cli/migration.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Riding a spot migration: resume a foreground process on its new host.
|
|
2
|
+
|
|
3
|
+
Reconnect UX, the restore budget, the resumed-process drain, and the
|
|
4
|
+
log-follow reconnect/tail-delta helpers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from fcloud.cli.attach import (
|
|
12
|
+
_ENDED_SESSION_STATES,
|
|
13
|
+
_RESUME_BUDGET_S,
|
|
14
|
+
_TERMINAL_SESSION_STATES,
|
|
15
|
+
_authoritative_session_status,
|
|
16
|
+
)
|
|
17
|
+
from fcloud.client import Client, FcloudError, PaymentOverdueError
|
|
18
|
+
from fcloud.types import ProcessStatus
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _migration_ui():
|
|
22
|
+
"""User-facing reconnect notice callbacks, shared by every CLI path that
|
|
23
|
+
rides a connection drop (``logs --follow``, ``exec``) so the message is
|
|
24
|
+
identical everywhere. Returns ``(on_migrating, on_reconnected)`` to hand to
|
|
25
|
+
``Client.reconnect_session``.
|
|
26
|
+
|
|
27
|
+
Whether the drop is an actual host migration is only known after the
|
|
28
|
+
re-attach (via the ``migrated`` flag), so the up-front notice stays
|
|
29
|
+
neutral: a data-plane flap on a healthy host must not be narrated as
|
|
30
|
+
infra churn.
|
|
31
|
+
"""
|
|
32
|
+
def on_migrating() -> None:
|
|
33
|
+
sys.stderr.write(
|
|
34
|
+
"\n\033[33m⏳ connection interrupted — reconnecting, "
|
|
35
|
+
"no action needed.\033[0m\n")
|
|
36
|
+
sys.stderr.flush()
|
|
37
|
+
|
|
38
|
+
def on_reconnected(host_id: str, migrated: bool) -> None:
|
|
39
|
+
where = (f"resumed on a new host ({host_id})" if migrated
|
|
40
|
+
else f"same host ({host_id})")
|
|
41
|
+
sys.stderr.write(
|
|
42
|
+
f"\033[32m✅ reconnected — {where}. Continuing.\033[0m\n")
|
|
43
|
+
sys.stderr.flush()
|
|
44
|
+
|
|
45
|
+
return on_migrating, on_reconnected
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ride_budget(wait_deadline: float | None) -> float:
|
|
49
|
+
"""Remaining reconnect budget for one ride step.
|
|
50
|
+
|
|
51
|
+
The caller's --wait deadline bounds the default restore budget: a user who
|
|
52
|
+
asked for --wait 5m must not be blocked for the full 10-minute restore
|
|
53
|
+
window (that once held --wait 5m callers for 5-10 minutes). No deadline (wait_s=0,
|
|
54
|
+
or fcloud run's fixed in-run timeout) keeps the full budget.
|
|
55
|
+
"""
|
|
56
|
+
if wait_deadline is None:
|
|
57
|
+
return _RESUME_BUDGET_S
|
|
58
|
+
return max(0.0, min(wait_deadline - time.monotonic(), _RESUME_BUDGET_S))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _still_migrating(result, sid: str, pid: str):
|
|
62
|
+
"""Shape a ride that ran out of budget: the restore may still land, so
|
|
63
|
+
don't claim success or death — status "migrating" (exit 75, retryable)
|
|
64
|
+
lets JSON callers tell a ride-in-progress from a live process."""
|
|
65
|
+
result.status = "migrating"
|
|
66
|
+
result.returncode = None
|
|
67
|
+
result.message = (
|
|
68
|
+
f"Session {sid} is still being restored. Follow it with: "
|
|
69
|
+
f"fcloud logs {sid} {pid} --follow")
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _ride_migration(client: Client, result, on_output,
|
|
74
|
+
wait_deadline: float | None = None):
|
|
75
|
+
"""Ride a spot migration for a foreground run/exec so the user's command
|
|
76
|
+
resumes in place instead of falsely dying or hanging.
|
|
77
|
+
|
|
78
|
+
The bridge returns status="host_lost" when the host vanished with no
|
|
79
|
+
teardown notice — a checkpoint/restore migration is (likely) in flight.
|
|
80
|
+
Confirm with the dispatcher that the session is genuinely coming back
|
|
81
|
+
(NEVER resurrect one that was stopped/suspended/killed), wait for the
|
|
82
|
+
restore, re-attach to the SAME process, and stream it to completion. Every
|
|
83
|
+
other result passes through untouched. ``wait_deadline`` (monotonic) is
|
|
84
|
+
the caller's --wait budget; the ride never outlives it.
|
|
85
|
+
"""
|
|
86
|
+
if result is None or result.status != "host_lost":
|
|
87
|
+
return result
|
|
88
|
+
sid, pid = result.session_id, result.process_id
|
|
89
|
+
if _authoritative_session_status(client, sid) in _TERMINAL_SESSION_STATES:
|
|
90
|
+
# Ended by stop/suspension/failure — resuming would rebuild a host for
|
|
91
|
+
# a deliberately-killed session. Report a non-success terminal so the
|
|
92
|
+
# caller never reads it as exit 0.
|
|
93
|
+
return _host_lost_terminal(
|
|
94
|
+
result, f"Session {sid} ended; not resuming. "
|
|
95
|
+
f"Captured output: fcloud logs {sid} {pid} --output all")
|
|
96
|
+
on_migrating, on_reconnected = _migration_ui()
|
|
97
|
+
try:
|
|
98
|
+
client.reconnect_session(
|
|
99
|
+
sid, on_migrating=on_migrating, on_reconnected=on_reconnected,
|
|
100
|
+
budget=_ride_budget(wait_deadline),
|
|
101
|
+
)
|
|
102
|
+
except PaymentOverdueError:
|
|
103
|
+
return _host_lost_terminal(
|
|
104
|
+
result, f"Session {sid} suspended (payment overdue); not resuming.")
|
|
105
|
+
except FcloudError:
|
|
106
|
+
# Terminal status surfaced during re-attach (closed/failed).
|
|
107
|
+
return _host_lost_terminal(
|
|
108
|
+
result, f"Session {sid} could not be restored; "
|
|
109
|
+
"the process did not survive.")
|
|
110
|
+
except Exception:
|
|
111
|
+
return _still_migrating(result, sid, pid)
|
|
112
|
+
return _drain_resumed_process(
|
|
113
|
+
client, sid, pid, on_output, result, wait_deadline)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _host_lost_terminal(result, message: str):
|
|
117
|
+
"""Shape a lost-host result as a non-success terminal (exit 143)."""
|
|
118
|
+
result.status = "host_terminated"
|
|
119
|
+
result.returncode = None
|
|
120
|
+
result.message = message
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _rebuild_restart_mode() -> str:
|
|
125
|
+
"""FCLOUD_MIGRATE_RESTART: "auto" (default) re-executes a workload whose
|
|
126
|
+
process was lost with its host on a plain rebuild; "never" fails fast
|
|
127
|
+
with a distinct host_lost result instead (for non-idempotent scripts)."""
|
|
128
|
+
return os.environ.get("FCLOUD_MIGRATE_RESTART", "auto").strip().lower()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _maybe_respawn_after_rebuild(client: Client, sid: str, pid: str):
|
|
132
|
+
"""After a ride reconnects, the process may simply not exist anymore: a
|
|
133
|
+
plain REBUILD restores the session's files, not its processes (a true
|
|
134
|
+
checkpoint-restore resumes them; the client used to print "reconnected"
|
|
135
|
+
and poll the corpse pid for its whole budget).
|
|
136
|
+
The durable index says "lost_host" for exactly this case. Returns
|
|
137
|
+
``(new_process, refused)``: a re-executed process (auto mode), or
|
|
138
|
+
``(None, True)`` when the record is lost but a restart is disabled or
|
|
139
|
+
impossible. ``(None, False)`` means the record is fine — keep polling.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
log = client.session_process_logs(sid, pid)
|
|
143
|
+
except Exception:
|
|
144
|
+
return None, False
|
|
145
|
+
if log.status != "lost_host":
|
|
146
|
+
return None, False
|
|
147
|
+
if log.session_status and log.session_status in _ENDED_SESSION_STATES:
|
|
148
|
+
return None, True
|
|
149
|
+
if _rebuild_restart_mode() == "never" or not log.command:
|
|
150
|
+
return None, True
|
|
151
|
+
proc = client.session_spawn(sid, log.command)
|
|
152
|
+
print(
|
|
153
|
+
f"\N{ANTICLOCKWISE OPEN CIRCLE ARROW} process {pid} was lost with "
|
|
154
|
+
f"its host — restarted from the beginning as {proc.process_id} "
|
|
155
|
+
f"(disable with FCLOUD_MIGRATE_RESTART=never)",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
return proc, False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _drain_resumed_process(client: Client, sid: str, pid: str, on_output,
|
|
162
|
+
result, wait_deadline: float | None = None):
|
|
163
|
+
"""Stream a restored process to exit on its new host, surviving further
|
|
164
|
+
migrations, and return its real exit result. Live output goes to the
|
|
165
|
+
console sink (human mode) or is captured onto the result (e.g. --json).
|
|
166
|
+
``wait_deadline`` bounds the drain like session.run's own timeout would."""
|
|
167
|
+
from fcloud.types import Process
|
|
168
|
+
proc = Process(process_id=pid, session_id=sid, _client=client)
|
|
169
|
+
prev = ""
|
|
170
|
+
# Host B replays the checkpointed backlog before resuming; let it advance
|
|
171
|
+
# past the checkpoint, then baseline so we emit only genuinely-new output.
|
|
172
|
+
time.sleep(3)
|
|
173
|
+
while True:
|
|
174
|
+
try:
|
|
175
|
+
proc.poll(lines=1000)
|
|
176
|
+
except Exception:
|
|
177
|
+
try:
|
|
178
|
+
on_migrating, on_reconnected = _migration_ui()
|
|
179
|
+
client.reconnect_session(
|
|
180
|
+
sid, on_migrating=on_migrating,
|
|
181
|
+
on_reconnected=on_reconnected,
|
|
182
|
+
budget=_ride_budget(wait_deadline))
|
|
183
|
+
except Exception:
|
|
184
|
+
return _still_migrating(result, sid, pid)
|
|
185
|
+
# The session is back — but after a plain rebuild the process
|
|
186
|
+
# is not. Consult durable truth (the rebuilt host marks lost
|
|
187
|
+
# records lost_host) and re-execute or fail fast instead of
|
|
188
|
+
# polling a corpse pid for the rest of the budget.
|
|
189
|
+
respawned, refused = _maybe_respawn_after_rebuild(client, sid, pid)
|
|
190
|
+
if refused:
|
|
191
|
+
result.status = "host_lost"
|
|
192
|
+
result.returncode = None
|
|
193
|
+
result.message = (
|
|
194
|
+
f"Process {pid} was lost with its host (the session was "
|
|
195
|
+
f"rebuilt, not restored) and was not restarted. Re-run "
|
|
196
|
+
f"the command, or leave FCLOUD_MIGRATE_RESTART=auto. "
|
|
197
|
+
f"Captured output: fcloud logs {sid} {pid} --output all")
|
|
198
|
+
return result
|
|
199
|
+
if respawned is not None:
|
|
200
|
+
pid = respawned.process_id
|
|
201
|
+
proc = Process(process_id=pid, session_id=sid, _client=client)
|
|
202
|
+
result.process_id = pid
|
|
203
|
+
prev = ""
|
|
204
|
+
time.sleep(3)
|
|
205
|
+
continue
|
|
206
|
+
if proc.output != prev:
|
|
207
|
+
delta = _tail_follow_delta(prev, proc.output)
|
|
208
|
+
if delta and on_output is not None:
|
|
209
|
+
on_output(delta)
|
|
210
|
+
prev = proc.output
|
|
211
|
+
if proc.status != ProcessStatus.RUNNING:
|
|
212
|
+
break
|
|
213
|
+
if wait_deadline is not None and time.monotonic() >= wait_deadline:
|
|
214
|
+
# The resumed process is genuinely alive but the caller's --wait
|
|
215
|
+
# is spent: report it exactly like session.run's own timeout
|
|
216
|
+
# (status "running", exit 0), not as a failed ride.
|
|
217
|
+
result.status = "running"
|
|
218
|
+
result.returncode = None
|
|
219
|
+
result.message = (
|
|
220
|
+
f"Command is still running. Follow logs: "
|
|
221
|
+
f"fcloud logs {sid} {pid} --follow")
|
|
222
|
+
return result
|
|
223
|
+
time.sleep(1)
|
|
224
|
+
if on_output is None:
|
|
225
|
+
result.stdout = proc.output
|
|
226
|
+
result.status = "exited"
|
|
227
|
+
result.returncode = proc.exit_code
|
|
228
|
+
result.message = ""
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _follow_reconnect(client: Client, sid: str, detach, *,
|
|
233
|
+
budget: float = _RESUME_BUDGET_S):
|
|
234
|
+
"""Re-attach to a session after its host was pre-empted/migrated, for the
|
|
235
|
+
log-follow loop. Thin wrapper over ``Client.reconnect_session`` (the shared
|
|
236
|
+
library mechanic): drops the dead connection, prints the migration UX, waits
|
|
237
|
+
for the restore, and returns a fresh detach fn. Raises if the session does
|
|
238
|
+
not come back within ``budget`` seconds. The default budget matches the
|
|
239
|
+
run/exec migration ride (_RESUME_BUDGET_S = the dispatcher's restore-phase
|
|
240
|
+
deadline) — --follow must not give up on restores the ride would survive.
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
detach()
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
on_migrating, on_reconnected = _migration_ui()
|
|
247
|
+
client.reconnect_session(
|
|
248
|
+
sid, on_migrating=on_migrating, on_reconnected=on_reconnected,
|
|
249
|
+
budget=budget,
|
|
250
|
+
)
|
|
251
|
+
return lambda: client.detach_session(sid)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _tail_follow_delta(prev: str, cur: str) -> str:
|
|
255
|
+
"""Return only the genuinely-new tail of ``cur`` given what we already
|
|
256
|
+
emitted (``prev``).
|
|
257
|
+
|
|
258
|
+
The host serves ``poll`` as a *sliding tail window* (the last N lines,
|
|
259
|
+
proc.go ReadOutput), not cumulative output. Once a process prints more
|
|
260
|
+
than N lines the window scrolls, so ``cur`` no longer starts with
|
|
261
|
+
``prev`` — and a naive prefix check (``cur.startswith(prev)``) then
|
|
262
|
+
reprints the entire window on every poll. For a long-running stream
|
|
263
|
+
(e.g. a trainer printing one line/sec) that turns ``--follow`` into a
|
|
264
|
+
flickering, endlessly-reprinting block. Align on the scrolled overlap
|
|
265
|
+
instead: emit only what follows the longest suffix-of-prev that is also
|
|
266
|
+
a prefix-of-cur. Falls back to the whole window when there is no overlap
|
|
267
|
+
(the first poll, or a post-migration rebaseline).
|
|
268
|
+
"""
|
|
269
|
+
if not prev:
|
|
270
|
+
return cur
|
|
271
|
+
if cur.startswith(prev):
|
|
272
|
+
return cur[len(prev):]
|
|
273
|
+
for k in range(min(len(prev), len(cur)), 0, -1):
|
|
274
|
+
if prev[-k:] == cur[:k]:
|
|
275
|
+
return cur[k:]
|
|
276
|
+
return cur
|
fcloud/cli/mount.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""`fcloud mount`: browse a session's files locally via rclone (read-only)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from fcloud.cli.registry import cmd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@cmd("mount", "fcloud mount <session-id> <mountpoint> [-- rclone-args...]",
|
|
9
|
+
"Mount a session's files locally via rclone (read-only)")
|
|
10
|
+
def cmd_mount(args: list[str]) -> None:
|
|
11
|
+
if len(args) < 2:
|
|
12
|
+
print("Usage: fcloud mount <session-id> <mountpoint> [-- rclone-args...]")
|
|
13
|
+
print("\nBrowse a session's files in Finder/Explorer (read-only). Needs")
|
|
14
|
+
print("only rclone (brew install rclone) — it uses the OS NFS client, so")
|
|
15
|
+
print("NO macFUSE / kernel extension / recovery mode is required.")
|
|
16
|
+
print("For CLI/programmatic access use `fcloud ls` and `fcloud download`.")
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
|
|
21
|
+
if not shutil.which("rclone"):
|
|
22
|
+
print("Error: rclone not found. Install: https://rclone.org/install/",
|
|
23
|
+
file=sys.stderr)
|
|
24
|
+
sys.exit(2)
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
|
|
28
|
+
import requests
|
|
29
|
+
|
|
30
|
+
from fcloud.config import resolve_api_key, resolve_url
|
|
31
|
+
|
|
32
|
+
sid, mountpoint = args[0], os.path.abspath(os.path.expanduser(args[1]))
|
|
33
|
+
extra = args[3:] if len(args) > 2 and args[2] == "--" else args[2:]
|
|
34
|
+
# Safety: fcloud mount only ever uses a path that does NOT exist yet. It
|
|
35
|
+
# creates the mountpoint and removes it on unmount, so it can only touch a
|
|
36
|
+
# directory it made itself — it will never shadow or delete an existing
|
|
37
|
+
# folder. So `fcloud mount ~/important` is refused outright, before any
|
|
38
|
+
# filesystem change. (os.path.lexists also catches a symlink target.)
|
|
39
|
+
if os.path.lexists(mountpoint):
|
|
40
|
+
print(f"Error: {mountpoint} already exists — refusing. fcloud mount "
|
|
41
|
+
f"creates its own mountpoint and deletes it on unmount, so it "
|
|
42
|
+
f"only ever touches a folder it made. Pick a path that does not "
|
|
43
|
+
f"exist yet, e.g. ~/fcloud-{sid}.", file=sys.stderr)
|
|
44
|
+
sys.exit(2)
|
|
45
|
+
try:
|
|
46
|
+
os.makedirs(mountpoint) # no exist_ok: it must not pre-exist
|
|
47
|
+
except OSError as e:
|
|
48
|
+
print(f"Error: cannot create mountpoint {mountpoint}: {e}", file=sys.stderr)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
url = resolve_url(None)
|
|
51
|
+
key = resolve_api_key(None)
|
|
52
|
+
if not key:
|
|
53
|
+
print("Error: no API key. Run: fcloud set_token <key>", file=sys.stderr)
|
|
54
|
+
sys.exit(2)
|
|
55
|
+
|
|
56
|
+
# Mint a short-lived capability token, then build a HEADER-LESS mount
|
|
57
|
+
# URL: the token lives in the path. rclone needs no auth header, so it
|
|
58
|
+
# follows each file's redirect to S3 cleanly (an Authorization header
|
|
59
|
+
# would be re-sent across the redirect and S3 rejects dual auth). The
|
|
60
|
+
# dispatcher only serves listings + redirects — file bytes stream
|
|
61
|
+
# straight from S3 to this machine.
|
|
62
|
+
resp = requests.post(
|
|
63
|
+
f"{url}/v1/fs/{sid}/mount-url",
|
|
64
|
+
json={}, headers={"Authorization": f"Bearer {key}"}, timeout=30,
|
|
65
|
+
)
|
|
66
|
+
if resp.status_code == 404:
|
|
67
|
+
print("Not found: unknown session or not yours.", file=sys.stderr)
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
if resp.status_code >= 400:
|
|
70
|
+
print(f"Error minting mount token: {resp.status_code}: {resp.text[:200]}",
|
|
71
|
+
file=sys.stderr)
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
mount_url = url.rstrip("/") + resp.json()["path"]
|
|
74
|
+
|
|
75
|
+
import re
|
|
76
|
+
|
|
77
|
+
# Use rclone's NFS mount (the OS NFS client), not FUSE: it needs NO
|
|
78
|
+
# macFUSE / kernel extension / recovery-mode approval — just rclone.
|
|
79
|
+
#
|
|
80
|
+
# Define a cleanly-NAMED remote via env vars rather than the on-the-fly
|
|
81
|
+
# ":http:" remote. The anonymous remote is auto-named ":http{xxxx}",
|
|
82
|
+
# and that name leaks into the Finder volume label and the VFS cache
|
|
83
|
+
# path (~/Library/Caches/rclone/vfs/:http{xxxx}/...), where the ':' and
|
|
84
|
+
# '{}' break cache-file creation. A clean per-session name fixes the
|
|
85
|
+
# label, makes the cache work, and avoids cache collisions across mounts.
|
|
86
|
+
remote = "fcloudmnt" + re.sub(r"[^a-z0-9]", "", sid.lower())
|
|
87
|
+
env = dict(os.environ)
|
|
88
|
+
env[f"RCLONE_CONFIG_{remote.upper()}_TYPE"] = "http"
|
|
89
|
+
env[f"RCLONE_CONFIG_{remote.upper()}_URL"] = mount_url
|
|
90
|
+
|
|
91
|
+
# Name the volume after the mountpoint the user chose, so Finder's label
|
|
92
|
+
# matches the folder (~/fcloud -> "fcloud") instead of an internal name.
|
|
93
|
+
volname = os.path.basename(os.path.normpath(mountpoint)) or f"fcloud-{sid}"
|
|
94
|
+
# vfs-cache-mode=off: read-only browsing doesn't need a local file
|
|
95
|
+
# cache, and rclone's full-cache path errors trying to create nested
|
|
96
|
+
# cache dirs. With off, reads stream straight from S3 (Range-capable),
|
|
97
|
+
# which is exactly what we want — no cache, no errors.
|
|
98
|
+
rclone_cmd = [
|
|
99
|
+
"rclone", "nfsmount", f"{remote}:", mountpoint,
|
|
100
|
+
"--volname", volname,
|
|
101
|
+
"--read-only",
|
|
102
|
+
"--vfs-cache-mode", "off",
|
|
103
|
+
"--dir-cache-time", "10s",
|
|
104
|
+
*extra,
|
|
105
|
+
]
|
|
106
|
+
print(f"Mounting session {sid} at {mountpoint} (read-only). "
|
|
107
|
+
f"Open it in Finder/Explorer; press Ctrl-C to unmount.")
|
|
108
|
+
print("(macOS may block Terminal from reading the mount — that's a "
|
|
109
|
+
"privacy/TCC quirk; browse in Finder, or use `fcloud ls` from the CLI.)")
|
|
110
|
+
import signal
|
|
111
|
+
import time
|
|
112
|
+
|
|
113
|
+
# Route SIGINT (Ctrl-C) and SIGTERM (kill / backgrounded runs) to the
|
|
114
|
+
# same unmount+cleanup path. Re-installing SIGINT matters because a
|
|
115
|
+
# shell that backgrounds the process sets SIGINT to "ignore".
|
|
116
|
+
def _request_unmount(signum, frame):
|
|
117
|
+
raise KeyboardInterrupt
|
|
118
|
+
try:
|
|
119
|
+
signal.signal(signal.SIGINT, _request_unmount)
|
|
120
|
+
signal.signal(signal.SIGTERM, _request_unmount)
|
|
121
|
+
except (ValueError, OSError):
|
|
122
|
+
pass # not the main thread / unsupported — fall back to defaults
|
|
123
|
+
|
|
124
|
+
proc = subprocess.Popen(rclone_cmd, env=env)
|
|
125
|
+
rc = 0
|
|
126
|
+
try:
|
|
127
|
+
rc = proc.wait()
|
|
128
|
+
except KeyboardInterrupt:
|
|
129
|
+
# Forward Ctrl-C and wait for rclone to unmount + exit before we
|
|
130
|
+
# touch the mountpoint, so cleanup sees an empty (unmounted) dir.
|
|
131
|
+
try:
|
|
132
|
+
proc.send_signal(signal.SIGINT)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
try:
|
|
136
|
+
rc = proc.wait(timeout=20)
|
|
137
|
+
except Exception:
|
|
138
|
+
rc = 0
|
|
139
|
+
finally:
|
|
140
|
+
# We created this mountpoint (it did not exist — we refused above if
|
|
141
|
+
# it did), so remove it now that rclone has unmounted. Retry briefly
|
|
142
|
+
# for macOS's NFS-detach lag. os.rmdir only removes an EMPTY dir, a
|
|
143
|
+
# final belt-and-suspenders so nothing unexpected is ever deleted.
|
|
144
|
+
for _ in range(20):
|
|
145
|
+
try:
|
|
146
|
+
os.rmdir(mountpoint)
|
|
147
|
+
break
|
|
148
|
+
except OSError:
|
|
149
|
+
time.sleep(0.3)
|
|
150
|
+
sys.exit(rc)
|
fcloud/cli/output.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Process-wide CLI output mode.
|
|
2
|
+
|
|
3
|
+
`_JSON_OUTPUT` is set once by `fcloud.cli.main` from the global `--json` flag
|
|
4
|
+
and read by every command at call time (`output._JSON_OUTPUT`), so a single
|
|
5
|
+
binding governs the whole invocation. Tests patch it here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json as _json
|
|
9
|
+
|
|
10
|
+
_JSON_OUTPUT = False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _out(data: dict) -> None:
|
|
14
|
+
"""Print data as JSON if --json, otherwise ignored (callers
|
|
15
|
+
handle human output themselves)."""
|
|
16
|
+
if _JSON_OUTPUT:
|
|
17
|
+
print(_json.dumps(data))
|