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/console.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Foreground-command plumbing shared by exec/run/wait.
|
|
2
|
+
|
|
3
|
+
Live console streaming, the long-wait stderr heartbeat, the ExecResult
|
|
4
|
+
exit-code contract and the --emit-pid writer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json as _json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from fcloud.cli import output
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _live_console_sink():
|
|
15
|
+
"""Return (on_output, pty) for live console streaming, or (None, None).
|
|
16
|
+
|
|
17
|
+
In human (non-JSON) output mode we stream the command's output to the
|
|
18
|
+
console as it arrives and run it under a pseudoterminal so the remote
|
|
19
|
+
process flushes in real time — just like running it locally in a
|
|
20
|
+
terminal. Under --json the output is captured and returned as one
|
|
21
|
+
structured result, so we neither stream nor allocate a PTY (a PTY
|
|
22
|
+
would merge stdout/stderr and inject \\r\\n into machine-read output).
|
|
23
|
+
"""
|
|
24
|
+
if output._JSON_OUTPUT:
|
|
25
|
+
return None, None
|
|
26
|
+
|
|
27
|
+
def on_output(text: str) -> None:
|
|
28
|
+
sys.stdout.write(text)
|
|
29
|
+
sys.stdout.flush()
|
|
30
|
+
|
|
31
|
+
return on_output, _console_pty()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _console_pty() -> dict[str, int]:
|
|
35
|
+
"""PTY dimensions matching the local terminal (fallback 80x24)."""
|
|
36
|
+
import shutil
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
sz = shutil.get_terminal_size()
|
|
40
|
+
return {"cols": max(1, int(sz.columns)), "rows": max(1, int(sz.lines))}
|
|
41
|
+
except Exception:
|
|
42
|
+
return {"cols": 80, "rows": 24}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _ExecKeepalive:
|
|
46
|
+
"""Periodic stderr heartbeat during a long foreground fcloud exec wait.
|
|
47
|
+
|
|
48
|
+
Tooling like the Claude Code Bash harness and many CI runners escalate
|
|
49
|
+
on no-output timers shorter than `fcloud exec --wait 8m`. Emitting a
|
|
50
|
+
short line every `interval_s` keeps those watchdogs happy without
|
|
51
|
+
polluting stdout (which carries the command's actual output / JSON).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, interval_s: float = 30.0) -> None:
|
|
55
|
+
import threading
|
|
56
|
+
self._interval = max(1.0, float(interval_s))
|
|
57
|
+
self._stop = threading.Event()
|
|
58
|
+
self._thread: threading.Thread | None = None
|
|
59
|
+
self._start_t = 0.0
|
|
60
|
+
|
|
61
|
+
def __enter__(self) -> "_ExecKeepalive":
|
|
62
|
+
import threading
|
|
63
|
+
import time
|
|
64
|
+
self._start_t = time.time()
|
|
65
|
+
self._thread = threading.Thread(
|
|
66
|
+
target=self._loop, name="fcloud-exec-keepalive", daemon=True,
|
|
67
|
+
)
|
|
68
|
+
self._thread.start()
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(self, *exc) -> None:
|
|
72
|
+
self._stop.set()
|
|
73
|
+
if self._thread is not None:
|
|
74
|
+
self._thread.join(timeout=1.0)
|
|
75
|
+
|
|
76
|
+
def _loop(self) -> None:
|
|
77
|
+
import time
|
|
78
|
+
while not self._stop.wait(self._interval):
|
|
79
|
+
elapsed = int(time.time() - self._start_t)
|
|
80
|
+
sys.stderr.write(
|
|
81
|
+
f" ⟳ still waiting on remote command ({elapsed}s)...\n",
|
|
82
|
+
)
|
|
83
|
+
sys.stderr.flush()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _maybe_keepalive(enabled: bool):
|
|
87
|
+
"""Context manager that activates the stderr heartbeat only when
|
|
88
|
+
long-wait runs would otherwise trip no-output watchdogs."""
|
|
89
|
+
import contextlib
|
|
90
|
+
if not enabled:
|
|
91
|
+
return contextlib.nullcontext()
|
|
92
|
+
return _ExecKeepalive()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _exec_result(result, *, streamed: bool = False,
|
|
96
|
+
still_running_exit: int = 75) -> None:
|
|
97
|
+
"""Print an ExecResult and exit with its return code.
|
|
98
|
+
|
|
99
|
+
When ``streamed`` is True the output was already written to the
|
|
100
|
+
console live (see ``_live_console_sink``), so we skip re-printing it
|
|
101
|
+
here and only surface status/return code.
|
|
102
|
+
"""
|
|
103
|
+
if output._JSON_OUTPUT:
|
|
104
|
+
output._out({
|
|
105
|
+
"stdout": result.stdout,
|
|
106
|
+
"stderr": result.stderr,
|
|
107
|
+
"returncode": result.returncode,
|
|
108
|
+
"duration_ms": result.duration_ms,
|
|
109
|
+
"status": result.status,
|
|
110
|
+
"process_id": result.process_id,
|
|
111
|
+
"session_id": result.session_id,
|
|
112
|
+
"message": result.message,
|
|
113
|
+
"stdout_truncated": result.stdout_truncated,
|
|
114
|
+
"stdout_total_bytes": result.stdout_total_bytes,
|
|
115
|
+
"stdout_retained_bytes": result.stdout_retained_bytes,
|
|
116
|
+
"stdout_range": result.stdout_range,
|
|
117
|
+
"output_dropped_bytes": result.output_dropped_bytes,
|
|
118
|
+
})
|
|
119
|
+
else:
|
|
120
|
+
if not streamed:
|
|
121
|
+
if result.stdout:
|
|
122
|
+
sys.stdout.write(result.stdout)
|
|
123
|
+
if result.stderr:
|
|
124
|
+
sys.stderr.write(result.stderr)
|
|
125
|
+
if result.stdout_truncated:
|
|
126
|
+
print(_stdout_truncation_warning(result), file=sys.stderr)
|
|
127
|
+
if result.output_dropped_bytes:
|
|
128
|
+
print(
|
|
129
|
+
"Warning: command output is incomplete: "
|
|
130
|
+
f"{result.output_dropped_bytes} byte(s) were dropped in transport.",
|
|
131
|
+
file=sys.stderr,
|
|
132
|
+
)
|
|
133
|
+
if result.status in ("running", "migrating"):
|
|
134
|
+
msg = result.message or (
|
|
135
|
+
"Command is still running. "
|
|
136
|
+
f"Follow logs: fcloud logs {result.session_id} "
|
|
137
|
+
f"{result.process_id} --follow"
|
|
138
|
+
)
|
|
139
|
+
print(msg, file=sys.stderr)
|
|
140
|
+
elif result.status in ("host_terminated", "host_lost"):
|
|
141
|
+
msg = result.message or (
|
|
142
|
+
"Host was interrupted; the process did not survive. "
|
|
143
|
+
f"Fetch any captured output: fcloud logs {result.session_id} "
|
|
144
|
+
f"{result.process_id} --output all"
|
|
145
|
+
)
|
|
146
|
+
print(msg, file=sys.stderr)
|
|
147
|
+
if result.returncode is None:
|
|
148
|
+
# Distinguish "still running, no return code yet" from "host died,
|
|
149
|
+
# the process was killed" (exit 143 = SIGTERM-style, so shell
|
|
150
|
+
# scripts and `set -e` callers don't silently treat eviction as
|
|
151
|
+
# success). host_lost reaching here means a migration ride wasn't
|
|
152
|
+
# wired for this path — treat it as a lost host (143), never a
|
|
153
|
+
# false success.
|
|
154
|
+
if result.status in ("host_terminated", "host_lost"):
|
|
155
|
+
sys.exit(143)
|
|
156
|
+
# "migrating": the ride ran out of --wait budget mid-restore. The
|
|
157
|
+
# process is neither confirmed alive nor dead, so exit retryable
|
|
158
|
+
# (75, EX_TEMPFAIL — the same convention as `fcloud wait` timeouts).
|
|
159
|
+
if result.status == "migrating":
|
|
160
|
+
sys.exit(75)
|
|
161
|
+
if result.status == "running":
|
|
162
|
+
# An expired wait exits 75 like `fcloud wait` does, so automation
|
|
163
|
+
# can't mistake an unfinished command for success (a fan-out
|
|
164
|
+
# once failed hundreds of healthy tasks on exactly that). Callers
|
|
165
|
+
# that explicitly did NOT wait (exec --wait 0 fire-and-forget)
|
|
166
|
+
# pass still_running_exit=0: "started" is their success.
|
|
167
|
+
sys.exit(still_running_exit)
|
|
168
|
+
sys.exit(0)
|
|
169
|
+
if result.returncode < 0:
|
|
170
|
+
# A negative code means the runtime lost the process to a signal —
|
|
171
|
+
# in practice the OOM killer. Passing it to sys.exit() wrapped it to
|
|
172
|
+
# 255, an exit code that means nothing to anyone: a training script
|
|
173
|
+
# dying at step 8 with "255" and no other clue is what that looks
|
|
174
|
+
# like. 137 (128+SIGKILL) is what a shell reports for a killed
|
|
175
|
+
# process, so `set -e` scripts and agents can branch on it. The
|
|
176
|
+
# reason, when the host knows it, arrives separately as the
|
|
177
|
+
# workload_oom session notice.
|
|
178
|
+
print(
|
|
179
|
+
"The command was killed before it could report an exit code "
|
|
180
|
+
"(exit 137). If this was memory, `fcloud logs` names the cause.",
|
|
181
|
+
file=sys.stderr,
|
|
182
|
+
)
|
|
183
|
+
sys.exit(137)
|
|
184
|
+
sys.exit(result.returncode)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _pid_emitter(path: str, session_id: str):
|
|
188
|
+
"""Build an on_start callback that records the process id to ``path`` the
|
|
189
|
+
instant the command starts — before the blocking wait.
|
|
190
|
+
|
|
191
|
+
This is the pipe- and background-proof channel the detach message can't
|
|
192
|
+
be: `fcloud exec --wait 60m ... 2>&1 | tail` buffers all stdio until EOF and
|
|
193
|
+
a backgrounded shell swallows it, so the proc-id needed for `fcloud wait` /
|
|
194
|
+
`fcloud logs --follow` never surfaces. The file is written independently of
|
|
195
|
+
stdout/stderr, so it's readable immediately even mid-wait. Written
|
|
196
|
+
atomically (temp + rename) so a reader never sees a half-written id.
|
|
197
|
+
"""
|
|
198
|
+
def _emit(pid: str) -> None:
|
|
199
|
+
payload = _json.dumps({"session_id": session_id, "process_id": pid})
|
|
200
|
+
tmp = f"{path}.tmp"
|
|
201
|
+
try:
|
|
202
|
+
# Create missing parent dirs so a mkdir failure lands in the same
|
|
203
|
+
# warn-and-continue branch as a write failure.
|
|
204
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
205
|
+
with open(tmp, "w") as fh:
|
|
206
|
+
fh.write(payload + "\n")
|
|
207
|
+
os.replace(tmp, path)
|
|
208
|
+
except OSError as exc:
|
|
209
|
+
# A bad --emit-pid path shouldn't sink the actual command; warn
|
|
210
|
+
# and carry on (the proc still runs and is waitable by other means).
|
|
211
|
+
sys.stderr.write(f"fcloud: could not write --emit-pid {path}: {exc}\n")
|
|
212
|
+
return _emit
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _stdout_truncation_warning(result) -> str:
|
|
216
|
+
logs_cmd = (
|
|
217
|
+
f" If the session is still active, fetch full log: fcloud logs {result.session_id} "
|
|
218
|
+
f"{result.process_id} --output all."
|
|
219
|
+
if result.session_id and result.process_id else ""
|
|
220
|
+
)
|
|
221
|
+
return (
|
|
222
|
+
"Warning: stdout truncated to "
|
|
223
|
+
f"{result.stdout_range} {result.stdout_retained_bytes} of "
|
|
224
|
+
f"{result.stdout_total_bytes} bytes. Rerun with --output all, "
|
|
225
|
+
"or choose --output head|middle|tail --output-bytes N."
|
|
226
|
+
f"{logs_cmd}"
|
|
227
|
+
)
|
fcloud/cli/context.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Local context: fcloud.json project defaults and API credentials.
|
|
2
|
+
|
|
3
|
+
Resolves what a command needs from the working directory (project
|
|
4
|
+
config, volumes, disk floor) and from the saved token/URL.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from fcloud.cli.common import _parse_volume_mount
|
|
10
|
+
from fcloud.client import Client
|
|
11
|
+
from fcloud.config import image_from_config, load_project_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _resolve_project(client: Client, sku: str = ""):
|
|
15
|
+
"""Build a project from fcloud.json if present, else use defaults.
|
|
16
|
+
|
|
17
|
+
`sku` is the CLI-provided override (e.g. `--sku cpu_8x_graviton`).
|
|
18
|
+
When present, it takes precedence over `cfg["sku"]` for image
|
|
19
|
+
construction — without this, an fcloud.json declaring a GPU sku
|
|
20
|
+
would force the pytorch wheel index even when the user explicitly
|
|
21
|
+
overrides to a CPU sku at the CLI.
|
|
22
|
+
"""
|
|
23
|
+
cfg = load_project_config()
|
|
24
|
+
default_sku = cfg.get("sku", "")
|
|
25
|
+
image = image_from_config(cfg, sku=sku or default_sku)
|
|
26
|
+
if image:
|
|
27
|
+
project = client.project("_cli", image=image)
|
|
28
|
+
else:
|
|
29
|
+
project = client.project("_cli")
|
|
30
|
+
return project, default_sku
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_min_disk_gb(cli_value: int) -> int:
|
|
34
|
+
"""CLI --min-disk-gb wins; else fall back to fcloud.json `min_disk_gb`.
|
|
35
|
+
|
|
36
|
+
Mirrors how --sku overrides cfg["sku"], so a project can declare a disk
|
|
37
|
+
floor once in fcloud.json and skip the flag on every command.
|
|
38
|
+
"""
|
|
39
|
+
if cli_value > 0:
|
|
40
|
+
return cli_value
|
|
41
|
+
try:
|
|
42
|
+
return int(load_project_config().get("min_disk_gb", 0) or 0)
|
|
43
|
+
except (TypeError, ValueError):
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _normalize_config_volume(entry: object) -> dict[str, str] | None:
|
|
48
|
+
"""Normalize one fcloud.json `volumes` entry to {name[, mount_path]}.
|
|
49
|
+
|
|
50
|
+
Accepts a string ("name" or "name:/mount") or an object with `name`
|
|
51
|
+
and optional `mount`/`mount_path`.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(entry, str):
|
|
54
|
+
try:
|
|
55
|
+
return _parse_volume_mount(entry)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return None
|
|
58
|
+
if isinstance(entry, dict):
|
|
59
|
+
name = str(entry.get("name", "")).strip()
|
|
60
|
+
if not name:
|
|
61
|
+
return None
|
|
62
|
+
out = {"name": name}
|
|
63
|
+
mount = entry.get("mount_path") or entry.get("mount") or ""
|
|
64
|
+
if mount:
|
|
65
|
+
out["mount_path"] = str(mount).strip()
|
|
66
|
+
return out
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve_volume_mounts(
|
|
71
|
+
cli_mounts: list[dict[str, str]],
|
|
72
|
+
) -> list[dict[str, str]]:
|
|
73
|
+
"""Merge fcloud.json `volumes` defaults with CLI --volume flags.
|
|
74
|
+
|
|
75
|
+
Effective set = config volumes ∪ CLI flags, keyed by name; a CLI
|
|
76
|
+
--volume for a name already in fcloud.json re-maps its mount path for
|
|
77
|
+
that invocation. Mirrors how --sku overrides cfg["sku"], so a project
|
|
78
|
+
can declare its volumes once and skip --volume on every command.
|
|
79
|
+
"""
|
|
80
|
+
cfg = load_project_config()
|
|
81
|
+
order: list[str] = []
|
|
82
|
+
by_name: dict[str, dict[str, str]] = {}
|
|
83
|
+
for entry in cfg.get("volumes", []) or []:
|
|
84
|
+
mount = _normalize_config_volume(entry)
|
|
85
|
+
if mount and mount["name"] not in by_name:
|
|
86
|
+
order.append(mount["name"])
|
|
87
|
+
by_name[mount["name"]] = mount
|
|
88
|
+
cli_seen: dict[str, dict[str, str]] = {}
|
|
89
|
+
for mount in cli_mounts:
|
|
90
|
+
name = mount["name"]
|
|
91
|
+
# A CLI flag overriding a fcloud.json default is the re-map feature;
|
|
92
|
+
# two CLI flags naming the same volume differently is a conflict —
|
|
93
|
+
# a volume mounts once per session, and silently keeping only the
|
|
94
|
+
# last flag hid that (BUG-14, 2026-08-20 report).
|
|
95
|
+
prior = cli_seen.get(name)
|
|
96
|
+
if prior is not None and prior != mount:
|
|
97
|
+
print(
|
|
98
|
+
f"Error: duplicate_volume — volume {name!r} can only be "
|
|
99
|
+
f"mounted once per session (asked for "
|
|
100
|
+
f"{prior.get('mount_path') or '/workspace/' + name} and "
|
|
101
|
+
f"{mount.get('mount_path') or '/workspace/' + name}).",
|
|
102
|
+
file=sys.stderr)
|
|
103
|
+
sys.exit(2)
|
|
104
|
+
cli_seen[name] = mount
|
|
105
|
+
if name not in by_name:
|
|
106
|
+
order.append(name)
|
|
107
|
+
by_name[name] = mount # CLI re-maps mount path
|
|
108
|
+
return [by_name[name] for name in order]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _current_image_spec_json(sku: str) -> str:
|
|
112
|
+
"""Serialize the local fcloud.json image spec for resume re-baseline.
|
|
113
|
+
|
|
114
|
+
Returns "" when there's no project config, so callers fall through
|
|
115
|
+
to the dispatcher's stored spec (existing behavior). When set, the
|
|
116
|
+
dispatcher uses this on resume of a cold session — the edited
|
|
117
|
+
fcloud.json actually takes effect on next use.
|
|
118
|
+
"""
|
|
119
|
+
cfg = load_project_config()
|
|
120
|
+
if not cfg:
|
|
121
|
+
return ""
|
|
122
|
+
image = image_from_config(cfg, sku=sku or cfg.get("sku", ""))
|
|
123
|
+
if image is None:
|
|
124
|
+
return ""
|
|
125
|
+
import json as _json
|
|
126
|
+
return _json.dumps(image.to_dict())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _api_base() -> tuple[str, dict]:
|
|
130
|
+
from fcloud.config import resolve_api_key, resolve_url
|
|
131
|
+
|
|
132
|
+
url = resolve_url(None)
|
|
133
|
+
key = resolve_api_key(None)
|
|
134
|
+
if not key:
|
|
135
|
+
print("Error: no API key. Run: fcloud set_token <key>", file=sys.stderr)
|
|
136
|
+
sys.exit(2)
|
|
137
|
+
return url, {"Authorization": f"Bearer {key}"}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _api_key() -> str:
|
|
141
|
+
"""The resolved API key, for SDK helpers that take it directly."""
|
|
142
|
+
from fcloud.config import resolve_api_key
|
|
143
|
+
|
|
144
|
+
key = resolve_api_key(None)
|
|
145
|
+
if not key:
|
|
146
|
+
print("Error: no API key. Run: fcloud set_token <key>", file=sys.stderr)
|
|
147
|
+
sys.exit(2)
|
|
148
|
+
return key
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _fs_base(session_id: str) -> tuple[str, dict]:
|
|
152
|
+
"""Resolve (browse-endpoint-base, auth-headers) for a session's
|
|
153
|
+
read-only file tree on the dispatcher."""
|
|
154
|
+
from fcloud.config import resolve_api_key, resolve_url
|
|
155
|
+
|
|
156
|
+
url = resolve_url(None)
|
|
157
|
+
key = resolve_api_key(None)
|
|
158
|
+
if not key:
|
|
159
|
+
print("Error: no API key. Run: fcloud set_token <key>", file=sys.stderr)
|
|
160
|
+
sys.exit(2)
|
|
161
|
+
return f"{url}/v1/fs/{session_id}/", {"Authorization": f"Bearer {key}"}
|
fcloud/cli/exec_cmd.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""`fcloud exec`: run one command on a GPU host in the foreground.
|
|
2
|
+
|
|
3
|
+
(Named exec_cmd because `exec` shadows the builtin.)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from fcloud.cli.attach import _attach_with_retry, _exit_session_unreachable
|
|
10
|
+
from fcloud.cli.common import (
|
|
11
|
+
_consume_positional_session,
|
|
12
|
+
_extract_flag,
|
|
13
|
+
_extract_min_disk,
|
|
14
|
+
_extract_on,
|
|
15
|
+
_extract_output_policy,
|
|
16
|
+
_extract_sku,
|
|
17
|
+
_extract_volumes,
|
|
18
|
+
_extract_wait,
|
|
19
|
+
_shape_exec_command,
|
|
20
|
+
_split_trailing_args,
|
|
21
|
+
_warn_sku_ignored_on_attach,
|
|
22
|
+
)
|
|
23
|
+
from fcloud.cli.console import (
|
|
24
|
+
_exec_result,
|
|
25
|
+
_live_console_sink,
|
|
26
|
+
_maybe_keepalive,
|
|
27
|
+
_pid_emitter,
|
|
28
|
+
)
|
|
29
|
+
from fcloud.cli.context import (
|
|
30
|
+
_current_image_spec_json,
|
|
31
|
+
_resolve_min_disk_gb,
|
|
32
|
+
_resolve_project,
|
|
33
|
+
_resolve_volume_mounts,
|
|
34
|
+
)
|
|
35
|
+
from fcloud.cli.migration import _ride_migration
|
|
36
|
+
from fcloud.cli.registry import cmd
|
|
37
|
+
from fcloud.client import Client
|
|
38
|
+
|
|
39
|
+
_EXEC_LONG_HELP = """\
|
|
40
|
+
Where it runs:
|
|
41
|
+
--sku SKU make a throwaway session on that hardware, run, release it.
|
|
42
|
+
Its filesystem is still saved and downloadable afterwards.
|
|
43
|
+
--on SID attach to an existing session (see `fcloud create`), run, detach.
|
|
44
|
+
The session stays alive; a cold one comes online first.
|
|
45
|
+
(neither) a throwaway session on the fcloud.json sku, or the default.
|
|
46
|
+
|
|
47
|
+
Commands run from /workspace — the same place `fcloud upload` puts your files —
|
|
48
|
+
so an uploaded train.py runs as `python train.py`. Don't hardcode /root paths.
|
|
49
|
+
For shell syntax (pipes, &&, redirects) pass one quoted expression or use
|
|
50
|
+
`sh -c`; unquoted metacharacters are eaten by your LOCAL shell first.
|
|
51
|
+
|
|
52
|
+
--wait bounds the foreground wait (default 30s). Past the deadline the process
|
|
53
|
+
keeps running and exec exits 75 — pick it up with `fcloud logs <sid> <pid>` and
|
|
54
|
+
`fcloud wait`, or start it detached with `fcloud spawn` in the first place.
|
|
55
|
+
|
|
56
|
+
--volume NAME[:MOUNT] mounts a volume for this command (repeatable) — see
|
|
57
|
+
`fcloud help volume`.
|
|
58
|
+
|
|
59
|
+
Examples:
|
|
60
|
+
fcloud exec --sku gpu_1x_l4 nvidia-smi -L
|
|
61
|
+
fcloud exec --on s-abc123 --wait 10m python train.py
|
|
62
|
+
fcloud exec --sku gpu_1x_l4 --volume checkpoints python train.py
|
|
63
|
+
fcloud exec --on s-abc123 -- sh -c 'cd data && ls | head'"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@cmd("exec",
|
|
67
|
+
"fcloud exec [--sku SKU] [--on SID|SID] [--volume NAME[:MOUNT]] "
|
|
68
|
+
"[--wait 30s] [--output MODE] <cmd...>",
|
|
69
|
+
"Run a command on a GPU host",
|
|
70
|
+
long_help=_EXEC_LONG_HELP)
|
|
71
|
+
def cmd_exec(args: list[str]) -> None:
|
|
72
|
+
(sku, on_sid, wait_s, output_range, output_limit,
|
|
73
|
+
volume_mounts, min_disk_gb, emit_pid, cmd_args) = _parse_exec_args(args)
|
|
74
|
+
if not cmd_args:
|
|
75
|
+
print("Usage: fcloud exec [--sku SKU] [--on SESSION|SESSION] "
|
|
76
|
+
"[--volume NAME[:MOUNT]] [--wait 30s] "
|
|
77
|
+
"[--emit-pid FILE] "
|
|
78
|
+
"[--output tail|head|middle|all] "
|
|
79
|
+
"[--output-bytes N] <command...>")
|
|
80
|
+
print("\nExamples:")
|
|
81
|
+
print(" fcloud exec nvidia-smi -L")
|
|
82
|
+
print(" fcloud exec --sku gpu_1x_l4 python train.py")
|
|
83
|
+
print(" fcloud exec --on s-abc123 --wait 10m python train.py")
|
|
84
|
+
print(" fcloud exec --volume checkpoints:/workspace/ckpt python train.py")
|
|
85
|
+
print(" fcloud exec --json --output all python cuda_probe.py")
|
|
86
|
+
print(" fcloud exec --output head --output-bytes 128k python noisy.py")
|
|
87
|
+
print(" fcloud exec s-abc123 --wait 10m -- python train.py")
|
|
88
|
+
print(" fcloud exec --on s-abc123 --emit-pid pid.txt --wait 10m python train.py")
|
|
89
|
+
print(" fcloud exec --on s-abc123 python train.py")
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
cmd_args = _shape_exec_command(cmd_args)
|
|
92
|
+
volume_mounts = _resolve_volume_mounts(volume_mounts)
|
|
93
|
+
on_output, pty = _live_console_sink()
|
|
94
|
+
# Long --wait blocks the foreground; no-output watchdogs (CI runners,
|
|
95
|
+
# Claude Code Bash) escalate without periodic stderr activity. Only
|
|
96
|
+
# arm the heartbeat for waits long enough to risk a watchdog kill, and
|
|
97
|
+
# only when stdout isn't already streaming (the stream itself is the
|
|
98
|
+
# heartbeat in human mode).
|
|
99
|
+
use_keepalive = wait_s >= 60 and on_output is None
|
|
100
|
+
client = Client()
|
|
101
|
+
if on_sid:
|
|
102
|
+
_warn_sku_ignored_on_attach(client, on_sid, sku)
|
|
103
|
+
# Forward the local fcloud.json spec on attach so a cold session's
|
|
104
|
+
# next resume re-baselines against the current config. Without
|
|
105
|
+
# this, sessions stay pinned to the spec they were first created
|
|
106
|
+
# with and edits to fcloud.json silently never apply.
|
|
107
|
+
attach_spec = _current_image_spec_json(sku)
|
|
108
|
+
on_start = _pid_emitter(emit_pid, on_sid) if emit_pid else None
|
|
109
|
+
try:
|
|
110
|
+
# Attach can blip in the setup/binding window right after a host
|
|
111
|
+
# first reports ready — the session isn't bound to its host yet, so
|
|
112
|
+
# the very first exec fails transiently and self-heals in seconds.
|
|
113
|
+
# Retry the (idempotent) attach before giving up; run() is issued
|
|
114
|
+
# once, outside the retry, so a command never double-executes.
|
|
115
|
+
session = _attach_with_retry(
|
|
116
|
+
client, on_sid, volume_mounts, attach_spec, sku)
|
|
117
|
+
try:
|
|
118
|
+
# The --wait budget covers the whole foreground command,
|
|
119
|
+
# including any migration ride after the run itself returns.
|
|
120
|
+
deadline = time.monotonic() + wait_s if wait_s > 0 else None
|
|
121
|
+
with _maybe_keepalive(use_keepalive):
|
|
122
|
+
result = session.run(
|
|
123
|
+
cmd_args,
|
|
124
|
+
timeout_ms=wait_s * 1000,
|
|
125
|
+
output_limit_bytes=output_limit,
|
|
126
|
+
output_range=output_range,
|
|
127
|
+
on_output=on_output,
|
|
128
|
+
on_start=on_start,
|
|
129
|
+
pty=pty,
|
|
130
|
+
)
|
|
131
|
+
result = _ride_migration(client, result, on_output, deadline)
|
|
132
|
+
# --wait 0 is explicit fire-and-forget: "launched OK" is
|
|
133
|
+
# success (0). Any positive wait that expires exits 75.
|
|
134
|
+
_exec_result(result, streamed=on_output is not None,
|
|
135
|
+
still_running_exit=0 if wait_s == 0 else 75)
|
|
136
|
+
finally:
|
|
137
|
+
client.detach_session(on_sid)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
# Attach/run raised and couldn't recover. This fires for an initial
|
|
140
|
+
# attach that never landed, or a run whose host vanished before
|
|
141
|
+
# _ride_migration could re-attach. Ask the dispatcher what actually
|
|
142
|
+
# happened and report the true cause — we only claim a migration
|
|
143
|
+
# when one is real, never as a blanket label for every blip.
|
|
144
|
+
_exit_session_unreachable(client, on_sid, exc)
|
|
145
|
+
else:
|
|
146
|
+
project, default_sku = _resolve_project(client, sku=sku)
|
|
147
|
+
sku = sku or default_sku
|
|
148
|
+
session = project.session(
|
|
149
|
+
sku=sku, volume_mounts=volume_mounts,
|
|
150
|
+
min_disk_gb=_resolve_min_disk_gb(min_disk_gb),
|
|
151
|
+
)
|
|
152
|
+
print(f"Session: {session.session_id}", file=sys.stderr)
|
|
153
|
+
on_start = _pid_emitter(emit_pid, session.session_id) if emit_pid else None
|
|
154
|
+
result = None
|
|
155
|
+
try:
|
|
156
|
+
# The --wait budget covers the whole foreground command,
|
|
157
|
+
# including any migration ride after the run itself returns.
|
|
158
|
+
deadline = time.monotonic() + wait_s if wait_s > 0 else None
|
|
159
|
+
with _maybe_keepalive(use_keepalive):
|
|
160
|
+
result = session.run(
|
|
161
|
+
cmd_args,
|
|
162
|
+
timeout_ms=wait_s * 1000,
|
|
163
|
+
output_limit_bytes=output_limit,
|
|
164
|
+
output_range=output_range,
|
|
165
|
+
on_output=on_output,
|
|
166
|
+
on_start=on_start,
|
|
167
|
+
pty=pty,
|
|
168
|
+
)
|
|
169
|
+
result = _ride_migration(client, result, on_output, deadline)
|
|
170
|
+
finally:
|
|
171
|
+
# Soft-release once the command is done: the host tears the
|
|
172
|
+
# ephemeral session down only if nothing else is using it,
|
|
173
|
+
# freeing the host promptly for the common one-shot case. If
|
|
174
|
+
# the command is still running — or still riding a restore
|
|
175
|
+
# ("migrating": releasing would tear down the session the
|
|
176
|
+
# restore is bringing back) — detach instead.
|
|
177
|
+
if result is None or result.status not in ("running", "migrating"):
|
|
178
|
+
client.release_session(session.session_id)
|
|
179
|
+
else:
|
|
180
|
+
client.detach_session(session.session_id)
|
|
181
|
+
_exec_result(result, streamed=on_output is not None,
|
|
182
|
+
still_running_exit=0 if wait_s == 0 else 75)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _parse_exec_args(
|
|
186
|
+
args: list[str],
|
|
187
|
+
) -> tuple[str, str, int, str, int | None, list[dict[str, str]], int, str, list[str]]:
|
|
188
|
+
args, trailing = _split_trailing_args(args)
|
|
189
|
+
volume_mounts, args = _extract_volumes(args)
|
|
190
|
+
sku, remaining = _extract_sku(args)
|
|
191
|
+
min_disk_gb, remaining = _extract_min_disk(remaining)
|
|
192
|
+
on_sid, remaining = _extract_on(remaining)
|
|
193
|
+
wait_s, remaining = _extract_wait(remaining)
|
|
194
|
+
emit_pid, remaining = _extract_flag(remaining, "--emit-pid")
|
|
195
|
+
output_range, output_limit, remaining = _extract_output_policy(remaining)
|
|
196
|
+
if not on_sid:
|
|
197
|
+
on_sid, remaining = _consume_positional_session(remaining)
|
|
198
|
+
return (
|
|
199
|
+
sku, on_sid, wait_s, output_range, output_limit,
|
|
200
|
+
volume_mounts, min_disk_gb, emit_pid, remaining + trailing,
|
|
201
|
+
)
|