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/job.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""`fcloud job`: run a command on an ephemeral session (no saved filesystem)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from fcloud.cli import output
|
|
7
|
+
from fcloud.cli.common import (
|
|
8
|
+
_extract_flag,
|
|
9
|
+
_extract_secrets,
|
|
10
|
+
_extract_upload_options,
|
|
11
|
+
_parse_duration_seconds,
|
|
12
|
+
_parse_min_disk,
|
|
13
|
+
_parse_volume_mount,
|
|
14
|
+
_reject_volume_upload_collision,
|
|
15
|
+
_shape_exec_command,
|
|
16
|
+
)
|
|
17
|
+
from fcloud.cli.context import (
|
|
18
|
+
_resolve_min_disk_gb,
|
|
19
|
+
_resolve_project,
|
|
20
|
+
_resolve_volume_mounts,
|
|
21
|
+
)
|
|
22
|
+
from fcloud.cli.processes import cmd_logs
|
|
23
|
+
from fcloud.cli.registry import cmd
|
|
24
|
+
from fcloud.cli.sessions import _session_state
|
|
25
|
+
from fcloud.cli.wait import cmd_wait
|
|
26
|
+
from fcloud.client import Client
|
|
27
|
+
|
|
28
|
+
_JOB_LONG_HELP = """\
|
|
29
|
+
A job runs a command to completion on an EPHEMERAL session: its filesystem
|
|
30
|
+
is the image + --include files + volumes, and it is never persisted — the
|
|
31
|
+
session cannot be resumed, mounted, or downloaded after it closes. Write
|
|
32
|
+
durable outputs to a volume (--volume) or download them while the job runs;
|
|
33
|
+
the exit code and logs always survive (fcloud job logs).
|
|
34
|
+
|
|
35
|
+
Jobs are never checkpoint/migrated: if the host is lost mid-run (spot
|
|
36
|
+
preemption), --retries N re-runs the command on a fresh session.
|
|
37
|
+
|
|
38
|
+
--include PATH uploads a file or directory before the command starts (same
|
|
39
|
+
rules as `fcloud upload`: hidden entries, dependency dirs and symlinked files
|
|
40
|
+
are skipped unless --include-hidden / --follow-symlinks say otherwise).
|
|
41
|
+
|
|
42
|
+
`job run` returns once the command has exited AND its volumes are
|
|
43
|
+
committed — read them the moment it returns. If the host is unreachable at
|
|
44
|
+
that point the commit is deferred to its teardown replay, and the command
|
|
45
|
+
says so instead of returning a silent lie.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
fcloud job run --sku gpu_1x_l4 --volume results -- python train.py
|
|
49
|
+
fcloud job run --include ./src --env RUN=7 --detach -- python src/gen.py
|
|
50
|
+
fcloud job ls
|
|
51
|
+
fcloud job logs s-abc123 # durable output, live or after close
|
|
52
|
+
fcloud job wait s-abc123 # block until the job ends
|
|
53
|
+
fcloud job kill s-abc123
|
|
54
|
+
|
|
55
|
+
Sweep management moved to `fcloud sweep <status|logs|retry|cancel|wait>`."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_JOB_SUBCOMMANDS = ("run", "ls", "logs", "wait", "kill")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@cmd("job", "fcloud job <run|ls|logs|wait|kill> …",
|
|
62
|
+
"Run a job: ephemeral session, no saved filesystem",
|
|
63
|
+
long_help=_JOB_LONG_HELP)
|
|
64
|
+
def cmd_job(args: list[str]) -> None:
|
|
65
|
+
if not args:
|
|
66
|
+
print("Usage: fcloud job <run|ls|logs|wait|kill> … "
|
|
67
|
+
"(see `fcloud help job`)", file=sys.stderr)
|
|
68
|
+
sys.exit(2)
|
|
69
|
+
sub, rest = args[0], args[1:]
|
|
70
|
+
if sub == "run":
|
|
71
|
+
_job_run(rest)
|
|
72
|
+
elif sub == "ls":
|
|
73
|
+
_job_ls(rest)
|
|
74
|
+
elif sub == "logs":
|
|
75
|
+
_job_logs(rest)
|
|
76
|
+
elif sub == "wait":
|
|
77
|
+
_job_wait(rest)
|
|
78
|
+
elif sub == "kill":
|
|
79
|
+
_job_kill(rest)
|
|
80
|
+
else:
|
|
81
|
+
print(f"Unknown job subcommand {sub!r}. Jobs: fcloud job "
|
|
82
|
+
f"<run|ls|logs|wait|kill>. Sweeps: fcloud sweep <name>.",
|
|
83
|
+
file=sys.stderr)
|
|
84
|
+
sys.exit(2)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _job_run(args: list[str]) -> None:
|
|
88
|
+
try:
|
|
89
|
+
secrets, args = _extract_secrets(args)
|
|
90
|
+
except ValueError as exc:
|
|
91
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
92
|
+
sys.exit(2)
|
|
93
|
+
upload_opts, args = _extract_upload_options(args)
|
|
94
|
+
sku, min_disk, retries, detach = "", 0, 0, False
|
|
95
|
+
volumes: list[dict[str, str]] = []
|
|
96
|
+
include: list[str] = []
|
|
97
|
+
env: dict[str, str] = {}
|
|
98
|
+
cmd_args: list[str] = []
|
|
99
|
+
i = 0
|
|
100
|
+
while i < len(args):
|
|
101
|
+
a = args[i]
|
|
102
|
+
if a == "--":
|
|
103
|
+
cmd_args = args[i + 1:]
|
|
104
|
+
break
|
|
105
|
+
if a == "--sku" and i + 1 < len(args):
|
|
106
|
+
sku = args[i + 1]
|
|
107
|
+
i += 2
|
|
108
|
+
elif a == "--volume" and i + 1 < len(args):
|
|
109
|
+
volumes.append(_parse_volume_mount(args[i + 1]))
|
|
110
|
+
i += 2
|
|
111
|
+
elif a == "--include" and i + 1 < len(args):
|
|
112
|
+
include.append(args[i + 1])
|
|
113
|
+
i += 2
|
|
114
|
+
elif a == "--env" and i + 1 < len(args):
|
|
115
|
+
key, _, value = args[i + 1].partition("=")
|
|
116
|
+
env[key] = value
|
|
117
|
+
i += 2
|
|
118
|
+
elif a in ("--min-disk-gb", "--min-disk") and i + 1 < len(args):
|
|
119
|
+
try:
|
|
120
|
+
min_disk = _parse_min_disk(args[i + 1])
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
123
|
+
sys.exit(2)
|
|
124
|
+
i += 2
|
|
125
|
+
elif a == "--retries" and i + 1 < len(args):
|
|
126
|
+
retries = int(args[i + 1])
|
|
127
|
+
i += 2
|
|
128
|
+
elif a == "--detach":
|
|
129
|
+
detach = True
|
|
130
|
+
i += 1
|
|
131
|
+
elif a.startswith("--"):
|
|
132
|
+
print(f"Error: unknown job run option {a!r} (put the command "
|
|
133
|
+
f"after `--`)", file=sys.stderr)
|
|
134
|
+
sys.exit(2)
|
|
135
|
+
else:
|
|
136
|
+
cmd_args = args[i:]
|
|
137
|
+
break
|
|
138
|
+
continue
|
|
139
|
+
if not cmd_args:
|
|
140
|
+
print("Usage: fcloud job run [--sku SKU] [--volume NAME[:MOUNT]] "
|
|
141
|
+
"[--include PATH] [--include-hidden] [--follow-symlinks] "
|
|
142
|
+
"[--env K=V] [--secret KEY[=VALUE]] "
|
|
143
|
+
"[--retries N] [--detach] -- <command...>", file=sys.stderr)
|
|
144
|
+
sys.exit(2)
|
|
145
|
+
env.update(secrets)
|
|
146
|
+
|
|
147
|
+
client = Client()
|
|
148
|
+
project, default_sku = _resolve_project(client, sku=sku)
|
|
149
|
+
volume_mounts = _resolve_volume_mounts(volumes)
|
|
150
|
+
# --include uploads land at /workspace/<basename>: same collision
|
|
151
|
+
# hazard as `fcloud run` (BUG-1) — a mount there absorbs the upload.
|
|
152
|
+
from pathlib import Path
|
|
153
|
+
for inc in include:
|
|
154
|
+
_reject_volume_upload_collision(volume_mounts, Path(inc).resolve())
|
|
155
|
+
job = project.job(
|
|
156
|
+
_shape_exec_command(cmd_args),
|
|
157
|
+
sku=sku or default_sku,
|
|
158
|
+
volume_mounts=volume_mounts or None,
|
|
159
|
+
include=include,
|
|
160
|
+
include_hidden=upload_opts.include_hidden,
|
|
161
|
+
follow_symlinks=upload_opts.follow_symlinks,
|
|
162
|
+
env=env or None,
|
|
163
|
+
retries=retries,
|
|
164
|
+
min_disk_gb=_resolve_min_disk_gb(min_disk),
|
|
165
|
+
# Teardown status on stderr: the close that commits the job's
|
|
166
|
+
# volumes runs after the command exits, and it is not instant.
|
|
167
|
+
on_progress=lambda line: print(line, file=sys.stderr),
|
|
168
|
+
)
|
|
169
|
+
print(f"Job {job.session_id} (pid {job.process_id}) — ephemeral: "
|
|
170
|
+
f"outputs go to volumes/logs.", file=sys.stderr)
|
|
171
|
+
if detach:
|
|
172
|
+
if output._JSON_OUTPUT:
|
|
173
|
+
output._out({"session_id": job.session_id,
|
|
174
|
+
"process_id": job.process_id, "detached": True})
|
|
175
|
+
else:
|
|
176
|
+
print(f"Detached. Track: fcloud job logs {job.session_id} · "
|
|
177
|
+
f"fcloud job wait {job.session_id}", file=sys.stderr)
|
|
178
|
+
return
|
|
179
|
+
try:
|
|
180
|
+
for event in job.watch():
|
|
181
|
+
data = event.get("data", "")
|
|
182
|
+
if data:
|
|
183
|
+
sys.stdout.write(data)
|
|
184
|
+
sys.stdout.flush()
|
|
185
|
+
except KeyboardInterrupt:
|
|
186
|
+
print(f"\nDetached — the job keeps running. Track: "
|
|
187
|
+
f"fcloud job wait {job.session_id}", file=sys.stderr)
|
|
188
|
+
return
|
|
189
|
+
log = job.wait(poll_interval=2)
|
|
190
|
+
if job.attempts > 1:
|
|
191
|
+
print(f"(re-ran {job.attempts - 1} time(s) after host loss)",
|
|
192
|
+
file=sys.stderr)
|
|
193
|
+
if output._JSON_OUTPUT:
|
|
194
|
+
output._out({"session_id": job.session_id, "process_id": job.process_id,
|
|
195
|
+
"exit_code": log.exit_code, "status": job.status,
|
|
196
|
+
"attempts": job.attempts, "volume_sync": job.volume_sync})
|
|
197
|
+
if log.exit_code is None:
|
|
198
|
+
print("Job ended without an exit code (host lost; retries "
|
|
199
|
+
"exhausted).", file=sys.stderr)
|
|
200
|
+
sys.exit(1)
|
|
201
|
+
sys.exit(log.exit_code)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _job_ls(args: list[str]) -> None:
|
|
205
|
+
show_all = "--all" in args
|
|
206
|
+
limit, _rest = _extract_flag([a for a in args if a != "--all"], "--limit")
|
|
207
|
+
limit_n = int(limit) if limit else 0
|
|
208
|
+
client = Client()
|
|
209
|
+
jobs = [s for s in client.list_sessions(status="all") if s.ephemeral]
|
|
210
|
+
if not show_all:
|
|
211
|
+
jobs = [s for s in jobs
|
|
212
|
+
if _session_state(s) not in
|
|
213
|
+
{"cold", "ended", "failed", "dead"}]
|
|
214
|
+
jobs.sort(key=lambda s: (s.last_active or s.created_at or ""),
|
|
215
|
+
reverse=True)
|
|
216
|
+
if limit_n > 0:
|
|
217
|
+
jobs = jobs[:limit_n]
|
|
218
|
+
if output._JSON_OUTPUT:
|
|
219
|
+
output._out({"jobs": [
|
|
220
|
+
{"session_id": s.session_id, "status": s.status,
|
|
221
|
+
"state": _session_state(s), "lifecycle": s.lifecycle,
|
|
222
|
+
"created_at": s.created_at}
|
|
223
|
+
for s in jobs]})
|
|
224
|
+
return
|
|
225
|
+
if not jobs:
|
|
226
|
+
print("No jobs. Run one with: fcloud job run -- <cmd>"
|
|
227
|
+
+ ("" if show_all else " (--all includes finished jobs)"))
|
|
228
|
+
return
|
|
229
|
+
print(f"{'JOB':<22} {'STATE':<12} {'CREATED'}")
|
|
230
|
+
print("-" * 56)
|
|
231
|
+
for s in jobs:
|
|
232
|
+
print(f"{s.session_id:<22} {_session_state(s):<12} "
|
|
233
|
+
f"{s.created_at or ''}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _job_logs(args: list[str]) -> None:
|
|
237
|
+
if not args:
|
|
238
|
+
print("Usage: fcloud job logs <job-id> [--follow]", file=sys.stderr)
|
|
239
|
+
sys.exit(2)
|
|
240
|
+
cmd_logs(args)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _job_wait(args: list[str]) -> None:
|
|
244
|
+
timeout, rest = _extract_flag(args, "--timeout")
|
|
245
|
+
if not rest:
|
|
246
|
+
print("Usage: fcloud job wait <job-id> [pid] [--timeout DUR]",
|
|
247
|
+
file=sys.stderr)
|
|
248
|
+
sys.exit(2)
|
|
249
|
+
if len(rest) >= 2:
|
|
250
|
+
cmd_wait(args)
|
|
251
|
+
return
|
|
252
|
+
# No pid: wait for the job's SESSION to reach a terminal state — the
|
|
253
|
+
# run-to-completion contract releases the session when the command
|
|
254
|
+
# exits, so "session ended" == "job done".
|
|
255
|
+
sid = rest[0]
|
|
256
|
+
deadline = (
|
|
257
|
+
time.monotonic() + _parse_duration_seconds(timeout)
|
|
258
|
+
) if timeout else None
|
|
259
|
+
client = Client()
|
|
260
|
+
while True:
|
|
261
|
+
info = client.describe_session(sid)
|
|
262
|
+
stage = (info.lifecycle or info.status or "").lower()
|
|
263
|
+
if stage in ("closed", "failed", "dead"):
|
|
264
|
+
if output._JSON_OUTPUT:
|
|
265
|
+
output._out({"session_id": sid, "lifecycle": stage})
|
|
266
|
+
else:
|
|
267
|
+
print(f"Job {sid} ended ({stage}).")
|
|
268
|
+
sys.exit(0 if stage == "closed" else 1)
|
|
269
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
270
|
+
print(f"Job {sid} still {stage} after {timeout}.",
|
|
271
|
+
file=sys.stderr)
|
|
272
|
+
sys.exit(1)
|
|
273
|
+
time.sleep(3)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _job_kill(args: list[str]) -> None:
|
|
277
|
+
if not args:
|
|
278
|
+
print("Usage: fcloud job kill <job-id>", file=sys.stderr)
|
|
279
|
+
sys.exit(2)
|
|
280
|
+
sid = args[0]
|
|
281
|
+
client = Client()
|
|
282
|
+
# Idempotent like `fcloud stop`: killing an already-ended job is the no-op
|
|
283
|
+
# the caller wanted — but say so instead of claiming a fresh kill.
|
|
284
|
+
try:
|
|
285
|
+
close_result = client.close_session(sid) or {}
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
if "session_not_active" not in str(exc):
|
|
288
|
+
raise
|
|
289
|
+
close_result = {"status": "already_ended"}
|
|
290
|
+
status = close_result.get("status", "")
|
|
291
|
+
if output._JSON_OUTPUT:
|
|
292
|
+
output._out({"ok": True, "session_id": sid, "status": status})
|
|
293
|
+
return
|
|
294
|
+
if status == "already_ended":
|
|
295
|
+
print(f"Job {sid} already ended (no action needed).")
|
|
296
|
+
elif status in ("close_in_progress", "close_deferred"):
|
|
297
|
+
print(f"Job {sid} is already shutting down ({status}).")
|
|
298
|
+
else:
|
|
299
|
+
print(f"Job {sid} killed. Workspace discarded; volume commits and "
|
|
300
|
+
f"logs survive (fcloud job logs {sid}).")
|
fcloud/cli/main.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""CLI entry point: global flags, dispatch, top-level error handling."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from fcloud.cli import output
|
|
6
|
+
from fcloud.cli.help import print_command_help, print_help
|
|
7
|
+
from fcloud.cli.registry import COMMANDS
|
|
8
|
+
from fcloud.cli_args import SPECS, ArgError, split
|
|
9
|
+
from fcloud.client import PaymentOverdueError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _strip_precommand_json(rest: list[str], command: list[str]) -> list[str]:
|
|
13
|
+
"""Drop a fcloud-level ``--json`` that appears before the command begins.
|
|
14
|
+
|
|
15
|
+
``command`` is the opaque remote-command suffix from :func:`split` (empty
|
|
16
|
+
for non-passthrough commands). Everything from that boundary onward is the
|
|
17
|
+
user's own argv and is left untouched, so ``fcloud exec -- tool --json``
|
|
18
|
+
still passes ``--json`` to ``tool``.
|
|
19
|
+
"""
|
|
20
|
+
boundary = len(rest) - len(command) if command else len(rest)
|
|
21
|
+
head = [t for t in rest[:boundary] if t != "--json"]
|
|
22
|
+
return head + rest[boundary:]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main():
|
|
26
|
+
argv = sys.argv[1:]
|
|
27
|
+
# A --json *before* the command name is fcloud's global flag; a --json after
|
|
28
|
+
# the command is parsed per-spec below (and belongs to a passthrough
|
|
29
|
+
# command's own argv when it falls after `--`).
|
|
30
|
+
while argv and argv[0] == "--json":
|
|
31
|
+
output._JSON_OUTPUT = True
|
|
32
|
+
argv = argv[1:]
|
|
33
|
+
|
|
34
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
35
|
+
print_help()
|
|
36
|
+
sys.exit(0 if argv else 1)
|
|
37
|
+
|
|
38
|
+
if argv[0] in ("--version", "-V", "version"):
|
|
39
|
+
from fcloud import __version__
|
|
40
|
+
print(f"fcloud {__version__}")
|
|
41
|
+
sys.exit(0)
|
|
42
|
+
|
|
43
|
+
name = argv[0]
|
|
44
|
+
if name == "help":
|
|
45
|
+
if len(argv) > 1 and argv[1] in COMMANDS:
|
|
46
|
+
print_command_help(argv[1])
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
print_help()
|
|
49
|
+
sys.exit(0)
|
|
50
|
+
|
|
51
|
+
if name not in COMMANDS:
|
|
52
|
+
print(f"Unknown command: {name}")
|
|
53
|
+
print("Run 'fcloud help' for a list of commands.")
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
if len(argv) > 1 and argv[1] in ("-h", "--help", "help"):
|
|
57
|
+
print_command_help(name)
|
|
58
|
+
sys.exit(0)
|
|
59
|
+
|
|
60
|
+
rest = argv[1:]
|
|
61
|
+
# Validate flags against the command's spec: reject an undeclared leading
|
|
62
|
+
# flag (the typo'd-flag class) and resolve --json without eating the user's own
|
|
63
|
+
# trailing --json. Handlers then parse the (json-stripped) rest themselves.
|
|
64
|
+
spec = SPECS.get(name)
|
|
65
|
+
if spec is not None:
|
|
66
|
+
try:
|
|
67
|
+
parsed = split(spec, rest)
|
|
68
|
+
except ArgError as exc:
|
|
69
|
+
if output._JSON_OUTPUT:
|
|
70
|
+
output._out({"error": str(exc)})
|
|
71
|
+
else:
|
|
72
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
73
|
+
sys.exit(exc.exit_code)
|
|
74
|
+
if parsed.flags.get("--json"):
|
|
75
|
+
output._JSON_OUTPUT = True
|
|
76
|
+
rest = _strip_precommand_json(rest, parsed.command)
|
|
77
|
+
|
|
78
|
+
handler, _, _ = COMMANDS[name]
|
|
79
|
+
try:
|
|
80
|
+
handler(rest)
|
|
81
|
+
except SystemExit:
|
|
82
|
+
raise
|
|
83
|
+
except PaymentOverdueError as e:
|
|
84
|
+
# A missed payment suspends the whole account: the key is rejected
|
|
85
|
+
# and running sessions are killed. Make the cause unmistakable so the
|
|
86
|
+
# user fixes billing instead of debugging a phantom auth failure.
|
|
87
|
+
if output._JSON_OUTPUT:
|
|
88
|
+
output._out({"error": str(e), "code": "payment_overdue"})
|
|
89
|
+
else:
|
|
90
|
+
print(f"Payment overdue: {e}", file=sys.stderr)
|
|
91
|
+
print(
|
|
92
|
+
"Your account is suspended for a missed payment. Complete the "
|
|
93
|
+
"outstanding payment in your billing dashboard to restore "
|
|
94
|
+
"access.",
|
|
95
|
+
file=sys.stderr,
|
|
96
|
+
)
|
|
97
|
+
sys.exit(3)
|
|
98
|
+
except KeyboardInterrupt:
|
|
99
|
+
# The command's own cleanup (kill remote process, release the
|
|
100
|
+
# session) has already run as the interrupt unwound through it;
|
|
101
|
+
# exit on the conventional SIGINT code without a noisy traceback.
|
|
102
|
+
print("Interrupted.", file=sys.stderr)
|
|
103
|
+
sys.exit(130)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
# Best-effort client telemetry for an otherwise-invisible CLI crash
|
|
106
|
+
# the dispatcher can't see an exception that dies
|
|
107
|
+
# in the client process, so report it before surfacing it locally.
|
|
108
|
+
# Strictly fire-and-forget — report_client_event swallows everything.
|
|
109
|
+
_report_cli_error(e)
|
|
110
|
+
if output._JSON_OUTPUT:
|
|
111
|
+
output._out({"error": str(e)})
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
114
|
+
sys.exit(1)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _report_cli_error(exc: Exception) -> None:
|
|
118
|
+
"""Best-effort telemetry for an uncaught CLI error, then return.
|
|
119
|
+
|
|
120
|
+
Resolves the dispatcher URL + api key the same way the commands do and
|
|
121
|
+
fires a ``client_cli_error`` event. The error_code is the exception class
|
|
122
|
+
name and the detail is the (truncated) message — never a traceback, and
|
|
123
|
+
never the message of an OS/IO error, since those routinely embed local
|
|
124
|
+
filesystem paths. ``FCLOUD_TELEMETRY=0`` disables this along with every
|
|
125
|
+
other client event (enforced centrally in ``report_client_event``). Any
|
|
126
|
+
failure here is swallowed so telemetry never masks the original error.
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
from fcloud import v2_connect
|
|
130
|
+
from fcloud.config import resolve_api_key, resolve_url
|
|
131
|
+
|
|
132
|
+
detail = "" if isinstance(exc, OSError) else str(exc)
|
|
133
|
+
v2_connect.report_client_event(
|
|
134
|
+
resolve_url(None), resolve_api_key(None),
|
|
135
|
+
event_type="client_cli_error",
|
|
136
|
+
error_code=type(exc).__name__,
|
|
137
|
+
detail=detail,
|
|
138
|
+
)
|
|
139
|
+
except Exception:
|
|
140
|
+
return
|