clouddeck 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.
- clouddeck/__init__.py +3 -0
- clouddeck/__main__.py +5 -0
- clouddeck/cli.py +250 -0
- clouddeck/controller.py +3417 -0
- clouddeck/data/config.example.toml +94 -0
- clouddeck/data/jobremote/boot.sh +23 -0
- clouddeck/data/jobremote/fetch.sh +26 -0
- clouddeck/data/jobremote/step.sh +27 -0
- clouddeck/data/lease/README.md +58 -0
- clouddeck/data/lease/agent.sh +91 -0
- clouddeck/data/lease/bench.sh +43 -0
- clouddeck/data/lease/checkpoint.sh +169 -0
- clouddeck/data/lease/ddc-lib.sh +73 -0
- clouddeck/data/lease/dist.sh +45 -0
- clouddeck/data/lease/restore.sh +134 -0
- clouddeck/data/lease/setup.sh +95 -0
- clouddeck/data/lease/tmux.conf +25 -0
- clouddeck/data/lease/workers.sh +23 -0
- clouddeck/data/sdl.yaml.tmpl +63 -0
- clouddeck/engine.py +37 -0
- clouddeck/fmt.py +43 -0
- clouddeck/hardware.py +598 -0
- clouddeck/job/__init__.py +1 -0
- clouddeck/job/__main__.py +3 -0
- clouddeck/job/cli.py +153 -0
- clouddeck/job/clock.py +20 -0
- clouddeck/job/market.py +194 -0
- clouddeck/job/preflight.py +196 -0
- clouddeck/job/remote.py +190 -0
- clouddeck/job/runner.py +731 -0
- clouddeck/job/spec.py +400 -0
- clouddeck/job/telemetry.py +100 -0
- clouddeck/providers/__init__.py +44 -0
- clouddeck/providers/akash.py +620 -0
- clouddeck/providers/base.py +494 -0
- clouddeck/providers/demo.py +329 -0
- clouddeck/remote.py +447 -0
- clouddeck/remote_client.py +90 -0
- clouddeck/sdlgen.py +275 -0
- clouddeck/service.py +434 -0
- clouddeck/tui/__init__.py +0 -0
- clouddeck/tui/app.py +495 -0
- clouddeck/tui/clouddeck.tcss +109 -0
- clouddeck/tui/compute.py +627 -0
- clouddeck/tui/monitor.py +188 -0
- clouddeck/tui/picker.py +622 -0
- clouddeck/tui/projects.py +265 -0
- clouddeck/tui/recovery.py +217 -0
- clouddeck/tui/safety.py +120 -0
- clouddeck/tui/widgets.py +483 -0
- clouddeck/tui/workers.py +421 -0
- clouddeck-0.1.0.dist-info/METADATA +322 -0
- clouddeck-0.1.0.dist-info/RECORD +57 -0
- clouddeck-0.1.0.dist-info/WHEEL +5 -0
- clouddeck-0.1.0.dist-info/entry_points.txt +4 -0
- clouddeck-0.1.0.dist-info/licenses/LICENSE +21 -0
- clouddeck-0.1.0.dist-info/top_level.txt +1 -0
clouddeck/__init__.py
ADDED
clouddeck/__main__.py
ADDED
clouddeck/cli.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""`clouddeck` command line: the TUI (default), plus scriptable status / doctor / install / remote-access commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
|
|
15
|
+
PLACEHOLDER_REPO = re.compile(r"OWNER|your-private-state-repo") # what the docs and config.example.toml use; `ddc install` refuses it too
|
|
16
|
+
WARNING_FIXES = [ # (start of the controller's warning, what to do about it)
|
|
17
|
+
("unsafe_disable_limits", "Fix: remove `unsafe_disable_limits` from config.toml"),
|
|
18
|
+
("budget_guard = false", "Fix: set budget_guard = true in config.toml"),
|
|
19
|
+
("state_repo is empty", "Fix: set state_repo = \"OWNER/your-private-state-repo\" (your own private repo), then run `ddc install`"),
|
|
20
|
+
("image is a mutable tag", "Fix: set image = \"...@sha256:<digest>\" in config.toml"),
|
|
21
|
+
("push_claude_credentials", "Fix: set push_claude_credentials = false unless you need Claude Code logged in on the box"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _provider(name: str | None):
|
|
26
|
+
from . import providers
|
|
27
|
+
return providers.get(name or os.environ.get("CLOUDDECK_PROVIDER") or "akash")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _need_config(a: argparse.Namespace) -> None:
|
|
31
|
+
"""Commands that can spend need a real config. The simulated demo provider does not: it touches nothing."""
|
|
32
|
+
from .engine import ddc
|
|
33
|
+
from .providers.base import ProviderError
|
|
34
|
+
if ddc.CONFIG_ERROR and (a.provider or os.environ.get("CLOUDDECK_PROVIDER") or "akash") != "demo":
|
|
35
|
+
raise ProviderError(f"{ddc.CONFIG_ERROR}\n fix: run `ddc init` (creates a config with safe default limits), or try the simulated provider: clouddeck -p demo")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_tui(a: argparse.Namespace) -> int:
|
|
39
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
40
|
+
print("CloudDeck needs an interactive terminal. For scripts use: clouddeck status --json", file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
_need_config(a)
|
|
43
|
+
try:
|
|
44
|
+
from .tui.app import CloudDeckApp
|
|
45
|
+
except ImportError as e:
|
|
46
|
+
print(f"Missing dependency ({e}). Install with: pip install clouddeck", file=sys.stderr)
|
|
47
|
+
return 2
|
|
48
|
+
CloudDeckApp(_provider(a.provider)).run()
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_status(a: argparse.Namespace) -> int:
|
|
53
|
+
_need_config(a)
|
|
54
|
+
p = _provider(a.provider)
|
|
55
|
+
insts = p.instances(True)
|
|
56
|
+
s = p.spend()
|
|
57
|
+
if a.json:
|
|
58
|
+
print(json.dumps({"provider": p.id, "balance": s.balance, "burn_usd_hr": s.burn_hr, "today_usd": s.today, "closed_total_usd": s.closed_total,
|
|
59
|
+
"instances": [{k: v for k, v in i.__dict__.items() if k != "meta"} | {"uptime_h": i.uptime_h} for i in insts]}, indent=1, default=str))
|
|
60
|
+
return 0
|
|
61
|
+
bal = f"${s.balance:.2f}" if s.balance is not None else "unknown"
|
|
62
|
+
print(f"{p.label}: balance {bal} | burn ${s.burn_hr:.3f}/h | today ${s.today:.2f} | closed total ${s.closed_total:.2f} | budget guard {'ON' if s.budget_guard else 'off'}")
|
|
63
|
+
if not insts:
|
|
64
|
+
print("No instances running (nothing is billing).")
|
|
65
|
+
for i in insts:
|
|
66
|
+
tag = "" if i.managed else " (external, read-only)"
|
|
67
|
+
print(f" {i.role:8} {i.state:12} {i.cpu:>3} vCPU ${i.usd_hr:.3f}/h up {i.uptime_h:4.1f}h spent ${i.spent_usd:.3f} {i.name}{tag}")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_providers(a: argparse.Namespace) -> int:
|
|
72
|
+
from . import providers
|
|
73
|
+
for name, cls in sorted(providers.available().items()):
|
|
74
|
+
print(f"{name:10} {cls.label:22} {' '.join(sorted(cls.capabilities))}")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_doctor(a: argparse.Namespace) -> int:
|
|
79
|
+
"""Everything CloudDeck needs, checked read-only. Never prints a secret."""
|
|
80
|
+
from .engine import ddc
|
|
81
|
+
bad = 0
|
|
82
|
+
|
|
83
|
+
def line(ok: bool | None, what: str, detail: str = "") -> None:
|
|
84
|
+
nonlocal bad
|
|
85
|
+
bad += ok is False
|
|
86
|
+
print(f" {'PASS' if ok else 'FAIL' if ok is False else 'WARN'} {what}" + (f" — {detail}" if detail else ""))
|
|
87
|
+
|
|
88
|
+
print(f"CloudDeck {__version__}")
|
|
89
|
+
line(sys.version_info >= (3, 11), f"Python {sys.version.split()[0]} (3.11+ needed)")
|
|
90
|
+
try:
|
|
91
|
+
import textual
|
|
92
|
+
line(True, f"Textual {textual.__version__}")
|
|
93
|
+
except ImportError:
|
|
94
|
+
line(False, "Textual not installed", "pip install clouddeck")
|
|
95
|
+
demo = (a.provider or os.environ.get("CLOUDDECK_PROVIDER") or "akash") == "demo"
|
|
96
|
+
if ddc.CONFIG_ERROR:
|
|
97
|
+
if demo and ddc.CONFIG_FILE is None: # the simulated provider needs no config; every real provider does
|
|
98
|
+
line(None, "config: none found", "optional for the demo provider; `ddc init` creates one with safe default limits")
|
|
99
|
+
else:
|
|
100
|
+
line(False, "config", f"{ddc.CONFIG_ERROR}. Fix: run `ddc init` (or set DDC_CONFIG to your config.toml)")
|
|
101
|
+
else:
|
|
102
|
+
line(True, f"config: {ddc.CONFIG_FILE}")
|
|
103
|
+
if not ddc.CONFIG_ERROR: # without a config there are no limits to report (the controller's defaults are not what a real run would use)
|
|
104
|
+
for problem in ddc.config_problems(): # a problem here is refused by the controller too: the real providers cannot run until it is fixed
|
|
105
|
+
line(None if demo else False, f"config problem: {problem}", f"Fix: edit {ddc.CONFIG_FILE}")
|
|
106
|
+
if ddc.CFG.get("budget_guard", True) is not False:
|
|
107
|
+
line(True, f"budget_guard = {ddc.CFG.get('budget_guard', True)}",
|
|
108
|
+
f"ON: budget ${ddc.CFG.get('budget_usd')}, reserve ${ddc.CFG.get('reserve_usd')}, at most {ddc.CFG.get('max_total_leases')} leases / {ddc.CFG.get('max_workers')} workers")
|
|
109
|
+
if ddc.state_repo():
|
|
110
|
+
line(None if PLACEHOLDER_REPO.search(ddc.state_repo()) else True, f"state_repo = {ddc.state_repo()}",
|
|
111
|
+
"this is the documentation placeholder: set your own private repo, or empty it to disable checkpoints" if PLACEHOLDER_REPO.search(ddc.state_repo()) else "")
|
|
112
|
+
for w in ddc.config_warnings():
|
|
113
|
+
line(None, w, next((fix for start, fix in WARNING_FIXES if w.startswith(start)), ""))
|
|
114
|
+
if demo: # simulated provider: no ssh, keys, secrets or network are involved
|
|
115
|
+
print(" ---- demo provider: simulated, nothing else to check")
|
|
116
|
+
return 1 if bad else 0
|
|
117
|
+
line(shutil.which(ddc.SSH) is not None or Path(ddc.SSH).exists(), f"ssh client: {ddc.SSH}")
|
|
118
|
+
line(shutil.which(ddc.GPG) is not None or Path(ddc.GPG).exists(), f"gpg (checkpoint verification): {ddc.GPG}")
|
|
119
|
+
secrets = ddc.load_secrets()
|
|
120
|
+
line("AKASH_API_KEY" in secrets, f"AKASH_API_KEY present in {ddc.SECRETS_DIR / 'secrets.env'}")
|
|
121
|
+
line("DDC_BACKUP_PASSPHRASE" in secrets, "DDC_BACKUP_PASSPHRASE present (needed to decrypt checkpoints)")
|
|
122
|
+
line((ddc.KEYS / "access").exists(), f"ssh access key in {ddc.KEYS}")
|
|
123
|
+
try:
|
|
124
|
+
n = len(ddc.public_get("/v1/providers"))
|
|
125
|
+
line(True, f"Akash Console API reachable ({n} providers listed)")
|
|
126
|
+
except Exception as e:
|
|
127
|
+
line(False, "Akash Console API unreachable", str(e)[:100])
|
|
128
|
+
if "AKASH_API_KEY" in secrets:
|
|
129
|
+
try:
|
|
130
|
+
line(True, f"API key accepted; balance ${ddc.balance_usd():.2f}")
|
|
131
|
+
except Exception as e:
|
|
132
|
+
line(False, "API key rejected or API error", str(e)[:100])
|
|
133
|
+
live: list[dict] | None = None
|
|
134
|
+
try:
|
|
135
|
+
live = [d for d in ddc.load_state()["deps"].values() if d.get("status") != "closed"]
|
|
136
|
+
line(True, f"{len(live)} lease(s) recorded as running in {ddc.STATE_FILE}")
|
|
137
|
+
except Exception as e: # a damaged state file fails closed: no lease is created until it reads cleanly
|
|
138
|
+
hint = "" if "reconcile" in str(e) else ". Fix: restore it from a backup (state.json.bak) or run `ddc reconcile`; leases may still be running: check `ddc status`"
|
|
139
|
+
line(False, f"state file {ddc.STATE_FILE}", f"{str(e)[:700]}{hint}")
|
|
140
|
+
try:
|
|
141
|
+
watchdog = ddc.watchdog_installed()
|
|
142
|
+
except Exception:
|
|
143
|
+
watchdog = None
|
|
144
|
+
if watchdog:
|
|
145
|
+
line(True, "watchdog installed", "closes idle and over-budget boxes even when CloudDeck is not open")
|
|
146
|
+
elif watchdog is None:
|
|
147
|
+
line(None, "watchdog: cannot tell whether it is installed on this system", "run `ddc watchdog --once` every 5 minutes yourself if `ddc install` does not support your scheduler")
|
|
148
|
+
else: # nothing else closes an idle box before the platform's runtime limit: that is a FAIL once something is running
|
|
149
|
+
line(None if live == [] else False, "watchdog is NOT installed", "idle and over-budget boxes are not closed automatically (a watchdog registered for another config does not count). Fix: run "
|
|
150
|
+
f"`ddc install` (Windows, replacing a pre-release {ddc.LEGACY_TASK} task: `ddc install --migrate`)")
|
|
151
|
+
tokens = ddc.STATE_DIR / "clouddeck-tokens.json"
|
|
152
|
+
if os.name != "nt" and tokens.exists() and tokens.stat().st_mode & 0o077:
|
|
153
|
+
line(None, f"{tokens} is readable by other users", f"chmod 600 {tokens}")
|
|
154
|
+
return 1 if bad else 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_install(a: argparse.Namespace) -> int:
|
|
158
|
+
"""Create a `clouddeck` launcher next to the existing ddc / claude-cloud shims. Existing shims are never touched."""
|
|
159
|
+
from .engine import ddc
|
|
160
|
+
py = sys.executable
|
|
161
|
+
root = ddc.CHECKOUT # a plain checkout needs PYTHONPATH; an installed package does not
|
|
162
|
+
if os.name == "nt":
|
|
163
|
+
target = Path.home() / "bin" / "clouddeck.cmd"
|
|
164
|
+
body = "@echo off\r\n" + (f'set "PYTHONPATH={root};%PYTHONPATH%"\r\n' if root else "") + f'"{py}" -m clouddeck %*\r\n'
|
|
165
|
+
else:
|
|
166
|
+
target = Path.home() / ".local" / "bin" / "clouddeck"
|
|
167
|
+
body = "#!/bin/sh\n" + (f'PYTHONPATH="{root}${{PYTHONPATH:+:$PYTHONPATH}}" ' if root else "") + f'exec "{py}" -m clouddeck "$@"\n'
|
|
168
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
target.write_text(body, encoding="utf-8", newline="")
|
|
170
|
+
if os.name != "nt":
|
|
171
|
+
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
172
|
+
print(f"installed {target}")
|
|
173
|
+
on_path = str(target.parent).lower() in (os.environ.get("PATH", "").lower().split(os.pathsep))
|
|
174
|
+
if not on_path:
|
|
175
|
+
print(f"note: {target.parent} is not on PATH in this shell; open a new terminal or add it.")
|
|
176
|
+
print("backward compatible: `ddc` and `claude-cloud` are unchanged.")
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def cmd_serve(a: argparse.Namespace) -> int:
|
|
181
|
+
from . import remote
|
|
182
|
+
if not (a.list_tokens or a.revoke or a.new_token): # token housekeeping needs no config; serving (and scaling for the box) does
|
|
183
|
+
_need_config(a)
|
|
184
|
+
return remote.serve_cli(a)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_remote(a: argparse.Namespace) -> int:
|
|
188
|
+
from . import remote
|
|
189
|
+
return remote.client_cli(a)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
193
|
+
p = argparse.ArgumentParser(prog="clouddeck", description="Keyboard-driven terminal UI for on-demand cloud compute (Akash first).")
|
|
194
|
+
p.add_argument("--version", action="version", version=f"clouddeck {__version__}")
|
|
195
|
+
p.add_argument("-p", "--provider", help="akash (default) | demo (simulated, safe) | any plugin; or set CLOUDDECK_PROVIDER")
|
|
196
|
+
sub = p.add_subparsers(dest="cmd")
|
|
197
|
+
sub.add_parser("tui", help="open the TUI (default)")
|
|
198
|
+
s = sub.add_parser("status", help="print instances and spend, then exit")
|
|
199
|
+
s.add_argument("--json", action="store_true")
|
|
200
|
+
sub.add_parser("providers", help="list providers and their capabilities")
|
|
201
|
+
sub.add_parser("doctor", help="check the environment (read-only)")
|
|
202
|
+
sub.add_parser("install", help="create the `clouddeck` launcher (existing ddc/claude-cloud shims are untouched)")
|
|
203
|
+
s = sub.add_parser("serve", help="controller API on 127.0.0.1 for remote terminals (token-authenticated)")
|
|
204
|
+
s.add_argument("--port", type=int, default=8765)
|
|
205
|
+
s.add_argument("--new-token", metavar="LABEL", help="create a token (printed once) and exit")
|
|
206
|
+
s.add_argument("--scope", choices=["read", "fleet", "admin"], default="read",
|
|
207
|
+
help="read = status only; fleet = also scale (within max_workers and the budget guard), dispatch to workers, checkpoint; admin = also run commands on the dev box")
|
|
208
|
+
s.add_argument("--expires-hours", type=float, default=None, metavar="H",
|
|
209
|
+
help="token lifetime in hours (default 24; with --tunnel: the dev box's runtime limit + 1; 0 = never)")
|
|
210
|
+
s.add_argument("--revoke", metavar="LABEL", help="revoke a token and exit")
|
|
211
|
+
s.add_argument("--list-tokens", action="store_true")
|
|
212
|
+
s.add_argument("--tunnel", action="store_true", help="push a token + clouddeck-remote to the running dev box and keep an SSH reverse tunnel to it open")
|
|
213
|
+
s = sub.add_parser("remote", help="talk to a controller from a remote terminal (e.g. the Akash dev box)")
|
|
214
|
+
s.add_argument("action", choices=["status", "instances", "scale", "dispatch", "checkpoint"])
|
|
215
|
+
s.add_argument("arg", nargs="*")
|
|
216
|
+
s.add_argument("--url", help="default $CLOUDDECK_URL or http://127.0.0.1:8765")
|
|
217
|
+
s.add_argument("--json", action="store_true")
|
|
218
|
+
for sp in sub.choices.values(): # also accept `clouddeck doctor -p demo` (SUPPRESS keeps the value given before the subcommand)
|
|
219
|
+
sp.add_argument("-p", "--provider", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
220
|
+
return p
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main(argv: list[str] | None = None) -> int:
|
|
224
|
+
a = build_parser().parse_args(argv)
|
|
225
|
+
fn = {None: cmd_tui, "tui": cmd_tui, "status": cmd_status, "providers": cmd_providers, "doctor": cmd_doctor, "install": cmd_install,
|
|
226
|
+
"serve": cmd_serve, "remote": cmd_remote}[a.cmd]
|
|
227
|
+
try:
|
|
228
|
+
return fn(a)
|
|
229
|
+
except KeyboardInterrupt:
|
|
230
|
+
return 130
|
|
231
|
+
except Exception as e:
|
|
232
|
+
from .engine import DdcError
|
|
233
|
+
from .providers.base import ProviderError
|
|
234
|
+
if isinstance(e, (ProviderError, DdcError)): # includes subclasses such as a damaged-state error
|
|
235
|
+
print(f"error: {e}", file=sys.stderr)
|
|
236
|
+
return 1
|
|
237
|
+
if os.environ.get("CLOUDDECK_DEBUG"):
|
|
238
|
+
raise
|
|
239
|
+
print(f"error: {type(e).__name__}: {e}\n (unexpected; set CLOUDDECK_DEBUG=1 for the full traceback and please report it)", file=sys.stderr)
|
|
240
|
+
return 1
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def claude_cloud(argv: list[str] | None = None) -> int:
|
|
244
|
+
"""`claude-cloud [window]` on Linux/macOS installs: same as `ddc attach`."""
|
|
245
|
+
from .engine import ddc
|
|
246
|
+
return ddc.main(["attach", *(sys.argv[1:] if argv is None else argv)])
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
raise SystemExit(main())
|