hoist-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.
- hoist/__init__.py +4 -0
- hoist/__main__.py +4 -0
- hoist/cli.py +1672 -0
- hoist/config.py +131 -0
- hoist/containers.py +781 -0
- hoist/dash/__init__.py +0 -0
- hoist/dash/collect.py +497 -0
- hoist/dash/fleet.py +140 -0
- hoist/dash/jobs.py +111 -0
- hoist/dash/server.py +979 -0
- hoist/dash/static/app.css +595 -0
- hoist/dash/static/app.js +437 -0
- hoist/dash/static/app.ui.js +501 -0
- hoist/dash/static/app.views.js +1057 -0
- hoist/dash/static/favicon.svg +1 -0
- hoist/dash/static/fonts/IBMPlexMono-400.woff2 +0 -0
- hoist/dash/static/fonts/IBMPlexMono-500.woff2 +0 -0
- hoist/dash/static/fonts/IBMPlexSans.woff2 +0 -0
- hoist/dash/static/fonts/OFL.txt +93 -0
- hoist/dash/static/fonts/fonts.css +3 -0
- hoist/dash/static/index.html +41 -0
- hoist/dash/static/login.html +41 -0
- hoist/dash/static/vendor/XTERM-LICENSE +21 -0
- hoist/dash/static/vendor/addon-fit.js +2 -0
- hoist/dash/static/vendor/xterm.css +218 -0
- hoist/dash/static/vendor/xterm.js +2 -0
- hoist/dash/team.py +241 -0
- hoist/dash/terminal.py +164 -0
- hoist/detect.py +159 -0
- hoist/front.py +457 -0
- hoist/notify.py +111 -0
- hoist/qr.py +417 -0
- hoist/service.py +210 -0
- hoist/source.py +410 -0
- hoist/tunnel.py +376 -0
- hoist/ui.py +101 -0
- hoist_cli-0.1.0.dist-info/METADATA +524 -0
- hoist_cli-0.1.0.dist-info/RECORD +42 -0
- hoist_cli-0.1.0.dist-info/WHEEL +5 -0
- hoist_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hoist_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- hoist_cli-0.1.0.dist-info/top_level.txt +1 -0
hoist/dash/server.py
ADDED
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
"""`hoist dash`: a web dashboard for everything hoist runs.
|
|
2
|
+
|
|
3
|
+
Standard library only. One token guards everything, because the dashboard
|
|
4
|
+
is meant to be published through the same tunnel as the apps -- and behind
|
|
5
|
+
a tunnel every request arrives from 127.0.0.1, so "local means trusted" is
|
|
6
|
+
not a rule this server can use.
|
|
7
|
+
|
|
8
|
+
Security model:
|
|
9
|
+
- the token lives in the state dir (0600) and is shown once by `hoist dash`;
|
|
10
|
+
- the browser exchanges it for an HttpOnly, SameSite=Strict cookie;
|
|
11
|
+
- every state-changing request must also carry `X-Hoist: 1`, which a
|
|
12
|
+
cross-site form cannot set, so the cookie alone is not enough to act.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import json
|
|
20
|
+
import mimetypes
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import secrets
|
|
24
|
+
import subprocess
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
import urllib.parse
|
|
28
|
+
from http import HTTPStatus
|
|
29
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from .. import __version__ as VERSION
|
|
33
|
+
from .. import config, containers, detect, notify, qr, service, source
|
|
34
|
+
from . import collect, fleet, jobs, team, terminal
|
|
35
|
+
|
|
36
|
+
STATIC = Path(__file__).resolve().parent / "static"
|
|
37
|
+
COOKIE = "hoist_dash"
|
|
38
|
+
TOKEN_FILE_NAME = "dash-token"
|
|
39
|
+
_ENV_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
40
|
+
_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
|
|
41
|
+
_SHA = re.compile(r"^[0-9a-f]{7,40}$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def token_path() -> Path:
|
|
45
|
+
return config.STATE_DIR / TOKEN_FILE_NAME
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_token(reset: bool = False) -> str:
|
|
49
|
+
path = token_path()
|
|
50
|
+
if path.exists() and not reset:
|
|
51
|
+
value = path.read_text().strip()
|
|
52
|
+
if value:
|
|
53
|
+
return value
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
value = secrets.token_urlsafe(24)
|
|
56
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
57
|
+
with os.fdopen(fd, "w") as fh:
|
|
58
|
+
fh.write(value + "\n")
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Dashboard:
|
|
63
|
+
def __init__(self, token: str) -> None:
|
|
64
|
+
self.token = token
|
|
65
|
+
self.seen: dict[str, float] = {}
|
|
66
|
+
self.monitor = collect.Monitor()
|
|
67
|
+
self.jobs = jobs.Jobs(on_finish=lambda _job: self.monitor.nudge())
|
|
68
|
+
self.monitor.start()
|
|
69
|
+
|
|
70
|
+
# -- helpers ---------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def app(self, name: str) -> dict | None:
|
|
73
|
+
return config.get_app(config.load(), name)
|
|
74
|
+
|
|
75
|
+
def app_detail(self, name: str) -> dict | None:
|
|
76
|
+
state = config.load()
|
|
77
|
+
app = config.get_app(state, name)
|
|
78
|
+
if app is None:
|
|
79
|
+
return None
|
|
80
|
+
row = next((r for r in self.monitor.get().get("apps", []) if r["name"] == name), None)
|
|
81
|
+
repo = app.get("repo") or {}
|
|
82
|
+
return {
|
|
83
|
+
"row": row,
|
|
84
|
+
"env": app.get("env") or {},
|
|
85
|
+
"history": repo.get("history", []),
|
|
86
|
+
"series": self.monitor.series(name),
|
|
87
|
+
"probes": self.monitor.probes_for(name),
|
|
88
|
+
"jobs": [j for j in self.jobs.recent() if j["app"] == name][:10],
|
|
89
|
+
"sync_unit": f"{source.sync_unit_base(name)}.service" if repo else None,
|
|
90
|
+
"unit": service.unit_name(name),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def make_handler(dash: Dashboard):
|
|
95
|
+
class Handler(BaseHTTPRequestHandler):
|
|
96
|
+
server_version = f"hoist-dash/{VERSION}"
|
|
97
|
+
protocol_version = "HTTP/1.1"
|
|
98
|
+
# Headers and body used to leave as two small sends. Nagle's algorithm
|
|
99
|
+
# holds the second until the first is acknowledged, and a browser delays
|
|
100
|
+
# that ACK by ~40 ms, so every API call took ~45 ms (curl hides this: it
|
|
101
|
+
# does not trigger the delay). Buffer each response into one write and
|
|
102
|
+
# turn Nagle off.
|
|
103
|
+
wbufsize = 64 * 1024
|
|
104
|
+
disable_nagle_algorithm = True
|
|
105
|
+
|
|
106
|
+
# Quiet by default; errors still reach stderr.
|
|
107
|
+
def log_message(self, fmt, *args): # noqa: N802
|
|
108
|
+
if os.environ.get("HOIST_DASH_DEBUG"):
|
|
109
|
+
super().log_message(fmt, *args)
|
|
110
|
+
|
|
111
|
+
# -- plumbing -------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def _secure(self) -> bool:
|
|
114
|
+
proto = self.headers.get("X-Forwarded-Proto", "")
|
|
115
|
+
return proto == "https" or bool(self.headers.get("Cf-Visitor"))
|
|
116
|
+
|
|
117
|
+
def _token(self) -> str:
|
|
118
|
+
for part in self.headers.get("Cookie", "").split(";"):
|
|
119
|
+
key, _, value = part.strip().partition("=")
|
|
120
|
+
if key == COOKIE and value:
|
|
121
|
+
return value
|
|
122
|
+
auth = self.headers.get("Authorization", "")
|
|
123
|
+
return auth[7:] if auth.startswith("Bearer ") else ""
|
|
124
|
+
|
|
125
|
+
def _authed(self) -> bool:
|
|
126
|
+
self.user = team.identify(self._token(), dash.token)
|
|
127
|
+
if self.user:
|
|
128
|
+
dash.seen[self.user] = time.time()
|
|
129
|
+
return self.user is not None
|
|
130
|
+
|
|
131
|
+
def _cookie(self, token: str) -> str:
|
|
132
|
+
return (f"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=2592000"
|
|
133
|
+
+ ("; Secure" if self._secure() else ""))
|
|
134
|
+
|
|
135
|
+
def _send(self, code: int, body: bytes, ctype: str, extra: dict | None = None) -> None:
|
|
136
|
+
self.send_response(code)
|
|
137
|
+
self.send_header("Content-Type", ctype)
|
|
138
|
+
self.send_header("Content-Length", str(len(body)))
|
|
139
|
+
self.send_header("Cache-Control", "no-store")
|
|
140
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
141
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
142
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
143
|
+
for key, value in (extra or {}).items():
|
|
144
|
+
self.send_header(key, value)
|
|
145
|
+
self.end_headers()
|
|
146
|
+
if self.command != "HEAD":
|
|
147
|
+
self.wfile.write(body)
|
|
148
|
+
|
|
149
|
+
def _json(self, data, code: int = 200) -> None:
|
|
150
|
+
self._send(code, json.dumps(data).encode(), "application/json")
|
|
151
|
+
|
|
152
|
+
def _error(self, code: int, message: str) -> None:
|
|
153
|
+
self._json({"error": message}, code)
|
|
154
|
+
|
|
155
|
+
def _body(self) -> dict:
|
|
156
|
+
raw = getattr(self, "_raw", b"")
|
|
157
|
+
if len(raw) > 1_000_000:
|
|
158
|
+
raise ValueError("request too large")
|
|
159
|
+
if not raw:
|
|
160
|
+
return {}
|
|
161
|
+
data = json.loads(raw)
|
|
162
|
+
if not isinstance(data, dict):
|
|
163
|
+
raise ValueError("expected a JSON object")
|
|
164
|
+
return data
|
|
165
|
+
|
|
166
|
+
def _static(self, rel: str) -> None:
|
|
167
|
+
path = (STATIC / rel).resolve()
|
|
168
|
+
if STATIC not in path.parents and path != STATIC or not path.is_file():
|
|
169
|
+
self._error(404, "not found")
|
|
170
|
+
return
|
|
171
|
+
ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
172
|
+
if path.suffix == ".woff2":
|
|
173
|
+
ctype = "font/woff2"
|
|
174
|
+
extra = {"Cache-Control": "public, max-age=86400"} if "fonts/" in rel else None
|
|
175
|
+
body = path.read_bytes()
|
|
176
|
+
self.send_response(200)
|
|
177
|
+
self.send_header("Content-Type", ctype)
|
|
178
|
+
self.send_header("Content-Length", str(len(body)))
|
|
179
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
180
|
+
self.send_header("Cache-Control", (extra or {}).get("Cache-Control", "no-cache"))
|
|
181
|
+
self.end_headers()
|
|
182
|
+
self.wfile.write(body)
|
|
183
|
+
|
|
184
|
+
def _sse_start(self) -> None:
|
|
185
|
+
self.send_response(200)
|
|
186
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
187
|
+
self.send_header("Cache-Control", "no-store")
|
|
188
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
189
|
+
self.send_header("Connection", "close")
|
|
190
|
+
self.end_headers()
|
|
191
|
+
self.close_connection = True
|
|
192
|
+
|
|
193
|
+
def _sse(self, data, event: str | None = None) -> None:
|
|
194
|
+
chunk = (f"event: {event}\n" if event else "") + f"data: {json.dumps(data)}\n\n"
|
|
195
|
+
self.wfile.write(chunk.encode())
|
|
196
|
+
self.wfile.flush()
|
|
197
|
+
|
|
198
|
+
# -- routing ----------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def do_HEAD(self): # noqa: N802
|
|
201
|
+
self.do_GET()
|
|
202
|
+
|
|
203
|
+
def do_GET(self): # noqa: N802
|
|
204
|
+
url = urllib.parse.urlsplit(self.path)
|
|
205
|
+
path, query = url.path, urllib.parse.parse_qs(url.query)
|
|
206
|
+
|
|
207
|
+
if path.startswith("/static/"):
|
|
208
|
+
return self._static(path[len("/static/"):])
|
|
209
|
+
if path == "/favicon.svg":
|
|
210
|
+
return self._static("favicon.svg")
|
|
211
|
+
|
|
212
|
+
if path in ("/", "/index.html"):
|
|
213
|
+
given = (query.get("token") or [""])[0]
|
|
214
|
+
if given:
|
|
215
|
+
if team.identify(given, dash.token):
|
|
216
|
+
cookie = self._cookie(given)
|
|
217
|
+
nxt = (query.get("next") or ["/"])[0]
|
|
218
|
+
if not nxt.startswith("/") or nxt.startswith("//"):
|
|
219
|
+
nxt = "/"
|
|
220
|
+
return self._send(303, b"", "text/plain",
|
|
221
|
+
{"Location": nxt, "Set-Cookie": cookie})
|
|
222
|
+
time.sleep(0.5)
|
|
223
|
+
page = "index.html" if self._authed() else "login.html"
|
|
224
|
+
return self._static(page)
|
|
225
|
+
|
|
226
|
+
if not path.startswith("/api/"):
|
|
227
|
+
# client-side routes fall back to the app shell
|
|
228
|
+
return self._static("index.html" if self._authed() else "login.html")
|
|
229
|
+
if not self._authed():
|
|
230
|
+
return self._error(401, "sign in first")
|
|
231
|
+
|
|
232
|
+
remote = re.fullmatch(r"/api/remote/([a-z0-9-]+)/(.+)", path)
|
|
233
|
+
if remote:
|
|
234
|
+
return self._proxy("GET", remote.group(1), remote.group(2), url.query)
|
|
235
|
+
if path == "/api/servers":
|
|
236
|
+
local = dash.monitor.get()
|
|
237
|
+
apps = [a for a in local.get("apps", []) if a.get("kind") != "db"]
|
|
238
|
+
broken = [a["name"] for a in apps if a.get("status") in ("down", "failed")
|
|
239
|
+
or (a.get("public") and a.get("probe") and not a["probe"].get("up"))]
|
|
240
|
+
return self._json({"local": {"host": local.get("host"), "apps": len(apps), "broken": broken,
|
|
241
|
+
"system": local.get("system")},
|
|
242
|
+
"servers": fleet.summary() if self.user == team.OWNER else []})
|
|
243
|
+
try:
|
|
244
|
+
return self._api_get(path, query)
|
|
245
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
def do_POST(self): # noqa: N802
|
|
249
|
+
url = urllib.parse.urlsplit(self.path)
|
|
250
|
+
path = url.path
|
|
251
|
+
# Read the body before anything can answer early. On a keep-alive
|
|
252
|
+
# connection an unread body is parsed as the start of the NEXT
|
|
253
|
+
# request, so a rejected click used to corrupt the one after it.
|
|
254
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
255
|
+
if length > 5_000_000:
|
|
256
|
+
self.close_connection = True
|
|
257
|
+
return self._error(413, "request too large")
|
|
258
|
+
self._raw = self.rfile.read(length) if length else b""
|
|
259
|
+
match = re.fullmatch(r"/hooks/([a-z0-9-]+)", path)
|
|
260
|
+
if match:
|
|
261
|
+
return self._webhook(match.group(1))
|
|
262
|
+
if path == "/api/login":
|
|
263
|
+
try:
|
|
264
|
+
given = str(self._body().get("token", ""))
|
|
265
|
+
except ValueError:
|
|
266
|
+
given = ""
|
|
267
|
+
if not team.identify(given, dash.token):
|
|
268
|
+
time.sleep(0.75)
|
|
269
|
+
return self._error(401, "that token is not right")
|
|
270
|
+
cookie = self._cookie(given)
|
|
271
|
+
return self._send(200, b'{"ok":true}', "application/json", {"Set-Cookie": cookie})
|
|
272
|
+
if path == "/api/logout":
|
|
273
|
+
return self._send(200, b'{"ok":true}', "application/json",
|
|
274
|
+
{"Set-Cookie": f"{COOKIE}=; Path=/; Max-Age=0; SameSite=Strict"})
|
|
275
|
+
if not self._authed():
|
|
276
|
+
return self._error(401, "sign in first")
|
|
277
|
+
if self.headers.get("X-Hoist") != "1":
|
|
278
|
+
return self._error(403, "missing X-Hoist header")
|
|
279
|
+
remote = re.fullmatch(r"/api/remote/([a-z0-9-]+)/(.+)", path)
|
|
280
|
+
if remote:
|
|
281
|
+
return self._proxy("POST", remote.group(1), remote.group(2), url.query)
|
|
282
|
+
need = _required(path)
|
|
283
|
+
if need is not None and not team.allows(self.user, *need):
|
|
284
|
+
return self._error(403, _denied(self.user, *need))
|
|
285
|
+
try:
|
|
286
|
+
body = self._body()
|
|
287
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
288
|
+
return self._error(400, str(exc))
|
|
289
|
+
try:
|
|
290
|
+
return self._api_post(path, body)
|
|
291
|
+
except (ValueError, team.TeamError) as exc:
|
|
292
|
+
return self._error(400, str(exc))
|
|
293
|
+
|
|
294
|
+
# -- GET endpoints --------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def _api_get(self, path: str, query: dict) -> None:
|
|
297
|
+
if path == "/api/state":
|
|
298
|
+
dash.monitor.touch()
|
|
299
|
+
snap = dash.monitor.get()
|
|
300
|
+
seen = team.scope(self.user)
|
|
301
|
+
if seen is not None:
|
|
302
|
+
snap = dict(snap, apps=[a for a in snap.get("apps", []) if a["name"] in seen])
|
|
303
|
+
snap = redact_state(snap, lambda n: team.allows(self.user, "secrets", n))
|
|
304
|
+
snap["jobs"] = [j for j in dash.jobs.recent()[:40]
|
|
305
|
+
if seen is None or j.get("app") in seen][:12]
|
|
306
|
+
snap["version"] = VERSION
|
|
307
|
+
snap["me"] = self.user
|
|
308
|
+
snap["role"] = team.role_of(self.user)
|
|
309
|
+
access = team.access(self.user) or {}
|
|
310
|
+
snap["can"] = sorted(access.get("caps", ()))
|
|
311
|
+
snap["scope"] = sorted(seen) if seen is not None else None
|
|
312
|
+
return self._json(snap)
|
|
313
|
+
|
|
314
|
+
if path == "/api/deployments":
|
|
315
|
+
state = config.load()
|
|
316
|
+
rows = []
|
|
317
|
+
seen = team.scope(self.user)
|
|
318
|
+
for name, app in state.get("apps", {}).items():
|
|
319
|
+
if seen is not None and name not in seen:
|
|
320
|
+
continue
|
|
321
|
+
repo = app.get("repo") or {}
|
|
322
|
+
for entry in repo.get("history", []):
|
|
323
|
+
rows.append(dict(entry, app=name, live=entry.get("sha") == repo.get("sha")
|
|
324
|
+
and entry is next((h for h in repo["history"]
|
|
325
|
+
if h.get("status") != "failed"), None)))
|
|
326
|
+
rows.sort(key=lambda r: r.get("at", ""), reverse=True)
|
|
327
|
+
return self._json({"deployments": rows[:200]})
|
|
328
|
+
|
|
329
|
+
if path == "/api/notify":
|
|
330
|
+
url = (config.load().get("notify") or {}).get("url")
|
|
331
|
+
if url and not team.allows(self.user, "secrets"):
|
|
332
|
+
url = _URL_SECRET.sub(r"\1••••••@", url)
|
|
333
|
+
return self._json({"url": url, "kind": notify.kind(url) if url else None})
|
|
334
|
+
|
|
335
|
+
if path == "/api/templates":
|
|
336
|
+
return self._json({"templates": [{"id": k, "label": v["label"], "about": v["about"],
|
|
337
|
+
"image": v["image"], "user": bool(v.get("user"))}
|
|
338
|
+
for k, v in containers.catalogue().items()],
|
|
339
|
+
"docker": containers.available()})
|
|
340
|
+
|
|
341
|
+
if path == "/api/team":
|
|
342
|
+
people = [{"name": team.OWNER, "role": "owner", "added": None,
|
|
343
|
+
"seen": dash.seen.get(team.OWNER)}]
|
|
344
|
+
for name, info in sorted(team.users().items()):
|
|
345
|
+
people.append({"name": name, "role": info.get("role", "member"), "added": info.get("added"),
|
|
346
|
+
"seen": dash.seen.get(name), "can": info.get("can", []),
|
|
347
|
+
"apps": info.get("apps", []), "stages": info.get("stages", [])})
|
|
348
|
+
seen = team.scope(self.user)
|
|
349
|
+
audit = team.audit_tail(int((query.get("n") or ["200"])[0]))
|
|
350
|
+
if seen is not None: # a scoped person sees only their own apps' history
|
|
351
|
+
audit = [r for r in audit if r.get("target") in seen]
|
|
352
|
+
people = [p for p in people if p["name"] == self.user]
|
|
353
|
+
return self._json({"me": self.user, "people": people, "audit": audit,
|
|
354
|
+
"caps": team.CAPS, "stages": list(team.STAGES)})
|
|
355
|
+
|
|
356
|
+
if path == "/api/metrics":
|
|
357
|
+
return self._json({"system": dash.monitor.series()})
|
|
358
|
+
|
|
359
|
+
if path == "/api/doctor":
|
|
360
|
+
from ..cli import doctor_checks
|
|
361
|
+
return self._json({"checks": doctor_checks()})
|
|
362
|
+
|
|
363
|
+
if path == "/api/qr.svg":
|
|
364
|
+
text = (query.get("text") or [""])[0][:300]
|
|
365
|
+
if not text:
|
|
366
|
+
return self._error(400, "text is required")
|
|
367
|
+
return self._send(200, qr_svg(text).encode(), "image/svg+xml")
|
|
368
|
+
|
|
369
|
+
if path == "/api/routes":
|
|
370
|
+
rules = dash.monitor.get().get("routes", {}).get("rules", [])
|
|
371
|
+
data = []
|
|
372
|
+
seen = team.scope(self.user)
|
|
373
|
+
for rule in rules:
|
|
374
|
+
if seen is not None and rule.get("managed") not in seen:
|
|
375
|
+
continue
|
|
376
|
+
key = (f"route:{rule['hostname']}" if not rule.get("managed")
|
|
377
|
+
else rule.get("managed"))
|
|
378
|
+
data.append(dict(rule, probes=dash.monitor.probes_for(key or "")))
|
|
379
|
+
return self._json({"rules": data})
|
|
380
|
+
|
|
381
|
+
match = re.fullmatch(r"/api/jobs/(j\d+)(/stream)?", path)
|
|
382
|
+
if match:
|
|
383
|
+
job = dash.jobs.get(match.group(1))
|
|
384
|
+
if job is None:
|
|
385
|
+
return self._error(404, "no such job")
|
|
386
|
+
if job.app is not None and not team.allows(self.user, "view", job.app):
|
|
387
|
+
return self._error(404, "no such job")
|
|
388
|
+
if not team.allows(self.user, "secrets", job.app):
|
|
389
|
+
data = job.as_dict(with_lines=True)
|
|
390
|
+
data["lines"] = [re.sub(r"(password[^:]*:\s*)\S+", r"\1••••••", ln, flags=re.I)
|
|
391
|
+
for ln in data["lines"]]
|
|
392
|
+
return self._json(data)
|
|
393
|
+
if not match.group(2):
|
|
394
|
+
return self._json(job.as_dict(with_lines=True))
|
|
395
|
+
return self._stream_job(job)
|
|
396
|
+
|
|
397
|
+
match = re.fullmatch(r"/api/apps/([a-z0-9-]+)(/[a-z/]+(?:/[0-9]{8}-[0-9]{6}\.[a-z.]+)?)?", path)
|
|
398
|
+
if match:
|
|
399
|
+
name, rest = match.group(1), match.group(2) or ""
|
|
400
|
+
if not team.allows(self.user, "view", name):
|
|
401
|
+
return self._error(404, "no such app")
|
|
402
|
+
if rest == "":
|
|
403
|
+
detail = dash.app_detail(name)
|
|
404
|
+
if detail and not team.allows(self.user, "secrets", name):
|
|
405
|
+
detail = redact(detail)
|
|
406
|
+
return self._json(detail) if detail else self._error(404, "no such app")
|
|
407
|
+
app = dash.app(name)
|
|
408
|
+
if app is None:
|
|
409
|
+
return self._error(404, "no such app")
|
|
410
|
+
if rest == "/terminal":
|
|
411
|
+
return self._terminal(name, app)
|
|
412
|
+
if rest == "/backups":
|
|
413
|
+
if app.get("kind") not in ("db", "compose"):
|
|
414
|
+
return self._error(400, "only databases and Docker stacks have backups")
|
|
415
|
+
holder = app.get("db") or app.get("backup") or {}
|
|
416
|
+
return self._json({"backups": containers.list_backups(name),
|
|
417
|
+
"daily": bool(holder.get("daily_backup")),
|
|
418
|
+
"offsite": holder.get("offsite")})
|
|
419
|
+
match_dl = re.fullmatch(r"/backups/([0-9]{8}-[0-9]{6}\.[a-z.]+)", rest)
|
|
420
|
+
if match_dl and not team.allows(self.user, "secrets", name):
|
|
421
|
+
return self._error(403, "downloading a backup needs the 'secrets' permission")
|
|
422
|
+
if match_dl and app.get("kind") in ("db", "compose"):
|
|
423
|
+
path = containers.backup_dir(name) / match_dl.group(1)
|
|
424
|
+
if not path.is_file():
|
|
425
|
+
return self._error(404, "no such backup")
|
|
426
|
+
team.audit(self.user or "?", "download backup", name, path.name)
|
|
427
|
+
data = path.read_bytes()
|
|
428
|
+
return self._send(200, data, "application/octet-stream",
|
|
429
|
+
{"Content-Disposition": f'attachment; filename="{name}-{path.name}"'})
|
|
430
|
+
if rest in ("/logs", "/logs/stream"):
|
|
431
|
+
kind = (query.get("kind") or ["app"])[0]
|
|
432
|
+
if app.get("adopted"):
|
|
433
|
+
return self._error(409, "hoist does not manage this process, so it has no logs here")
|
|
434
|
+
unit = (f"{source.sync_unit_base(name)}.service" if kind == "deploys"
|
|
435
|
+
else service.unit_name(name))
|
|
436
|
+
if rest == "/logs":
|
|
437
|
+
lines = int((query.get("n") or ["400"])[0])
|
|
438
|
+
return self._json({"unit": unit, "lines": collect.journal(unit, min(lines, 5000))})
|
|
439
|
+
return self._stream_journal(unit)
|
|
440
|
+
return self._error(404, "unknown endpoint")
|
|
441
|
+
|
|
442
|
+
def _proxy(self, method: str, sid: str, rest: str, query: str) -> None:
|
|
443
|
+
"""Forward an API call to a linked machine, streams included."""
|
|
444
|
+
if self.user != team.OWNER:
|
|
445
|
+
return self._error(403, "only the owner can manage other machines")
|
|
446
|
+
srv = fleet.servers().get(sid)
|
|
447
|
+
if srv is None:
|
|
448
|
+
return self._error(404, "no such machine")
|
|
449
|
+
if rest.endswith("/terminal"):
|
|
450
|
+
return self._error(501, "open the terminal on that machine's own dashboard")
|
|
451
|
+
body = None
|
|
452
|
+
if method == "POST":
|
|
453
|
+
body = self._raw or b"{}"
|
|
454
|
+
self._audit("remote " + rest.rsplit("/", 1)[-1], f"{sid}:{rest}")
|
|
455
|
+
stream = rest.endswith("/stream")
|
|
456
|
+
try:
|
|
457
|
+
resp = fleet.request(srv, method, rest + (f"?{query}" if query else ""), body, stream=stream)
|
|
458
|
+
except fleet.urllib.error.HTTPError as exc:
|
|
459
|
+
data = exc.read()
|
|
460
|
+
return self._send(exc.code, data, exc.headers.get("Content-Type", "application/json"))
|
|
461
|
+
except Exception as exc: # noqa: BLE001
|
|
462
|
+
return self._error(502, f"{srv['name']} did not answer: {getattr(exc, 'reason', exc)}")
|
|
463
|
+
with resp:
|
|
464
|
+
ctype = resp.headers.get("Content-Type", "application/json")
|
|
465
|
+
if not stream:
|
|
466
|
+
return self._send(resp.status, resp.read(), ctype,
|
|
467
|
+
{k: v for k, v in resp.headers.items() if k == "Content-Disposition"})
|
|
468
|
+
self._sse_start()
|
|
469
|
+
try:
|
|
470
|
+
while True:
|
|
471
|
+
chunk = resp.readline()
|
|
472
|
+
if not chunk:
|
|
473
|
+
return
|
|
474
|
+
self.wfile.write(chunk)
|
|
475
|
+
if chunk in (b"\n", b"\r\n"):
|
|
476
|
+
self.wfile.flush()
|
|
477
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
def _terminal(self, name: str, app: dict) -> None:
|
|
481
|
+
if self.headers.get("Upgrade", "").lower() != "websocket":
|
|
482
|
+
return self._error(426, "this endpoint speaks WebSocket")
|
|
483
|
+
if not team.allows(self.user, "terminal", name):
|
|
484
|
+
return self._error(403, "your access does not include the terminal")
|
|
485
|
+
# Cross-site WebSocket hijacking: browsers send cookies with any
|
|
486
|
+
# WebSocket, so the page that opens it must be this dashboard.
|
|
487
|
+
origin = urllib.parse.urlsplit(self.headers.get("Origin", "")).netloc
|
|
488
|
+
if not origin or origin != self.headers.get("Host", ""):
|
|
489
|
+
return self._error(403, "terminal must be opened from this dashboard")
|
|
490
|
+
if app.get("adopted"):
|
|
491
|
+
return self._error(409, "hoist does not know where this process runs")
|
|
492
|
+
key = self.headers.get("Sec-WebSocket-Key", "")
|
|
493
|
+
if not key:
|
|
494
|
+
return self._error(400, "missing Sec-WebSocket-Key")
|
|
495
|
+
argv, env, cwd = terminal.shell_command(name, app)
|
|
496
|
+
team.audit(self.user, "open terminal", name)
|
|
497
|
+
self.send_response(101, "Switching Protocols")
|
|
498
|
+
self.send_header("Upgrade", "websocket")
|
|
499
|
+
self.send_header("Connection", "Upgrade")
|
|
500
|
+
self.send_header("Sec-WebSocket-Accept", terminal.accept_key(key))
|
|
501
|
+
self.end_headers()
|
|
502
|
+
self.wfile.flush()
|
|
503
|
+
self.close_connection = True
|
|
504
|
+
terminal.bridge(self.rfile, self.wfile, argv, env, cwd)
|
|
505
|
+
|
|
506
|
+
def _stream_job(self, job) -> None:
|
|
507
|
+
self._sse_start()
|
|
508
|
+
sent = 0
|
|
509
|
+
try:
|
|
510
|
+
while True:
|
|
511
|
+
with job.changed:
|
|
512
|
+
if sent >= len(job.lines) and job.status == "running":
|
|
513
|
+
job.changed.wait(15)
|
|
514
|
+
lines = job.lines[sent:]
|
|
515
|
+
status = job.status
|
|
516
|
+
for line in lines:
|
|
517
|
+
self._sse(line, "line")
|
|
518
|
+
sent += len(lines)
|
|
519
|
+
if status != "running" and sent >= len(job.lines):
|
|
520
|
+
self._sse(job.as_dict(), "done")
|
|
521
|
+
return
|
|
522
|
+
if not lines:
|
|
523
|
+
self.wfile.write(b": keep-alive\n\n")
|
|
524
|
+
self.wfile.flush()
|
|
525
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
def _stream_journal(self, unit: str) -> None:
|
|
529
|
+
self._sse_start()
|
|
530
|
+
proc = subprocess.Popen(
|
|
531
|
+
["journalctl", "--user", "-u", unit, "-f", "-n", "0", "-o", "json",
|
|
532
|
+
"--output-fields=MESSAGE,PRIORITY,_PID"],
|
|
533
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, errors="replace",
|
|
534
|
+
)
|
|
535
|
+
alive = threading.Event()
|
|
536
|
+
alive.set()
|
|
537
|
+
|
|
538
|
+
def heartbeat():
|
|
539
|
+
while alive.is_set():
|
|
540
|
+
time.sleep(15)
|
|
541
|
+
try:
|
|
542
|
+
self.wfile.write(b": keep-alive\n\n")
|
|
543
|
+
self.wfile.flush()
|
|
544
|
+
except OSError:
|
|
545
|
+
alive.clear()
|
|
546
|
+
proc.kill()
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
threading.Thread(target=heartbeat, daemon=True).start()
|
|
550
|
+
try:
|
|
551
|
+
assert proc.stdout is not None
|
|
552
|
+
for line in proc.stdout:
|
|
553
|
+
entry = collect.parse_journal_line(line)
|
|
554
|
+
if entry:
|
|
555
|
+
self._sse(entry, "line")
|
|
556
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
557
|
+
pass
|
|
558
|
+
finally:
|
|
559
|
+
alive.clear()
|
|
560
|
+
proc.kill()
|
|
561
|
+
|
|
562
|
+
# -- POST endpoints -------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
def _webhook(self, name: str) -> None:
|
|
565
|
+
"""A push from GitHub or GitLab: verify it, then deploy right away.
|
|
566
|
+
|
|
567
|
+
No cookie here -- the forge cannot have one. The request must be
|
|
568
|
+
signed with the app's own secret (GitHub's X-Hub-Signature-256,
|
|
569
|
+
or GitLab's X-Gitlab-Token), compared in constant time.
|
|
570
|
+
"""
|
|
571
|
+
raw = self._raw
|
|
572
|
+
app = dash.app(name)
|
|
573
|
+
secret = ((app or {}).get("repo") or {}).get("webhook_secret")
|
|
574
|
+
if not secret:
|
|
575
|
+
time.sleep(0.5)
|
|
576
|
+
return self._error(404, "no webhook for this app")
|
|
577
|
+
signature = self.headers.get("X-Hub-Signature-256", "")
|
|
578
|
+
expected = "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
|
|
579
|
+
gitlab = self.headers.get("X-Gitlab-Token", "")
|
|
580
|
+
if not (hmac.compare_digest(signature, expected) or (gitlab and hmac.compare_digest(gitlab, secret))):
|
|
581
|
+
time.sleep(0.5)
|
|
582
|
+
return self._error(401, "bad signature")
|
|
583
|
+
event = self.headers.get("X-GitHub-Event") or self.headers.get("X-Gitlab-Event") or ""
|
|
584
|
+
if event in ("ping",):
|
|
585
|
+
return self._json({"ok": True, "pong": True})
|
|
586
|
+
try:
|
|
587
|
+
payload = json.loads(raw or b"{}")
|
|
588
|
+
except json.JSONDecodeError:
|
|
589
|
+
return self._error(400, "payload is not JSON")
|
|
590
|
+
ref = str(payload.get("ref", ""))
|
|
591
|
+
branch = app["repo"]["branch"]
|
|
592
|
+
previews = bool(app["repo"].get("previews"))
|
|
593
|
+
if ref != f"refs/heads/{branch}" and not previews:
|
|
594
|
+
return self._json({"ok": True, "ignored": f"push to {ref}, watching {branch}"})
|
|
595
|
+
team.audit("webhook", "push received", name, ref)
|
|
596
|
+
job = dash.jobs.start(f"Deploy {name} (webhook)", ["sync", name, "--wait"], name, "webhook")
|
|
597
|
+
return self._json({"ok": True, "job": job.id}, 202)
|
|
598
|
+
|
|
599
|
+
def _audit(self, action: str, target: str | None = None, detail: str = "") -> None:
|
|
600
|
+
team.audit(self.user or "?", action, target, detail)
|
|
601
|
+
|
|
602
|
+
def _api_post(self, path: str, body: dict) -> None:
|
|
603
|
+
if path == "/api/apps":
|
|
604
|
+
self._audit("create app", str(body.get("name") or ""), str(body.get("target", ""))[:200])
|
|
605
|
+
return self._create(body)
|
|
606
|
+
if path == "/api/templates":
|
|
607
|
+
tid = str(body.get("id", ""))
|
|
608
|
+
catalogue = containers.catalogue()
|
|
609
|
+
if tid not in catalogue:
|
|
610
|
+
raise ValueError("pick a template")
|
|
611
|
+
name = str(body.get("name", "") or tid).strip().lower()
|
|
612
|
+
if not _NAME.match(name):
|
|
613
|
+
raise ValueError("name: lowercase letters, digits and dashes, up to 40")
|
|
614
|
+
if dash.app(name):
|
|
615
|
+
raise ValueError(f"{name} already exists")
|
|
616
|
+
args = ["template", tid, "--name", name, "--no-qr"] + (["--local"] if body.get("local") else [])
|
|
617
|
+
self._audit("create from template", name, tid)
|
|
618
|
+
job = dash.jobs.start(f"Start {catalogue[tid]['label']} as {name}", args, name, self.user)
|
|
619
|
+
return self._json({"job": job.as_dict(), "app": name}, 202)
|
|
620
|
+
if path == "/api/servers":
|
|
621
|
+
if self.user != team.OWNER:
|
|
622
|
+
return self._error(403, "only the owner can link machines")
|
|
623
|
+
try:
|
|
624
|
+
srv = fleet.add(str(body.get("name", "")), str(body.get("url", "")), str(body.get("token", "")))
|
|
625
|
+
except fleet.FleetError as exc:
|
|
626
|
+
raise ValueError(str(exc)) from exc
|
|
627
|
+
self._audit("link machine", srv["id"], srv["url"])
|
|
628
|
+
return self._json(srv)
|
|
629
|
+
match = re.fullmatch(r"/api/servers/([a-z0-9-]+)/remove", path)
|
|
630
|
+
if match:
|
|
631
|
+
if self.user != team.OWNER:
|
|
632
|
+
return self._error(403, "only the owner can unlink machines")
|
|
633
|
+
if not fleet.remove(match.group(1)):
|
|
634
|
+
return self._error(404, "no such machine")
|
|
635
|
+
self._audit("unlink machine", match.group(1))
|
|
636
|
+
return self._json({"ok": True})
|
|
637
|
+
if path == "/api/databases":
|
|
638
|
+
engine = str(body.get("engine", ""))
|
|
639
|
+
if engine not in containers.ENGINES:
|
|
640
|
+
raise ValueError("pick postgres, mysql, redis or mongo")
|
|
641
|
+
name = str(body.get("name", "") or engine).strip().lower()
|
|
642
|
+
if not _NAME.match(name):
|
|
643
|
+
raise ValueError("name: lowercase letters, digits and dashes, up to 40")
|
|
644
|
+
if dash.app(name):
|
|
645
|
+
raise ValueError(f"{name} already exists")
|
|
646
|
+
self._audit("create database", name, engine)
|
|
647
|
+
job = dash.jobs.start(f"Start {containers.ENGINES[engine]['label']} {name}",
|
|
648
|
+
["db", engine, "--name", name], name, self.user)
|
|
649
|
+
return self._json({"job": job.as_dict(), "app": name}, 202)
|
|
650
|
+
if path == "/api/team":
|
|
651
|
+
if self.user != team.OWNER:
|
|
652
|
+
return self._error(403, "only the owner can add people")
|
|
653
|
+
name = str(body.get("name", ""))
|
|
654
|
+
role = str(body.get("role", "member"))
|
|
655
|
+
can, apps = body.get("can") or [], body.get("apps") or []
|
|
656
|
+
if not isinstance(can, list) or not isinstance(apps, list):
|
|
657
|
+
raise ValueError("can and apps must be lists")
|
|
658
|
+
stages = body.get("stages") or []
|
|
659
|
+
if not isinstance(stages, list):
|
|
660
|
+
raise ValueError("stages must be a list")
|
|
661
|
+
token = team.add(name, role, [str(c) for c in can], [str(a) for a in apps],
|
|
662
|
+
[str(s) for s in stages])
|
|
663
|
+
self._audit("add teammate", name.strip().lower(), _describe(role, can, apps, stages))
|
|
664
|
+
base = str(body.get("origin") or "").rstrip("/")
|
|
665
|
+
if not re.match(r"^https?://[^/\s]+$", base):
|
|
666
|
+
base = ""
|
|
667
|
+
return self._json({"name": name.strip().lower(), "link": f"{base}/?token={token}"})
|
|
668
|
+
match = re.fullmatch(r"/api/team/([a-z][a-z0-9_-]{0,23})/role", path)
|
|
669
|
+
if match:
|
|
670
|
+
if self.user != team.OWNER:
|
|
671
|
+
return self._error(403, "only the owner can change roles")
|
|
672
|
+
role = str(body.get("role", ""))
|
|
673
|
+
can, apps = body.get("can") or [], body.get("apps") or []
|
|
674
|
+
if not isinstance(can, list) or not isinstance(apps, list):
|
|
675
|
+
raise ValueError("can and apps must be lists")
|
|
676
|
+
stages = body.get("stages") or []
|
|
677
|
+
if not isinstance(stages, list):
|
|
678
|
+
raise ValueError("stages must be a list")
|
|
679
|
+
team.set_role(match.group(1), role, [str(c) for c in can], [str(a) for a in apps],
|
|
680
|
+
[str(s) for s in stages])
|
|
681
|
+
self._audit("change access", match.group(1), _describe(role, can, apps, stages))
|
|
682
|
+
return self._json({"ok": True})
|
|
683
|
+
match = re.fullmatch(r"/api/team/([a-z][a-z0-9_-]{0,23})/remove", path)
|
|
684
|
+
if match:
|
|
685
|
+
if self.user != team.OWNER:
|
|
686
|
+
return self._error(403, "only the owner can remove people")
|
|
687
|
+
if not team.remove(match.group(1)):
|
|
688
|
+
return self._error(404, "no such teammate")
|
|
689
|
+
self._audit("remove teammate", match.group(1))
|
|
690
|
+
return self._json({"ok": True})
|
|
691
|
+
if path == "/api/notify":
|
|
692
|
+
self._audit("set notifications", None, "webhook on" if body.get("url") else "off")
|
|
693
|
+
url = str(body.get("url") or "").strip()
|
|
694
|
+
if url and (not url.startswith(("https://", "http://", "smtp://", "smtps://")) or len(url) > 2000):
|
|
695
|
+
raise ValueError("expected a webhook URL (https://…) or email (smtps://user:pass@host:465/?to=…)")
|
|
696
|
+
with config.transaction() as state:
|
|
697
|
+
if url:
|
|
698
|
+
state["notify"] = {"url": url}
|
|
699
|
+
else:
|
|
700
|
+
state.pop("notify", None)
|
|
701
|
+
return self._json({"url": url or None, "kind": notify.kind(url) if url else None})
|
|
702
|
+
if path == "/api/notify/test":
|
|
703
|
+
good, detail = notify.send("hoist is connected",
|
|
704
|
+
"You will hear about deploys, failed builds and apps that stop answering.",
|
|
705
|
+
level="ok")
|
|
706
|
+
return self._json({"ok": good, "detail": detail}, 200 if good else 502)
|
|
707
|
+
|
|
708
|
+
match = re.fullmatch(r"/api/apps/([a-z0-9-]+)/([a-z-]+)", path)
|
|
709
|
+
if not match:
|
|
710
|
+
return self._error(404, "unknown endpoint")
|
|
711
|
+
name, action = match.groups()
|
|
712
|
+
app = dash.app(name)
|
|
713
|
+
if app is None:
|
|
714
|
+
return self._error(404, "no such app")
|
|
715
|
+
detail = {"env": f"{len(body.get('env') or {})} variables (values not logged)",
|
|
716
|
+
"rollback": f"to {str(body.get('sha', ''))[:12]}",
|
|
717
|
+
"limits": f"memory {body.get('memory') or 'none'}, cpu {body.get('cpu') or 'none'}",
|
|
718
|
+
"link": f"into {body.get('app', '')} as {body.get('var') or 'default'}"}.get(action, "")
|
|
719
|
+
self._audit(action, name, detail)
|
|
720
|
+
running = dash.jobs.busy(name)
|
|
721
|
+
long_actions = {"redeploy", "rollback", "delete", "backup", "restore"}
|
|
722
|
+
if action in long_actions and running:
|
|
723
|
+
return self._json({"error": "another action is still running", "job": running.as_dict()}, 409)
|
|
724
|
+
|
|
725
|
+
if action == "redeploy":
|
|
726
|
+
if not app.get("repo"):
|
|
727
|
+
return self._error(400, "only apps deployed from git can be redeployed")
|
|
728
|
+
job = dash.jobs.start(f"Redeploy {name}", ["sync", name, "--force"], name, self.user)
|
|
729
|
+
return self._json({"job": job.as_dict()}, 202)
|
|
730
|
+
if action == "rollback":
|
|
731
|
+
sha = str(body.get("sha", ""))
|
|
732
|
+
if not _SHA.match(sha):
|
|
733
|
+
return self._error(400, "a commit SHA is required")
|
|
734
|
+
job = dash.jobs.start(f"Roll back {name} to {sha[:7]}", ["sync", name, "--to", sha], name, self.user)
|
|
735
|
+
return self._json({"job": job.as_dict()}, 202)
|
|
736
|
+
if action == "delete":
|
|
737
|
+
job = dash.jobs.start(f"Take down {name}", ["down", name] + (["--purge"] if body.get("purge") else []), name, self.user)
|
|
738
|
+
return self._json({"job": job.as_dict()}, 202)
|
|
739
|
+
|
|
740
|
+
if action in ("restart", "stop", "start", "pause", "resume"):
|
|
741
|
+
return self._quick(name, app, action)
|
|
742
|
+
if action == "env":
|
|
743
|
+
return self._set_env(name, body)
|
|
744
|
+
if action == "link":
|
|
745
|
+
target = str(body.get("app", ""))
|
|
746
|
+
if app.get("kind") != "db":
|
|
747
|
+
return self._error(400, "only a database can be linked")
|
|
748
|
+
if not _NAME.match(target) or not dash.app(target):
|
|
749
|
+
raise ValueError("pick an app to link")
|
|
750
|
+
args = ["link", name, target]
|
|
751
|
+
var = str(body.get("var", "") or "").strip()
|
|
752
|
+
if var:
|
|
753
|
+
if not _ENV_KEY.match(var):
|
|
754
|
+
raise ValueError(f"{var!r} is not a valid variable name")
|
|
755
|
+
args += ["--var", var]
|
|
756
|
+
return self._run_quick(args)
|
|
757
|
+
if action == "exec":
|
|
758
|
+
command = str(body.get("command", "")).strip()
|
|
759
|
+
if not command or len(command) > 2000 or "\n" in command:
|
|
760
|
+
raise ValueError("one command, on one line")
|
|
761
|
+
if running:
|
|
762
|
+
return self._json({"error": "another action is still running", "job": running.as_dict()}, 409)
|
|
763
|
+
team.audit(self.user, "run command", name, command[:300])
|
|
764
|
+
job = dash.jobs.start(f"Run in {name}", ["exec", name, "--", command], name, self.user)
|
|
765
|
+
return self._json({"job": job.as_dict()}, 202)
|
|
766
|
+
if action in ("backup", "restore", "backup-schedule"):
|
|
767
|
+
if app.get("kind") not in ("db", "compose"):
|
|
768
|
+
return self._error(400, "only databases and Docker stacks have backups")
|
|
769
|
+
if action == "backup":
|
|
770
|
+
job = dash.jobs.start(f"Back up {name}", ["backup", name], name, self.user)
|
|
771
|
+
return self._json({"job": job.as_dict()}, 202)
|
|
772
|
+
if action == "restore":
|
|
773
|
+
file = str(body.get("file", ""))
|
|
774
|
+
if not re.fullmatch(r"[0-9]{8}-[0-9]{6}\.[a-z.]+", file):
|
|
775
|
+
raise ValueError("pick a backup to restore")
|
|
776
|
+
job = dash.jobs.start(f"Restore {name} from {file}", ["backup", name, "--restore", file],
|
|
777
|
+
name, self.user)
|
|
778
|
+
return self._json({"job": job.as_dict()}, 202)
|
|
779
|
+
return self._run_quick(["backup", name, "--daily" if body.get("on") else "--no-schedule"])
|
|
780
|
+
if action == "webhook":
|
|
781
|
+
if not app.get("repo"):
|
|
782
|
+
return self._error(400, "only apps deployed from git have webhooks")
|
|
783
|
+
if self.user != team.OWNER:
|
|
784
|
+
return self._error(403, "only the owner can issue a webhook secret")
|
|
785
|
+
secret = secrets.token_hex(20)
|
|
786
|
+
with config.transaction() as state:
|
|
787
|
+
state["apps"][name]["repo"]["webhook_secret"] = secret
|
|
788
|
+
base = str(body.get("origin") or "").rstrip("/")
|
|
789
|
+
if not re.match(r"^https?://[^/\s]+$", base):
|
|
790
|
+
base = ""
|
|
791
|
+
return self._json({"url": f"{base}/hooks/{name}", "secret": secret})
|
|
792
|
+
if action == "stage":
|
|
793
|
+
scoped = (team.access(self.user) or {}).get("stages")
|
|
794
|
+
if scoped is not None: # moving an app between environments is a governance change
|
|
795
|
+
return self._error(403, "people limited to some environments cannot change an app's environment")
|
|
796
|
+
stage = str(body.get("stage", ""))
|
|
797
|
+
if stage not in team.STAGES:
|
|
798
|
+
raise ValueError(f"pick one of {', '.join(team.STAGES)}")
|
|
799
|
+
return self._run_quick(["stage", name, stage])
|
|
800
|
+
if action == "previews":
|
|
801
|
+
return self._run_quick(["previews", name, "on" if body.get("on") else "off"])
|
|
802
|
+
if action == "limits":
|
|
803
|
+
args = ["limit", name]
|
|
804
|
+
for key in ("memory", "cpu"):
|
|
805
|
+
value = str(body.get(key, "") or "").strip() or "off"
|
|
806
|
+
if value != "off" and not re.match(getattr(service, f"LIMIT_{key.upper()}"), value.upper()):
|
|
807
|
+
raise ValueError(f"{key}: use something like {'512M' if key == 'memory' else '50%'}")
|
|
808
|
+
args += [f"--{key}", value]
|
|
809
|
+
return self._run_quick(args)
|
|
810
|
+
return self._error(404, "unknown action")
|
|
811
|
+
|
|
812
|
+
def _quick(self, name: str, app: dict, action: str) -> None:
|
|
813
|
+
if app.get("adopted") and action in ("restart", "stop", "start"):
|
|
814
|
+
return self._error(409, "hoist does not manage this process")
|
|
815
|
+
if action in ("pause", "resume") and not app.get("repo"):
|
|
816
|
+
return self._error(400, "auto-deploy only applies to apps deployed from git")
|
|
817
|
+
return self._run_quick([action, name])
|
|
818
|
+
|
|
819
|
+
def _run_quick(self, args: list[str]) -> None:
|
|
820
|
+
action = args[0]
|
|
821
|
+
proc = subprocess.run(
|
|
822
|
+
[os.sys.executable, "-m", "hoist", *args], capture_output=True, text=True,
|
|
823
|
+
env=dict(os.environ, NO_COLOR="1", HOIST_ACTOR=self.user or "",
|
|
824
|
+
PYTHONPATH=str(jobs._PKG_ROOT) + os.pathsep + os.environ.get("PYTHONPATH", "")),
|
|
825
|
+
timeout=60,
|
|
826
|
+
)
|
|
827
|
+
dash.monitor.nudge()
|
|
828
|
+
output = jobs._ANSI.sub("", (proc.stdout + proc.stderr).strip())
|
|
829
|
+
if proc.returncode != 0:
|
|
830
|
+
return self._json({"error": output.splitlines()[-1] if output else f"{action} failed"}, 500)
|
|
831
|
+
return self._json({"ok": True, "output": output})
|
|
832
|
+
|
|
833
|
+
def _set_env(self, name: str, body: dict) -> None:
|
|
834
|
+
env = body.get("env")
|
|
835
|
+
if not isinstance(env, dict):
|
|
836
|
+
raise ValueError("env must be an object of KEY: value")
|
|
837
|
+
clean: dict[str, str] = {}
|
|
838
|
+
for key, value in env.items():
|
|
839
|
+
key = str(key).strip()
|
|
840
|
+
if not _ENV_KEY.match(key):
|
|
841
|
+
raise ValueError(f"{key!r} is not a valid variable name")
|
|
842
|
+
if key in ("PORT", "HOIST_APP"):
|
|
843
|
+
raise ValueError(f"{key} is set by hoist")
|
|
844
|
+
value = "" if value is None else str(value)
|
|
845
|
+
if "\n" in value or "\r" in value:
|
|
846
|
+
raise ValueError(f"{key} has a line break; use one line")
|
|
847
|
+
clean[key] = value
|
|
848
|
+
with config.transaction() as state:
|
|
849
|
+
app = config.get_app(state, name)
|
|
850
|
+
if app is None:
|
|
851
|
+
return self._error(404, "no such app")
|
|
852
|
+
if app.get("adopted"):
|
|
853
|
+
return self._error(409, "hoist does not manage this process, so it cannot set its environment")
|
|
854
|
+
app["env"] = clean
|
|
855
|
+
app["updated"] = config.now()
|
|
856
|
+
service.write_unit(app)
|
|
857
|
+
containers.refresh_generated_compose(app) # so a container app actually receives it
|
|
858
|
+
restarted = False
|
|
859
|
+
if not app.get("stopped") and body.get("restart", True):
|
|
860
|
+
service.restart(name)
|
|
861
|
+
restarted = True
|
|
862
|
+
dash.monitor.nudge()
|
|
863
|
+
return self._json({"ok": True, "restarted": restarted, "count": len(clean)})
|
|
864
|
+
|
|
865
|
+
def _create(self, body: dict) -> None:
|
|
866
|
+
target = str(body.get("target", "")).strip()
|
|
867
|
+
if not target:
|
|
868
|
+
raise ValueError("a git URL or a folder is required")
|
|
869
|
+
name = str(body.get("name", "")).strip().lower()
|
|
870
|
+
if name and not _NAME.match(name):
|
|
871
|
+
raise ValueError("name: lowercase letters, digits and dashes, up to 40")
|
|
872
|
+
if not source.is_repo_url(target):
|
|
873
|
+
folder = Path(target).expanduser()
|
|
874
|
+
if not folder.is_dir():
|
|
875
|
+
raise ValueError(f"{target} is not a git URL or a folder on this machine")
|
|
876
|
+
target = str(folder.resolve())
|
|
877
|
+
args = ["up", target, "--no-qr"]
|
|
878
|
+
if name:
|
|
879
|
+
args += ["--name", name]
|
|
880
|
+
for key, flag in (("branch", "--branch"), ("build", "--build"), ("cmd", "--cmd")):
|
|
881
|
+
value = str(body.get(key, "") or "").strip()
|
|
882
|
+
if value:
|
|
883
|
+
if "\n" in value:
|
|
884
|
+
raise ValueError(f"{key} must be one line")
|
|
885
|
+
args += [flag, value]
|
|
886
|
+
if body.get("local"):
|
|
887
|
+
args.append("--local")
|
|
888
|
+
for key, value in (body.get("env") or {}).items():
|
|
889
|
+
if not _ENV_KEY.match(str(key)):
|
|
890
|
+
raise ValueError(f"{key!r} is not a valid variable name")
|
|
891
|
+
args += ["--env", f"{key}={value}"]
|
|
892
|
+
app_name = name or detect.slug(source.repo_name(target) if source.is_repo_url(target)
|
|
893
|
+
else Path(target).name)
|
|
894
|
+
if dash.jobs.busy(app_name):
|
|
895
|
+
return self._error(409, f"{app_name} is already being deployed")
|
|
896
|
+
job = dash.jobs.start(f"Deploy {app_name}", args, app_name, self.user)
|
|
897
|
+
return self._json({"job": job.as_dict(), "app": app_name}, 202)
|
|
898
|
+
|
|
899
|
+
return Handler
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
_URL_SECRET = re.compile(r"(://[^:/@]*:)[^@]+@")
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def redact(detail: dict) -> dict:
|
|
906
|
+
"""What a viewer may see of an app: everything but secrets."""
|
|
907
|
+
detail = dict(detail)
|
|
908
|
+
detail["env"] = {k: "••••••" for k in (detail.get("env") or {})}
|
|
909
|
+
if detail.get("row"):
|
|
910
|
+
detail["row"] = _redact_row(detail["row"])
|
|
911
|
+
return detail
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _redact_row(row: dict) -> dict:
|
|
915
|
+
row = dict(row)
|
|
916
|
+
if row.get("db"):
|
|
917
|
+
row["db"] = dict(row["db"], url=_URL_SECRET.sub(r"\1••••••@", row["db"].get("url") or ""))
|
|
918
|
+
return row
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def redact_state(snap: dict, keep=lambda name: False) -> dict:
|
|
922
|
+
"""Hide database passwords from anyone who lacks the 'secrets' permission on that app."""
|
|
923
|
+
return dict(snap, apps=[r if keep(r["name"]) else _redact_row(r) for r in snap.get("apps", [])])
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _required(path: str):
|
|
927
|
+
"""(capability, app) a write to `path` needs, or None when the handler checks for itself."""
|
|
928
|
+
if path in ("/api/apps", "/api/templates", "/api/databases"):
|
|
929
|
+
return ("create", None)
|
|
930
|
+
if path in ("/api/notify", "/api/notify/test"):
|
|
931
|
+
return ("config", None)
|
|
932
|
+
match = re.fullmatch(r"/api/apps/([a-z0-9-]+)/([a-z-]+)", path)
|
|
933
|
+
if match:
|
|
934
|
+
name, action = match.groups()
|
|
935
|
+
cap = team.ACTION_CAP.get(action)
|
|
936
|
+
if cap is None: # exec and anything else unlisted: the owner only
|
|
937
|
+
return ("owner-only", name)
|
|
938
|
+
return (cap, name)
|
|
939
|
+
return None
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _describe(role: str, can: list, apps: list, stages: list | None = None) -> str:
|
|
943
|
+
if role != "custom":
|
|
944
|
+
return role
|
|
945
|
+
where = (f" on {', '.join(sorted(map(str, apps)))}" if apps else " on all apps")
|
|
946
|
+
if stages:
|
|
947
|
+
where += f" in {', '.join(sorted(map(str, stages)))}"
|
|
948
|
+
return f"custom: {', '.join(sorted(map(str, can))) or 'view only'}" + where
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _denied(user: str | None, cap: str, app: str | None) -> str:
|
|
952
|
+
access = team.access(user) or {}
|
|
953
|
+
if cap == "owner-only":
|
|
954
|
+
return "only the owner can do that"
|
|
955
|
+
if cap not in access.get("caps", ()):
|
|
956
|
+
what = team.CAPS.get(cap, cap)
|
|
957
|
+
return f"your access does not include this ({what.lower()})"
|
|
958
|
+
return f"your access is limited to some apps, and {app or 'this action'} is not one of them"
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def qr_svg(text: str) -> str:
|
|
962
|
+
matrix = qr.encode(text)
|
|
963
|
+
size = len(matrix)
|
|
964
|
+
quiet = 2
|
|
965
|
+
full = size + quiet * 2
|
|
966
|
+
cells = "".join(
|
|
967
|
+
f"M{x + quiet} {y + quiet}h1v1h-1z"
|
|
968
|
+
for y, row in enumerate(matrix) for x, dark in enumerate(row) if dark
|
|
969
|
+
)
|
|
970
|
+
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {full} {full}" '
|
|
971
|
+
f'shape-rendering="crispEdges"><rect width="{full}" height="{full}" fill="#fff"/>'
|
|
972
|
+
f'<path d="{cells}" fill="#000"/></svg>')
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def serve(host: str, port: int, token: str) -> ThreadingHTTPServer:
|
|
976
|
+
dash = Dashboard(token)
|
|
977
|
+
httpd = ThreadingHTTPServer((host, port), make_handler(dash))
|
|
978
|
+
httpd.daemon_threads = True
|
|
979
|
+
return httpd
|