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.
Files changed (63) hide show
  1. fcloud/SKILL.md +1185 -0
  2. fcloud/__init__.py +85 -0
  3. fcloud/__main__.py +150 -0
  4. fcloud/_direct_bridge.py +1645 -0
  5. fcloud/_legacy_env.py +14 -0
  6. fcloud/cli/__init__.py +23 -0
  7. fcloud/cli/attach.py +180 -0
  8. fcloud/cli/common.py +382 -0
  9. fcloud/cli/console.py +227 -0
  10. fcloud/cli/context.py +161 -0
  11. fcloud/cli/exec_cmd.py +201 -0
  12. fcloud/cli/files.py +280 -0
  13. fcloud/cli/hardware.py +83 -0
  14. fcloud/cli/help.py +150 -0
  15. fcloud/cli/interactive.py +104 -0
  16. fcloud/cli/job.py +300 -0
  17. fcloud/cli/main.py +140 -0
  18. fcloud/cli/migration.py +276 -0
  19. fcloud/cli/mount.py +150 -0
  20. fcloud/cli/output.py +17 -0
  21. fcloud/cli/processes.py +360 -0
  22. fcloud/cli/registry.py +28 -0
  23. fcloud/cli/run.py +318 -0
  24. fcloud/cli/sessions.py +592 -0
  25. fcloud/cli/setup.py +44 -0
  26. fcloud/cli/ssh.py +255 -0
  27. fcloud/cli/sweep.py +578 -0
  28. fcloud/cli/sweep_harvest.py +187 -0
  29. fcloud/cli/sweep_watch.py +104 -0
  30. fcloud/cli/volume.py +317 -0
  31. fcloud/cli/wait.py +314 -0
  32. fcloud/cli_args.py +348 -0
  33. fcloud/client.py +632 -0
  34. fcloud/client_projects.py +52 -0
  35. fcloud/client_sessions.py +111 -0
  36. fcloud/client_volumes.py +80 -0
  37. fcloud/config.py +404 -0
  38. fcloud/direct.py +535 -0
  39. fcloud/errors.py +102 -0
  40. fcloud/fileset.py +155 -0
  41. fcloud/image.py +483 -0
  42. fcloud/job.py +249 -0
  43. fcloud/providers/__init__.py +5 -0
  44. fcloud/providers/requests_http.py +59 -0
  45. fcloud/py.typed +0 -0
  46. fcloud/session.py +442 -0
  47. fcloud/setup_cmd.py +213 -0
  48. fcloud/shell.py +558 -0
  49. fcloud/sweeps.py +557 -0
  50. fcloud/tunnel.py +251 -0
  51. fcloud/types.py +302 -0
  52. fcloud/v2_connect.py +928 -0
  53. fcloud/version.py +33 -0
  54. fcloud/volume_wait.py +131 -0
  55. fcloud/volumes.py +204 -0
  56. fcloud_sdk-0.1.0.dist-info/METADATA +234 -0
  57. fcloud_sdk-0.1.0.dist-info/RECORD +63 -0
  58. fcloud_sdk-0.1.0.dist-info/WHEEL +5 -0
  59. fcloud_sdk-0.1.0.dist-info/entry_points.txt +3 -0
  60. fcloud_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
  61. fcloud_sdk-0.1.0.dist-info/licenses/NOTICE +4 -0
  62. fcloud_sdk-0.1.0.dist-info/top_level.txt +2 -0
  63. foom/__init__.py +36 -0
@@ -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))