hivepods-cli 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.
- hivepods_cli/__init__.py +3 -0
- hivepods_cli/browser_login.py +193 -0
- hivepods_cli/client.py +410 -0
- hivepods_cli/config.py +137 -0
- hivepods_cli/main.py +808 -0
- hivepods_cli/terminal.py +314 -0
- hivepods_cli-0.1.0.dist-info/METADATA +126 -0
- hivepods_cli-0.1.0.dist-info/RECORD +11 -0
- hivepods_cli-0.1.0.dist-info/WHEEL +5 -0
- hivepods_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hivepods_cli-0.1.0.dist-info/top_level.txt +1 -0
hivepods_cli/__init__.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Browser sign-in for the CLI.
|
|
2
|
+
|
|
3
|
+
Starts a throwaway web server on 127.0.0.1, opens the DockHive sign-in page in
|
|
4
|
+
the browser, and waits for the page to hand the session back to that local
|
|
5
|
+
address once the user has approved. Falls back cleanly if no browser is around.
|
|
6
|
+
|
|
7
|
+
The page can hand the session back two ways:
|
|
8
|
+
* POST /callback (form or JSON body with state/token/refresh) — preferred, the
|
|
9
|
+
token never appears in a URL or the browser history;
|
|
10
|
+
* GET /callback?state=…&token=… — still accepted; the reply immediately
|
|
11
|
+
replaces the history entry so the token doesn't linger there.
|
|
12
|
+
|
|
13
|
+
Anything else (wrong path, wrong state, wrong Host, stray requests from other
|
|
14
|
+
pages) gets a 4xx and is ignored: we keep waiting for the genuine callback until
|
|
15
|
+
the timeout.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import secrets
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import webbrowser
|
|
25
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
# How long we'll sit waiting for the browser round-trip before giving up.
|
|
29
|
+
TIMEOUT_SECONDS = 300
|
|
30
|
+
MAX_BODY = 64 * 1024
|
|
31
|
+
|
|
32
|
+
_DONE_PAGE = b"""<!doctype html><meta charset="utf-8">
|
|
33
|
+
<meta name="referrer" content="no-referrer">
|
|
34
|
+
<title>DockHive CLI</title>
|
|
35
|
+
<script>try{history.replaceState(null,'','/done')}catch(e){}</script>
|
|
36
|
+
<style>body{font:16px -apple-system,Segoe UI,sans-serif;background:#0b0c0f;color:#e8e8e8;
|
|
37
|
+
display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
|
|
38
|
+
div{text-align:center;max-width:22rem;padding:2rem}</style>
|
|
39
|
+
<div><h2>%s</h2><p>%s</p></div>
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Result:
|
|
44
|
+
token: Optional[str] = None
|
|
45
|
+
refresh: Optional[str] = None
|
|
46
|
+
error: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
50
|
+
# bound by start_login() before the server spins up
|
|
51
|
+
expected_state = ""
|
|
52
|
+
expected_port = 0
|
|
53
|
+
result = _Result()
|
|
54
|
+
done = None # threading.Event set once a genuine callback was handled
|
|
55
|
+
|
|
56
|
+
def log_message(self, *_args): # silence the default stderr logging
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def _reply(self, heading: str, detail: str, code: int = 200):
|
|
60
|
+
body = _DONE_PAGE % (heading.encode(), detail.encode())
|
|
61
|
+
self.send_response(code)
|
|
62
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
63
|
+
self.send_header("Content-Length", str(len(body)))
|
|
64
|
+
self.send_header("Cache-Control", "no-store")
|
|
65
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
66
|
+
self.end_headers()
|
|
67
|
+
self.wfile.write(body)
|
|
68
|
+
|
|
69
|
+
def _host_ok(self) -> bool:
|
|
70
|
+
# Blocks DNS-rebinding pages from talking to the loopback server.
|
|
71
|
+
host = (self.headers.get("Host") or "").lower()
|
|
72
|
+
return host in (f"127.0.0.1:{self.expected_port}", f"localhost:{self.expected_port}")
|
|
73
|
+
|
|
74
|
+
def _handle(self, params: dict):
|
|
75
|
+
if not self._host_ok():
|
|
76
|
+
self._reply("Not allowed", "You can close this window.", code=421)
|
|
77
|
+
return
|
|
78
|
+
state = params.get("state") or ""
|
|
79
|
+
if not isinstance(state, str) or not secrets.compare_digest(
|
|
80
|
+
state.encode(), type(self).expected_state.encode()):
|
|
81
|
+
# Not our callback (stale tab, another page probing): ignore it.
|
|
82
|
+
self._reply("Something went wrong", "This sign-in link is not valid. Close this and retry.",
|
|
83
|
+
code=400)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
result = type(self).result
|
|
87
|
+
if params.get("error"):
|
|
88
|
+
result.error = "sign-in was cancelled"
|
|
89
|
+
self._reply("Sign-in cancelled", "You can close this window.")
|
|
90
|
+
type(self).done.set()
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
token = params.get("token") or ""
|
|
94
|
+
if not isinstance(token, str) or not token:
|
|
95
|
+
self._reply("Something went wrong", "No session was returned. Close this and retry.", code=400)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
result.token = token
|
|
99
|
+
refresh = params.get("refresh")
|
|
100
|
+
result.refresh = refresh if isinstance(refresh, str) and refresh else None
|
|
101
|
+
self._reply("You're signed in", "Head back to your terminal — you can close this window.")
|
|
102
|
+
type(self).done.set()
|
|
103
|
+
|
|
104
|
+
def do_GET(self):
|
|
105
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
106
|
+
if parsed.path != "/callback":
|
|
107
|
+
self._reply("Not found", "You can close this window.", code=404)
|
|
108
|
+
return
|
|
109
|
+
params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
|
|
110
|
+
self._handle(params)
|
|
111
|
+
|
|
112
|
+
def do_POST(self):
|
|
113
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
114
|
+
if parsed.path != "/callback":
|
|
115
|
+
self._reply("Not found", "You can close this window.", code=404)
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
119
|
+
except ValueError:
|
|
120
|
+
length = 0
|
|
121
|
+
if length <= 0 or length > MAX_BODY:
|
|
122
|
+
self._reply("Something went wrong", "Invalid request.", code=400)
|
|
123
|
+
return
|
|
124
|
+
raw = self.rfile.read(length).decode("utf-8", "replace")
|
|
125
|
+
ctype = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
|
126
|
+
if ctype == "application/json":
|
|
127
|
+
try:
|
|
128
|
+
params = json.loads(raw)
|
|
129
|
+
except ValueError:
|
|
130
|
+
params = None
|
|
131
|
+
if not isinstance(params, dict):
|
|
132
|
+
self._reply("Something went wrong", "Invalid request.", code=400)
|
|
133
|
+
return
|
|
134
|
+
else:
|
|
135
|
+
params = {k: v[0] for k, v in urllib.parse.parse_qs(raw).items()}
|
|
136
|
+
self._handle(params)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def start_login(web_url: str, open_browser: bool = True,
|
|
140
|
+
timeout: float = TIMEOUT_SECONDS) -> _Result:
|
|
141
|
+
"""Run the loopback sign-in dance and return the captured session.
|
|
142
|
+
|
|
143
|
+
Raises RuntimeError on timeout or if the browser reports an error.
|
|
144
|
+
"""
|
|
145
|
+
state = secrets.token_urlsafe(24)
|
|
146
|
+
|
|
147
|
+
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
|
148
|
+
port = server.server_address[1]
|
|
149
|
+
done = threading.Event()
|
|
150
|
+
_Handler.expected_state = state
|
|
151
|
+
_Handler.expected_port = port
|
|
152
|
+
_Handler.result = _Result()
|
|
153
|
+
_Handler.done = done
|
|
154
|
+
|
|
155
|
+
auth_url = (
|
|
156
|
+
web_url.rstrip("/")
|
|
157
|
+
+ "/cli-auth?"
|
|
158
|
+
+ urllib.parse.urlencode({"port": port, "state": state})
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
deadline = time.monotonic() + timeout
|
|
162
|
+
|
|
163
|
+
def _serve():
|
|
164
|
+
# Keep answering until the genuine callback arrives or time runs out;
|
|
165
|
+
# wrong-state / stray requests don't end the wait.
|
|
166
|
+
while not done.is_set():
|
|
167
|
+
remaining = deadline - time.monotonic()
|
|
168
|
+
if remaining <= 0:
|
|
169
|
+
break
|
|
170
|
+
server.timeout = min(remaining, 1.0)
|
|
171
|
+
server.handle_request()
|
|
172
|
+
|
|
173
|
+
worker = threading.Thread(target=_serve, daemon=True)
|
|
174
|
+
worker.start()
|
|
175
|
+
|
|
176
|
+
opened = webbrowser.open(auth_url) if open_browser else False
|
|
177
|
+
if not opened:
|
|
178
|
+
print("Open this URL in your browser to sign in:\n " + auth_url)
|
|
179
|
+
|
|
180
|
+
worker.join(timeout + 5)
|
|
181
|
+
try:
|
|
182
|
+
server.server_close()
|
|
183
|
+
except OSError:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
result = _Handler.result
|
|
187
|
+
if not done.is_set():
|
|
188
|
+
raise RuntimeError("timed out waiting for the browser sign-in")
|
|
189
|
+
if result.error:
|
|
190
|
+
raise RuntimeError(result.error)
|
|
191
|
+
if not result.token:
|
|
192
|
+
raise RuntimeError("no session was returned")
|
|
193
|
+
return result
|
hivepods_cli/client.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""HivePods API client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
from urllib.parse import urlparse, urlunparse
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Statuses a pod passes through while the server is still working on it.
|
|
15
|
+
TRANSITIONAL = frozenset({
|
|
16
|
+
"provisioning", "initializing", "starting", "stopping", "restarting",
|
|
17
|
+
"suspending", "resuming", "applying", "terminating",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WaitTimeout(Exception):
|
|
22
|
+
"""Polling gave up; `last` is the last representation seen."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, last: Optional[dict]):
|
|
25
|
+
super().__init__("timed out")
|
|
26
|
+
self.last = last
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class APIError(Exception):
|
|
30
|
+
"""Raised when the API returns an error. The message is safe to show."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str, status: Optional[int] = None):
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.status = status
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Anything mentioning these is an implementation detail, not something to show.
|
|
38
|
+
_INTERNAL_WORDS = re.compile(
|
|
39
|
+
r"docker|container|kubernetes|k8s|kvm|qemu|traceback|daemon|celery|redis|"
|
|
40
|
+
r"postgres|psycopg|django|exception|errno|/var/|/app/|sha256:",
|
|
41
|
+
re.IGNORECASE,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_GENERIC = {
|
|
45
|
+
400: "The request wasn't accepted. Check the values and try again.",
|
|
46
|
+
409: "That can't be done right now. Check the environment's status and try again.",
|
|
47
|
+
413: "That file is too large.",
|
|
48
|
+
429: "Too many requests — please wait a moment and try again.",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _jwt_expired(token: str, skew: int = 30) -> bool:
|
|
53
|
+
"""True if `token` is a JWT whose exp has passed (unverified peek)."""
|
|
54
|
+
parts = token.split(".")
|
|
55
|
+
if len(parts) != 3:
|
|
56
|
+
return False
|
|
57
|
+
try:
|
|
58
|
+
pad = "=" * (-len(parts[1]) % 4)
|
|
59
|
+
claims = json.loads(base64.urlsafe_b64decode(parts[1] + pad))
|
|
60
|
+
exp = claims.get("exp")
|
|
61
|
+
except (ValueError, TypeError):
|
|
62
|
+
return False
|
|
63
|
+
return isinstance(exp, (int, float)) and exp <= time.time() + skew
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class APIClient:
|
|
67
|
+
def __init__(self, api_url: str, token: Optional[str] = None, timeout: int = 30,
|
|
68
|
+
refresh_token: Optional[str] = None,
|
|
69
|
+
on_tokens: Optional[Callable[[str, Optional[str]], None]] = None):
|
|
70
|
+
self.api_url = api_url.rstrip("/")
|
|
71
|
+
self.token = token
|
|
72
|
+
self.refresh_token = refresh_token
|
|
73
|
+
# Called with (access, refresh) whenever the session is renewed, so the
|
|
74
|
+
# caller can persist it.
|
|
75
|
+
self.on_tokens = on_tokens
|
|
76
|
+
self.timeout = timeout
|
|
77
|
+
|
|
78
|
+
# ---- internals -----------------------------------------------------
|
|
79
|
+
def _headers(self) -> dict:
|
|
80
|
+
headers = {"Accept": "application/json"}
|
|
81
|
+
if self.token:
|
|
82
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
83
|
+
return headers
|
|
84
|
+
|
|
85
|
+
def refresh(self) -> bool:
|
|
86
|
+
"""Swap the refresh token for a new access token. True on success."""
|
|
87
|
+
if not self.refresh_token:
|
|
88
|
+
return False
|
|
89
|
+
try:
|
|
90
|
+
resp = requests.post(
|
|
91
|
+
f"{self.api_url}/api/v1/auth/refresh",
|
|
92
|
+
json={"refresh": self.refresh_token},
|
|
93
|
+
headers={"Accept": "application/json"},
|
|
94
|
+
timeout=self.timeout,
|
|
95
|
+
)
|
|
96
|
+
except requests.RequestException:
|
|
97
|
+
return False
|
|
98
|
+
if resp.status_code != 200:
|
|
99
|
+
return False
|
|
100
|
+
try:
|
|
101
|
+
data = resp.json()
|
|
102
|
+
except ValueError:
|
|
103
|
+
return False
|
|
104
|
+
access = data.get("access")
|
|
105
|
+
if not access:
|
|
106
|
+
return False
|
|
107
|
+
self.token = access
|
|
108
|
+
# The auth service rotates refresh tokens; keep the new one if sent.
|
|
109
|
+
self.refresh_token = data.get("refresh") or self.refresh_token
|
|
110
|
+
if self.on_tokens:
|
|
111
|
+
self.on_tokens(self.token, self.refresh_token)
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
def ensure_fresh(self) -> None:
|
|
115
|
+
"""Renew an expired access token up front (e.g. before opening a socket)."""
|
|
116
|
+
if self.token and self.refresh_token and _jwt_expired(self.token):
|
|
117
|
+
self.refresh()
|
|
118
|
+
|
|
119
|
+
def _request(self, method: str, path: str, _retried: bool = False, **kwargs):
|
|
120
|
+
if not _retried:
|
|
121
|
+
self.ensure_fresh()
|
|
122
|
+
url = f"{self.api_url}{path}"
|
|
123
|
+
extra = kwargs.pop("headers", None) or {} # per-call extras (X-Region)
|
|
124
|
+
headers = self._headers()
|
|
125
|
+
headers.update(extra)
|
|
126
|
+
try:
|
|
127
|
+
resp = requests.request(
|
|
128
|
+
method, url, headers=headers, timeout=self.timeout, **kwargs
|
|
129
|
+
)
|
|
130
|
+
except requests.RequestException:
|
|
131
|
+
host = urlparse(url).netloc or url
|
|
132
|
+
raise APIError(f"Could not reach DockHive ({host}). Check your connection and try again.")
|
|
133
|
+
|
|
134
|
+
if resp.status_code == 401:
|
|
135
|
+
if not _retried and self.refresh():
|
|
136
|
+
return self._request(method, path, _retried=True, headers=extra, **kwargs)
|
|
137
|
+
raise APIError("Please sign in first: run `hivepod login`.", 401)
|
|
138
|
+
if resp.status_code == 403:
|
|
139
|
+
raise APIError("Permission denied for this resource.", 403)
|
|
140
|
+
if resp.status_code == 404:
|
|
141
|
+
raise APIError("Not found.", 404)
|
|
142
|
+
if resp.status_code >= 500:
|
|
143
|
+
raise APIError(_server_message(resp), resp.status_code)
|
|
144
|
+
if resp.status_code >= 400:
|
|
145
|
+
raise APIError(_format_error(resp), resp.status_code)
|
|
146
|
+
if resp.status_code == 204 or not resp.content:
|
|
147
|
+
return None
|
|
148
|
+
return resp.json()
|
|
149
|
+
|
|
150
|
+
def _paginated(self, path: str, params: Optional[dict] = None) -> list:
|
|
151
|
+
"""Return all results, following DRF page pagination."""
|
|
152
|
+
items: list = []
|
|
153
|
+
data = self._request("GET", path, params=params)
|
|
154
|
+
while True:
|
|
155
|
+
if isinstance(data, dict) and "results" in data:
|
|
156
|
+
items.extend(data["results"])
|
|
157
|
+
nxt = data.get("next")
|
|
158
|
+
if not nxt:
|
|
159
|
+
break
|
|
160
|
+
# `next` may name an internal host; keep our base, take its path+query.
|
|
161
|
+
parsed = urlparse(nxt)
|
|
162
|
+
data = self._request("GET", parsed.path + (f"?{parsed.query}" if parsed.query else ""))
|
|
163
|
+
elif isinstance(data, list):
|
|
164
|
+
items.extend(data)
|
|
165
|
+
break
|
|
166
|
+
else:
|
|
167
|
+
break
|
|
168
|
+
return items
|
|
169
|
+
|
|
170
|
+
# ---- auth ----------------------------------------------------------
|
|
171
|
+
def login(self, email: str, password: str, mfa_code: Optional[str] = None) -> dict:
|
|
172
|
+
"""Password sign-in. Returns the response; {'requires_mfa': True} means
|
|
173
|
+
call again with mfa_code."""
|
|
174
|
+
body = {"email": email, "password": password}
|
|
175
|
+
if mfa_code:
|
|
176
|
+
body["mfa_code"] = mfa_code
|
|
177
|
+
data = self._request("POST", "/api/v1/auth/login", json=body) or {}
|
|
178
|
+
if data.get("requires_mfa"):
|
|
179
|
+
return data
|
|
180
|
+
token = data.get("access")
|
|
181
|
+
if not token:
|
|
182
|
+
raise APIError("Sign-in succeeded but no session was returned.")
|
|
183
|
+
self.token = token
|
|
184
|
+
self.refresh_token = data.get("refresh")
|
|
185
|
+
return data
|
|
186
|
+
|
|
187
|
+
def logout(self) -> None:
|
|
188
|
+
"""Best-effort: revoke the refresh token on the server."""
|
|
189
|
+
if not (self.token and self.refresh_token):
|
|
190
|
+
return
|
|
191
|
+
try:
|
|
192
|
+
requests.post(f"{self.api_url}/api/v1/auth/logout",
|
|
193
|
+
json={"refresh": self.refresh_token},
|
|
194
|
+
headers=self._headers(), timeout=10)
|
|
195
|
+
except requests.RequestException:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
# ---- OS catalog ----------------------------------------------------
|
|
199
|
+
def list_os(self, include_gated: bool = False, family: Optional[str] = None) -> list:
|
|
200
|
+
params = {}
|
|
201
|
+
if include_gated:
|
|
202
|
+
params["available"] = "all"
|
|
203
|
+
if family:
|
|
204
|
+
params["family"] = family
|
|
205
|
+
return self._paginated("/api/v1/hivepods/catalog/os-images/", params=params)
|
|
206
|
+
|
|
207
|
+
# ---- pods ----------------------------------------------------------
|
|
208
|
+
def list_pods(self) -> list:
|
|
209
|
+
return self._paginated("/api/v1/hivepods/")
|
|
210
|
+
|
|
211
|
+
def get_pod(self, pod_id: str) -> dict:
|
|
212
|
+
return self._request("GET", f"/api/v1/hivepods/{pod_id}/")
|
|
213
|
+
|
|
214
|
+
def create_pod(self, payload: dict, region: Optional[str] = None) -> dict:
|
|
215
|
+
# X-Region lets the gateway route the create to a chosen region (multi-region).
|
|
216
|
+
headers = {"X-Region": region} if region else None
|
|
217
|
+
return self._request("POST", "/api/v1/hivepods/", json=payload, headers=headers)
|
|
218
|
+
|
|
219
|
+
def list_tiers(self) -> list:
|
|
220
|
+
"""Resource sizes + specs. Falls back to built-ins on older servers."""
|
|
221
|
+
try:
|
|
222
|
+
return self._request("GET", "/api/v1/hivepods/tiers/") or []
|
|
223
|
+
except APIError:
|
|
224
|
+
return [
|
|
225
|
+
{"tier": "nano", "label": "Nano", "cpu": 0.5, "memory": "512m", "storage": "5g"},
|
|
226
|
+
{"tier": "micro", "label": "Micro", "cpu": 1, "memory": "1g", "storage": "10g"},
|
|
227
|
+
{"tier": "small", "label": "Small", "cpu": 2, "memory": "2g", "storage": "25g"},
|
|
228
|
+
{"tier": "medium", "label": "Medium", "cpu": 4, "memory": "4g", "storage": "50g"},
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
def list_regions(self) -> list:
|
|
232
|
+
"""Regions available for pod creation (best-effort)."""
|
|
233
|
+
try:
|
|
234
|
+
data = self._request("GET", "/api/v1/hivepods/regions/")
|
|
235
|
+
if isinstance(data, dict):
|
|
236
|
+
return data.get("regions", [])
|
|
237
|
+
return data or []
|
|
238
|
+
except APIError:
|
|
239
|
+
return []
|
|
240
|
+
|
|
241
|
+
def delete_pod(self, pod_id: str) -> None:
|
|
242
|
+
self._request("DELETE", f"/api/v1/hivepods/{pod_id}/")
|
|
243
|
+
|
|
244
|
+
def pod_action(self, pod_id: str, action: str, parameters: Optional[dict] = None) -> dict:
|
|
245
|
+
"""Request a lifecycle action. The server answers 202 with the pod's
|
|
246
|
+
transitional status (older servers: 200 once done); see wait_for_pod."""
|
|
247
|
+
return self._request(
|
|
248
|
+
"POST", f"/api/v1/hivepods/{pod_id}/execute_action/",
|
|
249
|
+
json={"action": action, "parameters": parameters or {}},
|
|
250
|
+
) or {}
|
|
251
|
+
|
|
252
|
+
def update_pod(self, pod_id: str, changes: dict) -> dict:
|
|
253
|
+
return self._request("PATCH", f"/api/v1/hivepods/{pod_id}/", json=changes)
|
|
254
|
+
|
|
255
|
+
def wait_for_pod(self, pod_id: str, timeout: float = 300, interval: float = 1.0,
|
|
256
|
+
on_status: Optional[Callable[[str], None]] = None,
|
|
257
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
258
|
+
clock: Callable[[], float] = time.monotonic) -> Optional[dict]:
|
|
259
|
+
"""Poll until the pod leaves every transitional status. Returns the pod,
|
|
260
|
+
or None once it no longer exists (destroyed). Raises WaitTimeout."""
|
|
261
|
+
deadline = clock() + timeout
|
|
262
|
+
last = None
|
|
263
|
+
delay = interval
|
|
264
|
+
while True:
|
|
265
|
+
try:
|
|
266
|
+
last = self.get_pod(pod_id)
|
|
267
|
+
except APIError as exc:
|
|
268
|
+
if exc.status == 404:
|
|
269
|
+
return None
|
|
270
|
+
raise
|
|
271
|
+
status = (last or {}).get("status", "")
|
|
272
|
+
if on_status:
|
|
273
|
+
on_status(status)
|
|
274
|
+
if status not in TRANSITIONAL:
|
|
275
|
+
return last
|
|
276
|
+
if clock() >= deadline:
|
|
277
|
+
raise WaitTimeout(last)
|
|
278
|
+
sleep(delay)
|
|
279
|
+
delay = min(delay * 1.5, 5.0)
|
|
280
|
+
|
|
281
|
+
# ---- backups -------------------------------------------------------
|
|
282
|
+
def create_backup(self, pod_id: str, name: str, description: str = "") -> dict:
|
|
283
|
+
return self._request("POST", f"/api/v1/hivepods/{pod_id}/backup/",
|
|
284
|
+
json={"name": name, "description": description})
|
|
285
|
+
|
|
286
|
+
def list_backups(self, pod_id: Optional[str] = None) -> list:
|
|
287
|
+
params = {"pod_id": pod_id} if pod_id else None
|
|
288
|
+
return self._paginated("/api/v1/hivepods/backups/", params=params)
|
|
289
|
+
|
|
290
|
+
def get_backup(self, backup_id: str) -> dict:
|
|
291
|
+
return self._request("GET", f"/api/v1/hivepods/backups/{backup_id}/")
|
|
292
|
+
|
|
293
|
+
def delete_backup(self, backup_id: str) -> None:
|
|
294
|
+
self._request("DELETE", f"/api/v1/hivepods/backups/{backup_id}/")
|
|
295
|
+
|
|
296
|
+
def restore_backup(self, backup_id: str, name: str, **options) -> dict:
|
|
297
|
+
body = {"name": name}
|
|
298
|
+
body.update({k: v for k, v in options.items() if v not in (None, "")})
|
|
299
|
+
return self._request("POST", f"/api/v1/hivepods/backups/{backup_id}/restore/", json=body)
|
|
300
|
+
|
|
301
|
+
def wait_for_backup(self, backup_id: str, timeout: float = 3600, interval: float = 2.0,
|
|
302
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
303
|
+
clock: Callable[[], float] = time.monotonic) -> dict:
|
|
304
|
+
deadline = clock() + timeout
|
|
305
|
+
delay = interval
|
|
306
|
+
while True:
|
|
307
|
+
backup = self.get_backup(backup_id)
|
|
308
|
+
if backup.get("status") != "creating":
|
|
309
|
+
return backup
|
|
310
|
+
if clock() >= deadline:
|
|
311
|
+
raise WaitTimeout(backup)
|
|
312
|
+
sleep(delay)
|
|
313
|
+
delay = min(delay * 1.5, 10.0)
|
|
314
|
+
|
|
315
|
+
def resolve_backup(self, ref: str) -> dict:
|
|
316
|
+
"""A backup by full id or a unique id prefix (as shown by `backup list`)."""
|
|
317
|
+
ref = ref.strip().lower()
|
|
318
|
+
backups = self.list_backups()
|
|
319
|
+
exact = [b for b in backups if str(b.get("id", "")).lower() == ref]
|
|
320
|
+
if exact:
|
|
321
|
+
return exact[0]
|
|
322
|
+
if len(ref) >= 4:
|
|
323
|
+
matches = [b for b in backups if str(b.get("id", "")).lower().startswith(ref)]
|
|
324
|
+
if len(matches) == 1:
|
|
325
|
+
return matches[0]
|
|
326
|
+
if len(matches) > 1:
|
|
327
|
+
raise APIError(f"'{ref}' matches more than one backup; use more of the id.")
|
|
328
|
+
raise APIError(f"No backup with id '{ref}'.")
|
|
329
|
+
|
|
330
|
+
def pod_exec(self, pod_id: str, command: str) -> dict:
|
|
331
|
+
"""Run one command; returns {'exit_code', 'output', 'truncated', 'timed_out'}."""
|
|
332
|
+
return self._request(
|
|
333
|
+
"POST", f"/api/v1/hivepods/{pod_id}/exec/", json={"command": command},
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def pod_metrics(self, pod_id: str) -> dict:
|
|
337
|
+
return self._request("GET", f"/api/v1/hivepods/{pod_id}/metrics/")
|
|
338
|
+
|
|
339
|
+
def pod_logs(self, pod_id: str, tail: int = 100) -> dict:
|
|
340
|
+
return self._request("GET", f"/api/v1/hivepods/{pod_id}/logs/", params={"tail": int(tail)})
|
|
341
|
+
|
|
342
|
+
def resolve_pod(self, name_or_id: str) -> dict:
|
|
343
|
+
"""Accept a pod's id (region-tagged, as listed) or its name; return the pod."""
|
|
344
|
+
pods = self.list_pods()
|
|
345
|
+
for pod in pods:
|
|
346
|
+
if pod.get("id") == name_or_id or pod.get("name") == name_or_id:
|
|
347
|
+
return pod
|
|
348
|
+
# A bare uuid still matches a region-tagged id (<region>-<uuid>).
|
|
349
|
+
for pod in pods:
|
|
350
|
+
if str(pod.get("id", "")).endswith("-" + name_or_id) and len(name_or_id) == 36:
|
|
351
|
+
return pod
|
|
352
|
+
raise APIError(f"No environment named or with id '{name_or_id}'.")
|
|
353
|
+
|
|
354
|
+
# ---- websocket helpers ---------------------------------------------
|
|
355
|
+
def ws_url(self, path: str) -> str:
|
|
356
|
+
parsed = urlparse(self.api_url)
|
|
357
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
358
|
+
base_path = parsed.path.rstrip("/")
|
|
359
|
+
return urlunparse((scheme, parsed.netloc, base_path + path, "", "", ""))
|
|
360
|
+
|
|
361
|
+
def pod_ws_url(self, pod_id: str, kind: str) -> str:
|
|
362
|
+
"""Terminal/log socket for a pod, under the hivepods prefix the gateway routes."""
|
|
363
|
+
return self.ws_url(f"/api/v1/hivepods/ws/pods/{pod_id}/{kind}/")
|
|
364
|
+
|
|
365
|
+
def ws_headers(self) -> dict:
|
|
366
|
+
"""Credentials for sockets go in a header, never the URL (URLs get logged)."""
|
|
367
|
+
self.ensure_fresh()
|
|
368
|
+
return {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _clean(text: str) -> Optional[str]:
|
|
372
|
+
text = str(text).strip()
|
|
373
|
+
if not text or _INTERNAL_WORDS.search(text):
|
|
374
|
+
return None
|
|
375
|
+
return text[:300]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _server_message(resp) -> str:
|
|
379
|
+
if resp.status_code in (502, 503, 504):
|
|
380
|
+
return "DockHive is temporarily unavailable. Please try again in a moment."
|
|
381
|
+
try:
|
|
382
|
+
data = resp.json()
|
|
383
|
+
except ValueError:
|
|
384
|
+
data = None
|
|
385
|
+
if isinstance(data, dict):
|
|
386
|
+
msg = _clean(data.get("error") or data.get("message") or "")
|
|
387
|
+
if msg:
|
|
388
|
+
return msg
|
|
389
|
+
return "Something went wrong on our side. Please try again."
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _format_error(resp) -> str:
|
|
393
|
+
try:
|
|
394
|
+
data = resp.json()
|
|
395
|
+
except ValueError:
|
|
396
|
+
return _GENERIC.get(resp.status_code, f"The request failed (HTTP {resp.status_code}).")
|
|
397
|
+
if isinstance(data, dict):
|
|
398
|
+
parts = []
|
|
399
|
+
for key, val in data.items():
|
|
400
|
+
if isinstance(val, list):
|
|
401
|
+
val = "; ".join(str(v) for v in val)
|
|
402
|
+
elif isinstance(val, dict):
|
|
403
|
+
val = "; ".join(f"{k}: {v}" for k, v in val.items())
|
|
404
|
+
val = _clean(val)
|
|
405
|
+
if val is None:
|
|
406
|
+
continue
|
|
407
|
+
parts.append(val if key in ("error", "message", "detail", "non_field_errors") else f"{key}: {val}")
|
|
408
|
+
if parts:
|
|
409
|
+
return " | ".join(parts)
|
|
410
|
+
return _GENERIC.get(resp.status_code, f"The request failed (HTTP {resp.status_code}).")
|