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.
Files changed (57) hide show
  1. clouddeck/__init__.py +3 -0
  2. clouddeck/__main__.py +5 -0
  3. clouddeck/cli.py +250 -0
  4. clouddeck/controller.py +3417 -0
  5. clouddeck/data/config.example.toml +94 -0
  6. clouddeck/data/jobremote/boot.sh +23 -0
  7. clouddeck/data/jobremote/fetch.sh +26 -0
  8. clouddeck/data/jobremote/step.sh +27 -0
  9. clouddeck/data/lease/README.md +58 -0
  10. clouddeck/data/lease/agent.sh +91 -0
  11. clouddeck/data/lease/bench.sh +43 -0
  12. clouddeck/data/lease/checkpoint.sh +169 -0
  13. clouddeck/data/lease/ddc-lib.sh +73 -0
  14. clouddeck/data/lease/dist.sh +45 -0
  15. clouddeck/data/lease/restore.sh +134 -0
  16. clouddeck/data/lease/setup.sh +95 -0
  17. clouddeck/data/lease/tmux.conf +25 -0
  18. clouddeck/data/lease/workers.sh +23 -0
  19. clouddeck/data/sdl.yaml.tmpl +63 -0
  20. clouddeck/engine.py +37 -0
  21. clouddeck/fmt.py +43 -0
  22. clouddeck/hardware.py +598 -0
  23. clouddeck/job/__init__.py +1 -0
  24. clouddeck/job/__main__.py +3 -0
  25. clouddeck/job/cli.py +153 -0
  26. clouddeck/job/clock.py +20 -0
  27. clouddeck/job/market.py +194 -0
  28. clouddeck/job/preflight.py +196 -0
  29. clouddeck/job/remote.py +190 -0
  30. clouddeck/job/runner.py +731 -0
  31. clouddeck/job/spec.py +400 -0
  32. clouddeck/job/telemetry.py +100 -0
  33. clouddeck/providers/__init__.py +44 -0
  34. clouddeck/providers/akash.py +620 -0
  35. clouddeck/providers/base.py +494 -0
  36. clouddeck/providers/demo.py +329 -0
  37. clouddeck/remote.py +447 -0
  38. clouddeck/remote_client.py +90 -0
  39. clouddeck/sdlgen.py +275 -0
  40. clouddeck/service.py +434 -0
  41. clouddeck/tui/__init__.py +0 -0
  42. clouddeck/tui/app.py +495 -0
  43. clouddeck/tui/clouddeck.tcss +109 -0
  44. clouddeck/tui/compute.py +627 -0
  45. clouddeck/tui/monitor.py +188 -0
  46. clouddeck/tui/picker.py +622 -0
  47. clouddeck/tui/projects.py +265 -0
  48. clouddeck/tui/recovery.py +217 -0
  49. clouddeck/tui/safety.py +120 -0
  50. clouddeck/tui/widgets.py +483 -0
  51. clouddeck/tui/workers.py +421 -0
  52. clouddeck-0.1.0.dist-info/METADATA +322 -0
  53. clouddeck-0.1.0.dist-info/RECORD +57 -0
  54. clouddeck-0.1.0.dist-info/WHEEL +5 -0
  55. clouddeck-0.1.0.dist-info/entry_points.txt +4 -0
  56. clouddeck-0.1.0.dist-info/licenses/LICENSE +21 -0
  57. clouddeck-0.1.0.dist-info/top_level.txt +1 -0
clouddeck/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CloudDeck: a keyboard-driven terminal UI for on-demand cloud compute (Akash first)."""
2
+
3
+ __version__ = "0.1.0"
clouddeck/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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())