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/cli.py
ADDED
|
@@ -0,0 +1,1672 @@
|
|
|
1
|
+
"""hoist -- put a local app on a public HTTPS URL, with one command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import fcntl
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import urllib.request
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import shlex
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
|
|
18
|
+
from . import config, containers, detect, front, notify, qr, service, source, tunnel, ui
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
HEALTH_TIMEOUT = 20.0
|
|
23
|
+
CRASH_LOOP_RESTARTS = 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UserError(Exception):
|
|
27
|
+
"""Something the user can fix; printed without a traceback."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- helpers ------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _wait_for_healthy(name: str, port: int, timeout: float = HEALTH_TIMEOUT, check=None) -> str:
|
|
34
|
+
"""Wait for the port to open. Returns "up", "failed" or "timeout".
|
|
35
|
+
|
|
36
|
+
The unit state is watched alongside the port so a crashed app is reported
|
|
37
|
+
in a second or two instead of after the full timeout.
|
|
38
|
+
"""
|
|
39
|
+
deadline = time.monotonic() + timeout
|
|
40
|
+
while time.monotonic() < deadline:
|
|
41
|
+
if (check or detect.port_in_use)(port):
|
|
42
|
+
return "up"
|
|
43
|
+
if service.status(name) == "failed":
|
|
44
|
+
return "failed"
|
|
45
|
+
if service.n_restarts(name) >= CRASH_LOOP_RESTARTS:
|
|
46
|
+
return "crashing"
|
|
47
|
+
time.sleep(0.25)
|
|
48
|
+
return "timeout"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _report_health(name: str, port: int, timeout: float = HEALTH_TIMEOUT, check=None) -> str:
|
|
52
|
+
health = _wait_for_healthy(name, port, timeout, check)
|
|
53
|
+
if health == "up":
|
|
54
|
+
ui.ok(f"listening on 127.0.0.1:{port}")
|
|
55
|
+
return health
|
|
56
|
+
if health == "crashing":
|
|
57
|
+
ui.fail(f"{name} keeps crashing on startup")
|
|
58
|
+
elif health == "failed":
|
|
59
|
+
ui.fail(f"{name} failed to start")
|
|
60
|
+
else:
|
|
61
|
+
ui.warn(f"nothing listening on port {port} after {int(timeout)}s")
|
|
62
|
+
tail = service.recent_logs(name)
|
|
63
|
+
for line in tail.splitlines()[-8:]:
|
|
64
|
+
ui.step(line)
|
|
65
|
+
ui.step(f"full logs: hoist logs {name}")
|
|
66
|
+
return health
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_env(pairs: list[str] | None) -> dict[str, str]:
|
|
70
|
+
env: dict[str, str] = {}
|
|
71
|
+
for pair in pairs or []:
|
|
72
|
+
if "=" not in pair:
|
|
73
|
+
raise UserError(f"--env expects KEY=VALUE, got {pair!r}")
|
|
74
|
+
key, value = pair.split("=", 1)
|
|
75
|
+
env[key.strip()] = value
|
|
76
|
+
return env
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_domain(state: dict, explicit: str | None, cf_text: str | None) -> str:
|
|
80
|
+
if explicit:
|
|
81
|
+
return explicit.lstrip(".")
|
|
82
|
+
if state.get("domain"):
|
|
83
|
+
return str(state["domain"])
|
|
84
|
+
if cf_text:
|
|
85
|
+
guess = tunnel.default_domain(cf_text)
|
|
86
|
+
if guess:
|
|
87
|
+
return guess
|
|
88
|
+
raise UserError(
|
|
89
|
+
"could not work out which domain to use -- pass --domain example.com "
|
|
90
|
+
"(it is remembered for next time)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _show_qr(url: str, ascii_mode: bool = False) -> None:
|
|
95
|
+
try:
|
|
96
|
+
print(qr.render_ascii(url) if ascii_mode else qr.render(url))
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
ui.warn(f"no QR code: {exc}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _app_url(app: dict) -> str:
|
|
102
|
+
if app.get("kind") == "db":
|
|
103
|
+
return app["db"]["url"]
|
|
104
|
+
if app.get("tunnel"):
|
|
105
|
+
return f"https://{app['hostname']}"
|
|
106
|
+
return f"http://{app.get('lan_ip', '127.0.0.1')}:{app['port']}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _pick_port(args: argparse.Namespace, existing: dict | None) -> int:
|
|
110
|
+
"""Reuse our own port across restarts; never steal someone else's."""
|
|
111
|
+
wanted = args.port or (existing or {}).get("port")
|
|
112
|
+
if wanted is None:
|
|
113
|
+
return detect.free_port()
|
|
114
|
+
if detect.port_is_free(wanted):
|
|
115
|
+
return int(wanted)
|
|
116
|
+
if existing and existing.get("port") == wanted:
|
|
117
|
+
return int(wanted) # it is our own service still holding it
|
|
118
|
+
raise UserError(f"port {wanted} is already in use")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _apply_ingress(
|
|
122
|
+
cf_path: Path, cf_text: str, name: str, hostname: str, port: int
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Write, validate and roll back the ingress edit. Returns the new text."""
|
|
125
|
+
try:
|
|
126
|
+
updated = tunnel.upsert(cf_text, name, hostname, port)
|
|
127
|
+
except tunnel.TunnelError as exc:
|
|
128
|
+
raise UserError(str(exc)) from exc
|
|
129
|
+
if updated == cf_text:
|
|
130
|
+
return updated
|
|
131
|
+
if tunnel.needs_sudo(cf_path):
|
|
132
|
+
ui.step(f"sudo needed to edit {cf_path}")
|
|
133
|
+
backup = tunnel.write_config(cf_path, updated)
|
|
134
|
+
good, message = tunnel.validate(cf_path)
|
|
135
|
+
if not good:
|
|
136
|
+
tunnel.restore(cf_path, backup)
|
|
137
|
+
raise UserError(f"ingress config rejected, rolled back: {message}")
|
|
138
|
+
ui.ok(f"ingress rule added {ui.dim(str(cf_path))}")
|
|
139
|
+
return updated
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _local_body(port: int, limit: int = 256) -> bytes:
|
|
143
|
+
"""Grab a sample of what the app serves, to compare against the public URL."""
|
|
144
|
+
try:
|
|
145
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=5) as response:
|
|
146
|
+
return response.read(limit)
|
|
147
|
+
except Exception:
|
|
148
|
+
return b""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _verify_public(url: str, port: int, attempts: int = 5) -> None:
|
|
152
|
+
"""Confirm the public URL really reaches this app, not something else.
|
|
153
|
+
|
|
154
|
+
On a zone with a wildcard DNS record any hostname resolves and returns a
|
|
155
|
+
page, so a half-finished publish looks like it worked. Comparing against
|
|
156
|
+
the local response is what actually catches that.
|
|
157
|
+
"""
|
|
158
|
+
expected = _local_body(port)
|
|
159
|
+
for attempt in range(attempts):
|
|
160
|
+
good, detail = tunnel.reaches_tunnel(url, expected)
|
|
161
|
+
if good:
|
|
162
|
+
ui.ok(f"public URL verified ({detail})")
|
|
163
|
+
return
|
|
164
|
+
if attempt < attempts - 1:
|
|
165
|
+
time.sleep(2.0)
|
|
166
|
+
ui.warn(f"{url} did not serve this app: {detail}")
|
|
167
|
+
ui.step("DNS may still be propagating — re-check in a minute")
|
|
168
|
+
ui.step("if a wildcard record covers this zone, it can shadow the hostname")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _publish(text: str, hostname: str) -> None:
|
|
172
|
+
tid = tunnel.tunnel_id(text)
|
|
173
|
+
if tid:
|
|
174
|
+
routed, message = tunnel.route_dns(tid, hostname)
|
|
175
|
+
if routed:
|
|
176
|
+
ui.ok(f"DNS → {hostname}")
|
|
177
|
+
else:
|
|
178
|
+
ui.warn(f"DNS not set automatically: {message}")
|
|
179
|
+
else:
|
|
180
|
+
ui.warn("no `tunnel:` id in config, skipped DNS")
|
|
181
|
+
reloaded, detail = tunnel.reload()
|
|
182
|
+
if reloaded:
|
|
183
|
+
ui.ok("cloudflared reloaded")
|
|
184
|
+
else:
|
|
185
|
+
ui.warn(f"could not reload cloudflared: {detail}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --- commands -----------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_up(args: argparse.Namespace) -> int:
|
|
192
|
+
state = config.load()
|
|
193
|
+
repo: dict | None = None
|
|
194
|
+
|
|
195
|
+
if source.is_repo_url(args.target):
|
|
196
|
+
url = source.normalize_url(args.target)
|
|
197
|
+
name = detect.slug(args.name or source.repo_name(url))
|
|
198
|
+
cache = source.checkout_dir(name)
|
|
199
|
+
ui.step(f"cloning {url}")
|
|
200
|
+
try:
|
|
201
|
+
branch = source.clone(url, cache, getattr(args, "branch", None))
|
|
202
|
+
sha = source.head(cache)
|
|
203
|
+
target = source.prepare_release(name, cache, sha)
|
|
204
|
+
except source.SourceError as exc:
|
|
205
|
+
raise UserError(str(exc)) from exc
|
|
206
|
+
repo = {"url": url, "branch": branch}
|
|
207
|
+
ui.ok(f"cloned {branch} @ {source.subject(cache)}")
|
|
208
|
+
else:
|
|
209
|
+
target = Path(args.target).expanduser().resolve() if args.target else Path.cwd()
|
|
210
|
+
if not target.is_dir():
|
|
211
|
+
raise UserError(f"not a directory: {target}")
|
|
212
|
+
name = detect.slug(args.name or target.name)
|
|
213
|
+
|
|
214
|
+
wants_buildpack = getattr(args, "buildpack", None)
|
|
215
|
+
if args.cmd:
|
|
216
|
+
command, how = args.cmd, "custom command"
|
|
217
|
+
elif wants_buildpack:
|
|
218
|
+
command, how = detect.BUILDPACK, "buildpack"
|
|
219
|
+
else:
|
|
220
|
+
try:
|
|
221
|
+
command, how = detect.detect_command(target)
|
|
222
|
+
except detect.DetectionError as exc:
|
|
223
|
+
# Nothing hoist knows how to run directly: a buildpack can still
|
|
224
|
+
# containerise it, if the user already has one installed.
|
|
225
|
+
if not containers.buildpack_tool():
|
|
226
|
+
raise detect.DetectionError(
|
|
227
|
+
f"{exc}. For other languages, hoist can use a Dockerfile, or Nixpacks / pack if you "
|
|
228
|
+
"install one (hoist never installs build tools itself)") from exc
|
|
229
|
+
command, how = detect.BUILDPACK, "buildpack"
|
|
230
|
+
|
|
231
|
+
kind = None
|
|
232
|
+
compose_path = None
|
|
233
|
+
generated = False
|
|
234
|
+
front_cfg = None
|
|
235
|
+
buildpack = None
|
|
236
|
+
if command == detect.BUILDPACK:
|
|
237
|
+
buildpack = containers.buildpack_tool(None if wants_buildpack in (None, "auto") else wants_buildpack)
|
|
238
|
+
if buildpack is None:
|
|
239
|
+
raise UserError("no buildpack tool found. Install Nixpacks (nixpacks.com) or pack "
|
|
240
|
+
"(buildpacks.io) yourself — hoist never installs build tools — or add a Dockerfile")
|
|
241
|
+
if not containers.available():
|
|
242
|
+
raise UserError("buildpacks build Docker images, and hoist needs a running Docker for it")
|
|
243
|
+
compose_path = containers.ensure_buildpack_compose(target, name, _parse_env(args.env))
|
|
244
|
+
generated = True
|
|
245
|
+
how = f"{buildpack} buildpack (port {containers.BUILDPACK_INNER_PORT} inside)"
|
|
246
|
+
command = detect.COMPOSE
|
|
247
|
+
if command == detect.DOCKERFILE:
|
|
248
|
+
if not containers.available():
|
|
249
|
+
raise UserError("this project has a Dockerfile, and hoist needs a running Docker for it")
|
|
250
|
+
compose_path = containers.ensure_dockerfile_compose(target, _parse_env(args.env))
|
|
251
|
+
generated = True
|
|
252
|
+
how = f"Dockerfile (port {containers.dockerfile_port(target)} inside)"
|
|
253
|
+
command = detect.COMPOSE
|
|
254
|
+
if command == detect.COMPOSE:
|
|
255
|
+
if not containers.available():
|
|
256
|
+
raise UserError("this project is a Compose stack, and hoist needs a running Docker for it")
|
|
257
|
+
compose_path = compose_path or containers.compose_file(target)
|
|
258
|
+
kind = "compose"
|
|
259
|
+
if not generated:
|
|
260
|
+
how = f"Compose stack ({compose_path.name})"
|
|
261
|
+
command = containers.compose_up_command(name, generated) + ("" if repo is not None else " --build")
|
|
262
|
+
if generated:
|
|
263
|
+
# hoist owns the public port and forwards to the container: seamless redeploys.
|
|
264
|
+
inner = containers.BUILDPACK_INNER_PORT if buildpack else containers.dockerfile_port(target)
|
|
265
|
+
front_cfg = {"inner": inner, "bind": "0.0.0.0" if args.local else "127.0.0.1"}
|
|
266
|
+
command = front.front_command(name)
|
|
267
|
+
elif not args.port and not containers.compose_uses_port_var(compose_path):
|
|
268
|
+
published = containers.compose_published_port(compose_path)
|
|
269
|
+
if published is None:
|
|
270
|
+
raise UserError("the compose file publishes no port — add `ports: [\"${PORT}:<container port>\"]`"
|
|
271
|
+
" to the web service, or pass --port")
|
|
272
|
+
args.port = published
|
|
273
|
+
|
|
274
|
+
if repo is not None:
|
|
275
|
+
build = getattr(args, "build", None)
|
|
276
|
+
if buildpack:
|
|
277
|
+
steps = [build or containers.buildpack_build_step(buildpack, name)]
|
|
278
|
+
elif kind == "compose" and not build:
|
|
279
|
+
steps = [containers.compose_build_step(name, generated)]
|
|
280
|
+
else:
|
|
281
|
+
steps = [build] if build else source.detect_build(target, command)
|
|
282
|
+
ok, detail = source.run_build(target, steps, log=ui.step)
|
|
283
|
+
if not ok:
|
|
284
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
285
|
+
raise UserError(f"build failed: {detail}")
|
|
286
|
+
if steps:
|
|
287
|
+
ui.ok("built")
|
|
288
|
+
source.mark_built(target)
|
|
289
|
+
source.activate(name, target)
|
|
290
|
+
source.prune_releases(name)
|
|
291
|
+
target = source.current_link(name)
|
|
292
|
+
if not args.cmd:
|
|
293
|
+
command = source.use_venv(target, command)
|
|
294
|
+
repo.update(sha=sha, build=steps, deployed=config.now(),
|
|
295
|
+
commit=source.subject(cache, sha))
|
|
296
|
+
previous = config.get_app(state, name)
|
|
297
|
+
if previous and (previous.get("repo") or {}).get("history"):
|
|
298
|
+
repo["history"] = previous["repo"]["history"]
|
|
299
|
+
source.record_deploy(repo, {
|
|
300
|
+
"sha": repo["sha"], "commit": repo["commit"], "at": config.now(),
|
|
301
|
+
"trigger": "initial", "status": "ok", "seconds": 0, "log": [],
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
existing = config.get_app(state, name)
|
|
305
|
+
port = _pick_port(args, existing)
|
|
306
|
+
|
|
307
|
+
use_tunnel = not args.local
|
|
308
|
+
cf_path: Path | None = None
|
|
309
|
+
cf_text: str | None = None
|
|
310
|
+
domain: str | None = None
|
|
311
|
+
hostname: str | None = None
|
|
312
|
+
|
|
313
|
+
if use_tunnel:
|
|
314
|
+
if not tunnel.have_cloudflared():
|
|
315
|
+
raise UserError(
|
|
316
|
+
"cloudflared is not installed -- install it, or use --local "
|
|
317
|
+
"for a LAN-only URL"
|
|
318
|
+
)
|
|
319
|
+
cf_path = tunnel.find_config()
|
|
320
|
+
cf_text = cf_path.read_text()
|
|
321
|
+
domain = _resolve_domain(state, args.domain, cf_text)
|
|
322
|
+
hostname = args.hostname or f"{name}.{domain}"
|
|
323
|
+
|
|
324
|
+
env = _parse_env(args.env)
|
|
325
|
+
if args.local:
|
|
326
|
+
env.setdefault("HOST", "0.0.0.0")
|
|
327
|
+
command = command.replace("--bind 127.0.0.1", "--bind 0.0.0.0")
|
|
328
|
+
|
|
329
|
+
app = {
|
|
330
|
+
"name": name,
|
|
331
|
+
"port": port,
|
|
332
|
+
"workdir": str(target),
|
|
333
|
+
"cmd": command,
|
|
334
|
+
"env": env,
|
|
335
|
+
"tunnel": use_tunnel,
|
|
336
|
+
"hostname": hostname,
|
|
337
|
+
"created": (existing or {}).get("created") or config.now(),
|
|
338
|
+
"updated": config.now(),
|
|
339
|
+
}
|
|
340
|
+
if generated and repo is None:
|
|
341
|
+
# A local folder has no deploy step to build in, so build the image now.
|
|
342
|
+
step = getattr(args, "build", None) or (containers.buildpack_build_step(buildpack, name) if buildpack
|
|
343
|
+
else containers.compose_build_step(name, True))
|
|
344
|
+
ok, detail = source.run_build(target, [step], log=ui.step)
|
|
345
|
+
if not ok:
|
|
346
|
+
raise UserError(f"build failed: {detail}")
|
|
347
|
+
ui.ok("built")
|
|
348
|
+
if repo is not None:
|
|
349
|
+
app["repo"] = repo
|
|
350
|
+
if kind == "compose":
|
|
351
|
+
app["kind"] = "compose"
|
|
352
|
+
if front_cfg:
|
|
353
|
+
app["front"] = front_cfg
|
|
354
|
+
if generated and buildpack:
|
|
355
|
+
app["buildpack"] = buildpack
|
|
356
|
+
elif generated:
|
|
357
|
+
app["dockerfile"] = True
|
|
358
|
+
if getattr(args, "preview_of", None):
|
|
359
|
+
app["preview_of"] = args.preview_of
|
|
360
|
+
if getattr(args, "stage", None):
|
|
361
|
+
from .dash import team as _team
|
|
362
|
+
if args.stage not in _team.STAGES:
|
|
363
|
+
raise UserError(f"stage must be one of {', '.join(_team.STAGES)}")
|
|
364
|
+
app["stage"] = args.stage
|
|
365
|
+
elif existing and existing.get("stage"):
|
|
366
|
+
app["stage"] = existing["stage"]
|
|
367
|
+
|
|
368
|
+
ui.info(f"{ui.bold(name)} {ui.dim('·')} {how} {ui.dim('·')} port {port}")
|
|
369
|
+
ui.step(f"$ {command.replace('$PORT', str(port))}")
|
|
370
|
+
|
|
371
|
+
if not service.have_systemd():
|
|
372
|
+
raise UserError("systemd --user is unavailable; hoist needs it to run apps")
|
|
373
|
+
|
|
374
|
+
service.write_unit(app)
|
|
375
|
+
if app.get("front"):
|
|
376
|
+
# `hoist front` reads its own configuration when it starts, so the app must be
|
|
377
|
+
# on record before its unit is.
|
|
378
|
+
with config.transaction() as _state:
|
|
379
|
+
config.put_app(_state, app)
|
|
380
|
+
try:
|
|
381
|
+
if existing:
|
|
382
|
+
service.restart(name)
|
|
383
|
+
else:
|
|
384
|
+
service.start(name)
|
|
385
|
+
except service.ServiceError as exc:
|
|
386
|
+
raise UserError(f"failed to start service: {exc}") from exc
|
|
387
|
+
|
|
388
|
+
health = _report_health(name, port, timeout=240 if kind == "compose" else HEALTH_TIMEOUT,
|
|
389
|
+
check=detect.http_answers if kind == "compose" else None)
|
|
390
|
+
|
|
391
|
+
# The app is running from here on, so record it before anything that can
|
|
392
|
+
# fail -- otherwise a failed tunnel step leaves a live service that
|
|
393
|
+
# `hoist ls` cannot see and `hoist down` cannot stop.
|
|
394
|
+
config.put_app(state, app)
|
|
395
|
+
config.save(state)
|
|
396
|
+
|
|
397
|
+
if use_tunnel:
|
|
398
|
+
assert cf_path is not None and cf_text is not None and hostname is not None
|
|
399
|
+
updated = _apply_ingress(cf_path, cf_text, name, hostname, port)
|
|
400
|
+
_publish(updated, hostname)
|
|
401
|
+
state["domain"] = domain
|
|
402
|
+
config.save(state)
|
|
403
|
+
if health == "up":
|
|
404
|
+
_verify_public(f"https://{hostname}", port)
|
|
405
|
+
else:
|
|
406
|
+
app["lan_ip"] = detect.lan_ip()
|
|
407
|
+
config.put_app(state, app)
|
|
408
|
+
config.save(state)
|
|
409
|
+
|
|
410
|
+
if repo is not None and not getattr(args, "no_sync", False):
|
|
411
|
+
try:
|
|
412
|
+
source.install_sync_timer(name, service.UNIT_DIR, service.systemctl)
|
|
413
|
+
ui.ok(f"watching {repo['branch']} — every push redeploys (checks every {source.SYNC_INTERVAL}s)")
|
|
414
|
+
except source.SourceError as exc:
|
|
415
|
+
ui.warn(f"auto-deploy not enabled: {exc}")
|
|
416
|
+
|
|
417
|
+
url = _app_url(app)
|
|
418
|
+
ui.banner(url, f"hoist logs {name} · hoist down {name}")
|
|
419
|
+
if not args.no_qr and health == "up":
|
|
420
|
+
_show_qr(url, args.ascii)
|
|
421
|
+
return 0 if health == "up" else 1
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def cmd_share(args: argparse.Namespace) -> int:
|
|
425
|
+
"""Serve a folder of files publicly, no project scaffolding required."""
|
|
426
|
+
directory = Path(args.path).expanduser().resolve()
|
|
427
|
+
if not directory.is_dir():
|
|
428
|
+
raise UserError(f"not a directory: {directory}")
|
|
429
|
+
python = sys.executable or "python3"
|
|
430
|
+
bind = "0.0.0.0" if args.local else "127.0.0.1"
|
|
431
|
+
args.target = str(directory)
|
|
432
|
+
args.cmd = f"{python} -m http.server $PORT --bind {bind} --directory {directory}"
|
|
433
|
+
args.name = args.name or directory.name
|
|
434
|
+
return cmd_up(args)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def cmd_ls(args: argparse.Namespace) -> int:
|
|
438
|
+
state = config.load()
|
|
439
|
+
apps = state["apps"]
|
|
440
|
+
if not apps:
|
|
441
|
+
ui.info("nothing hoisted yet — try: hoist up .")
|
|
442
|
+
return 0
|
|
443
|
+
|
|
444
|
+
rows = []
|
|
445
|
+
for name in sorted(apps):
|
|
446
|
+
app = apps[name]
|
|
447
|
+
unit_state = "adopted" if app.get("adopted") else service.status(name)
|
|
448
|
+
if app.get("stopped"):
|
|
449
|
+
unit_state = "stopped"
|
|
450
|
+
listening = detect.port_in_use(app["port"])
|
|
451
|
+
if listening and unit_state in ("active", "adopted"):
|
|
452
|
+
health = ui.green("up")
|
|
453
|
+
elif unit_state == "adopted":
|
|
454
|
+
# There is no unit to ask about an adopted process, so the port is
|
|
455
|
+
# the only evidence there is -- and a dead one still has a DNS
|
|
456
|
+
# record and an ingress rule pointing at it, so the public URL
|
|
457
|
+
# answers 502. Printing "adopted" here read like an ordinary state
|
|
458
|
+
# and hid exactly that.
|
|
459
|
+
health = ui.red("down")
|
|
460
|
+
elif unit_state == "active":
|
|
461
|
+
health = ui.yellow("starting")
|
|
462
|
+
elif unit_state == "failed":
|
|
463
|
+
health = ui.red("failed")
|
|
464
|
+
else:
|
|
465
|
+
health = ui.dim(unit_state)
|
|
466
|
+
repo = app.get("repo")
|
|
467
|
+
src = (f"{repo['branch']}@{repo.get('sha', '')[:7]}" if repo
|
|
468
|
+
else app["db"]["engine"] if app.get("kind") == "db" else ui.dim("local"))
|
|
469
|
+
if repo and repo.get("paused"):
|
|
470
|
+
src += " " + ui.yellow("(paused)")
|
|
471
|
+
if repo and repo.get("failed_sha"):
|
|
472
|
+
src += " " + ui.red(f"(build of {repo['failed_sha'][:7]} failed)")
|
|
473
|
+
shown = _app_url(app)
|
|
474
|
+
if app.get("kind") == "db": # never print a password in a listing
|
|
475
|
+
shown = re.sub(r"(://[^:/@]*:)[^@]+@", r"\1•••@", shown)
|
|
476
|
+
rows.append([name, str(app["port"]), health, src, shown])
|
|
477
|
+
ui.table(rows, ["NAME", "PORT", "STATE", "SOURCE", "URL"])
|
|
478
|
+
return 0
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def cmd_down(args: argparse.Namespace) -> int:
|
|
482
|
+
state = config.load()
|
|
483
|
+
app = config.get_app(state, args.name)
|
|
484
|
+
if app is None:
|
|
485
|
+
raise UserError(f"no hoisted app called {args.name!r}")
|
|
486
|
+
|
|
487
|
+
if app.get("repo"):
|
|
488
|
+
source.remove_sync_timer(args.name, service.UNIT_DIR, service.systemctl)
|
|
489
|
+
source.remove_all(args.name)
|
|
490
|
+
ui.ok("stopped watching the repo, checkout removed")
|
|
491
|
+
|
|
492
|
+
if not app.get("adopted"):
|
|
493
|
+
service.stop_and_remove(args.name)
|
|
494
|
+
ui.ok("service stopped and removed")
|
|
495
|
+
if app.get("kind") == "compose":
|
|
496
|
+
kept = containers.compose_down(args.name, purge=getattr(args, "purge", False))
|
|
497
|
+
ui.ok("compose stack removed" + (" with its volumes" if getattr(args, "purge", False) else ""))
|
|
498
|
+
if kept:
|
|
499
|
+
ui.step(f"data kept in {', '.join(kept)} — `hoist down {args.name} --purge` deletes it")
|
|
500
|
+
if app.get("kind") == "db":
|
|
501
|
+
containers.run("rm", "-f", containers.container_name(args.name), timeout=60)
|
|
502
|
+
base = containers.backup_unit_base(args.name)
|
|
503
|
+
service.systemctl("disable", "--now", f"{base}.timer")
|
|
504
|
+
for suffix in (".timer", ".service"):
|
|
505
|
+
(service.UNIT_DIR / f"{base}{suffix}").unlink(missing_ok=True)
|
|
506
|
+
if getattr(args, "purge", False):
|
|
507
|
+
containers.remove_volume(args.name)
|
|
508
|
+
ui.ok(f"data volume {app['db']['volume']} deleted")
|
|
509
|
+
else:
|
|
510
|
+
ui.step(f"data kept in volume {app['db']['volume']} — `hoist down {args.name} --purge` deletes it")
|
|
511
|
+
|
|
512
|
+
if app.get("tunnel"):
|
|
513
|
+
try:
|
|
514
|
+
cf_path = tunnel.find_config()
|
|
515
|
+
updated, changed = tunnel.remove(cf_path.read_text(), args.name)
|
|
516
|
+
if changed:
|
|
517
|
+
if tunnel.needs_sudo(cf_path):
|
|
518
|
+
ui.step(f"sudo needed to edit {cf_path}")
|
|
519
|
+
backup = tunnel.write_config(cf_path, updated)
|
|
520
|
+
good, message = tunnel.validate(cf_path)
|
|
521
|
+
if good:
|
|
522
|
+
ui.ok("ingress rule removed")
|
|
523
|
+
reloaded, detail = tunnel.reload()
|
|
524
|
+
if not reloaded:
|
|
525
|
+
ui.warn(f"could not reload cloudflared: {detail}")
|
|
526
|
+
else:
|
|
527
|
+
tunnel.restore(cf_path, backup)
|
|
528
|
+
ui.warn(f"ingress edit rolled back: {message}")
|
|
529
|
+
except tunnel.TunnelError as exc:
|
|
530
|
+
ui.warn(f"left ingress alone: {exc}")
|
|
531
|
+
if app.get("hostname"):
|
|
532
|
+
ui.step(f"DNS record for {app['hostname']} was left in place")
|
|
533
|
+
|
|
534
|
+
config.drop_app(state, args.name)
|
|
535
|
+
config.save(state)
|
|
536
|
+
ui.ok(f"{args.name} is down")
|
|
537
|
+
return 0
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def cmd_sync(args: argparse.Namespace) -> int:
|
|
541
|
+
"""Redeploy a git-backed app if its branch has moved. Run by the timer.
|
|
542
|
+
|
|
543
|
+
A failed fetch or build never takes the app down: the running process
|
|
544
|
+
keeps serving the last good commit, and a failed commit is not retried
|
|
545
|
+
until something newer is pushed. `--to <sha>` rolls back to an earlier
|
|
546
|
+
commit and pauses auto-deploy, so the next tick does not undo it.
|
|
547
|
+
"""
|
|
548
|
+
state = config.load()
|
|
549
|
+
app = config.get_app(state, args.name)
|
|
550
|
+
if app is None or not app.get("repo"):
|
|
551
|
+
raise UserError(f"{args.name!r} is not a git-backed app")
|
|
552
|
+
repo = app["repo"]
|
|
553
|
+
target = source.checkout_dir(args.name)
|
|
554
|
+
to = getattr(args, "to", None)
|
|
555
|
+
manual = bool(args.force or to)
|
|
556
|
+
|
|
557
|
+
if not manual and (repo.get("paused") or app.get("stopped")):
|
|
558
|
+
return 0
|
|
559
|
+
|
|
560
|
+
# One deploy per app at a time: the timer and a dashboard redeploy can
|
|
561
|
+
# otherwise build the same app concurrently.
|
|
562
|
+
lock_path = source.releases_dir(args.name)
|
|
563
|
+
lock_path.mkdir(parents=True, exist_ok=True)
|
|
564
|
+
lock = open(lock_path / ".deploy-lock", "a+")
|
|
565
|
+
try:
|
|
566
|
+
fcntl.flock(lock, fcntl.LOCK_EX | (0 if manual or getattr(args, "wait", False) else fcntl.LOCK_NB))
|
|
567
|
+
except BlockingIOError:
|
|
568
|
+
return 0 # a deploy is already running; the next tick re-checks
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
wanted = source.fetch_sha(target, to) if to else source.remote_head(target, repo["branch"])
|
|
572
|
+
except source.SourceError as exc:
|
|
573
|
+
if manual:
|
|
574
|
+
raise UserError(str(exc)) from exc
|
|
575
|
+
print(f"fetch skipped: {exc}")
|
|
576
|
+
return 0 # offline or rate-limited; the next tick tries again
|
|
577
|
+
if not manual and repo.get("previews") and not app.get("preview_of"):
|
|
578
|
+
_reconcile_previews(args.name, app)
|
|
579
|
+
if wanted in (repo.get("sha"), repo.get("failed_sha")) and not manual:
|
|
580
|
+
return 0
|
|
581
|
+
|
|
582
|
+
started = time.monotonic()
|
|
583
|
+
commit = source.subject(target, wanted)
|
|
584
|
+
trigger = "rollback" if to else ("manual" if args.force else "push")
|
|
585
|
+
print(f"deploying {commit} ({trigger})")
|
|
586
|
+
transcript: list[str] = []
|
|
587
|
+
release = source.find_release(args.name, wanted) if to else None
|
|
588
|
+
env_keys = list((app.get("env") or {}).keys())
|
|
589
|
+
if release is not None:
|
|
590
|
+
ok, detail = True, ""
|
|
591
|
+
transcript.append(f"reusing the release built earlier: {release.name}")
|
|
592
|
+
print(transcript[-1])
|
|
593
|
+
if app.get("dockerfile"): # an old release may carry the pre-rollover compose file
|
|
594
|
+
containers.ensure_dockerfile_compose(release, env_keys)
|
|
595
|
+
elif app.get("buildpack"):
|
|
596
|
+
containers.ensure_buildpack_compose(release, args.name, env_keys)
|
|
597
|
+
else:
|
|
598
|
+
try:
|
|
599
|
+
release = source.prepare_release(args.name, target, wanted)
|
|
600
|
+
except source.SourceError as exc:
|
|
601
|
+
if manual:
|
|
602
|
+
raise UserError(str(exc)) from exc
|
|
603
|
+
print(f"skipped: {exc}")
|
|
604
|
+
return 0
|
|
605
|
+
if app.get("dockerfile"):
|
|
606
|
+
containers.ensure_dockerfile_compose(release, env_keys)
|
|
607
|
+
elif app.get("buildpack"):
|
|
608
|
+
containers.ensure_buildpack_compose(release, args.name, env_keys)
|
|
609
|
+
ok, detail = source.run_build(release, repo.get("build", []), transcript=transcript)
|
|
610
|
+
|
|
611
|
+
# Seamless path: start the new container beside the old and let `hoist front`
|
|
612
|
+
# switch to it once it answers. A new version that never answers fails the
|
|
613
|
+
# deploy here, with the old one untouched and still serving.
|
|
614
|
+
rolled = False
|
|
615
|
+
if ok and app.get("front") and not app.get("stopped"):
|
|
616
|
+
swapped, why = front.rollover(args.name, release, app.get("env"))
|
|
617
|
+
if swapped is False:
|
|
618
|
+
ok, detail = False, why
|
|
619
|
+
transcript.append(why)
|
|
620
|
+
elif swapped:
|
|
621
|
+
rolled = True
|
|
622
|
+
transcript.append("started beside the old version; traffic switched with no gap")
|
|
623
|
+
|
|
624
|
+
entry = {"sha": wanted, "commit": commit, "at": config.now(), "trigger": trigger,
|
|
625
|
+
"seconds": round(time.monotonic() - started, 1)}
|
|
626
|
+
if os.environ.get("HOIST_ACTOR"):
|
|
627
|
+
entry["by"] = os.environ["HOIST_ACTOR"]
|
|
628
|
+
if not ok:
|
|
629
|
+
shutil.rmtree(release, ignore_errors=True) # never live, never needed
|
|
630
|
+
entry.update(status="failed", log=transcript[-60:], error=detail.splitlines()[0])
|
|
631
|
+
with config.transaction() as state: # re-read: the build may have run for minutes
|
|
632
|
+
app = config.get_app(state, args.name)
|
|
633
|
+
if app is None:
|
|
634
|
+
return 0 # taken down while we were building
|
|
635
|
+
app["repo"]["failed_sha"] = wanted
|
|
636
|
+
source.record_deploy(app["repo"], entry)
|
|
637
|
+
print(f"build failed, still serving {repo.get('sha', '')[:7]}: {detail}")
|
|
638
|
+
notify.send(f"{args.name}: push {wanted[:7]} failed to build",
|
|
639
|
+
f"{commit}\n{detail.splitlines()[0]}\nStill serving {repo.get('sha', '')[:7]}.",
|
|
640
|
+
level="bad", app=args.name)
|
|
641
|
+
return 1 if manual else 0
|
|
642
|
+
|
|
643
|
+
with config.transaction() as state:
|
|
644
|
+
app = config.get_app(state, args.name)
|
|
645
|
+
if app is None:
|
|
646
|
+
shutil.rmtree(release, ignore_errors=True)
|
|
647
|
+
return 0
|
|
648
|
+
source.mark_built(release)
|
|
649
|
+
source.activate(args.name, release)
|
|
650
|
+
source.prune_releases(args.name)
|
|
651
|
+
link = str(source.current_link(args.name))
|
|
652
|
+
if app.get("workdir") != link:
|
|
653
|
+
# An app deployed before releases existed ran straight from the git
|
|
654
|
+
# cache; point its unit at the release link from now on.
|
|
655
|
+
app["workdir"] = link
|
|
656
|
+
app["cmd"] = app.get("cmd", "").replace(str(target), link)
|
|
657
|
+
service.write_unit(app)
|
|
658
|
+
if (app.get("dockerfile") or app.get("buildpack")) and not app.get("front"):
|
|
659
|
+
# Deployed before seamless rollover existed: switch it over now. This one
|
|
660
|
+
# redeploy still restarts the container; every later one does not.
|
|
661
|
+
inner = (containers.BUILDPACK_INNER_PORT if app.get("buildpack")
|
|
662
|
+
else containers.dockerfile_port(release))
|
|
663
|
+
app["front"] = {"inner": inner, "bind": "127.0.0.1" if app.get("tunnel") else "0.0.0.0"}
|
|
664
|
+
app["cmd"] = front.front_command(args.name)
|
|
665
|
+
service.write_unit(app)
|
|
666
|
+
app["repo"].update(sha=wanted, commit=commit, deployed=config.now())
|
|
667
|
+
app["repo"].pop("failed_sha", None)
|
|
668
|
+
if to:
|
|
669
|
+
app["repo"]["paused"] = True
|
|
670
|
+
stopped, port, kind = bool(app.get("stopped")), int(app["port"]), app.get("kind")
|
|
671
|
+
hostname = app.get("hostname") if app.get("tunnel") else None
|
|
672
|
+
|
|
673
|
+
# Restart and wait outside the lock: health checks take seconds.
|
|
674
|
+
if rolled:
|
|
675
|
+
health = "up"
|
|
676
|
+
elif not stopped:
|
|
677
|
+
service.restart(args.name)
|
|
678
|
+
health = _wait_for_healthy(args.name, port, 240 if kind == "compose" else HEALTH_TIMEOUT,
|
|
679
|
+
detect.http_answers if kind == "compose" else None)
|
|
680
|
+
else:
|
|
681
|
+
health = "stopped"
|
|
682
|
+
entry.update(status="ok" if health in ("up", "stopped") else "unhealthy",
|
|
683
|
+
seconds=round(time.monotonic() - started, 1), log=transcript[-60:])
|
|
684
|
+
with config.transaction() as state:
|
|
685
|
+
app = config.get_app(state, args.name)
|
|
686
|
+
if app is not None:
|
|
687
|
+
source.record_deploy(app["repo"], entry)
|
|
688
|
+
print(f"deployed {commit} ({health})")
|
|
689
|
+
notify.send(f"{args.name} deployed {wanted[:7]}" + (" (rollback)" if to else ""),
|
|
690
|
+
f"{commit}\n{'https://' + hostname if hostname else ''}".strip(),
|
|
691
|
+
level="ok" if entry["status"] == "ok" else "warn", app=args.name)
|
|
692
|
+
if to:
|
|
693
|
+
print("auto-deploy paused after rollback — resume with: hoist resume " + args.name)
|
|
694
|
+
return 0
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
PREVIEW_LIMIT = 5
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def preview_name(app: str, branch: str) -> str:
|
|
701
|
+
return detect.slug(f"{app}-{branch}")[:40].strip("-")
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _reconcile_previews(name: str, app: dict) -> None:
|
|
705
|
+
"""One preview app per branch: create it on a new branch, remove it when the
|
|
706
|
+
branch is deleted. At most one new preview per tick, so a burst of branches
|
|
707
|
+
never stalls the main app's deploys."""
|
|
708
|
+
repo = app["repo"]
|
|
709
|
+
try:
|
|
710
|
+
heads = source.remote_branches(source.checkout_dir(name))
|
|
711
|
+
except source.SourceError as exc:
|
|
712
|
+
print(f"previews skipped: {exc}")
|
|
713
|
+
return
|
|
714
|
+
state = config.load()
|
|
715
|
+
existing = {a["repo"]["branch"]: n for n, a in state["apps"].items()
|
|
716
|
+
if a.get("preview_of") == name and a.get("repo")}
|
|
717
|
+
hoist = [sys.executable, "-m", "hoist"]
|
|
718
|
+
for branch, pname in existing.items():
|
|
719
|
+
if branch not in heads:
|
|
720
|
+
print(f"branch {branch} is gone, removing preview {pname}")
|
|
721
|
+
subprocess.run([*hoist, "down", pname], capture_output=True, text=True, timeout=300)
|
|
722
|
+
notify.send(f"Preview {pname} removed", f"Branch {branch} was deleted.", level="info", app=pname)
|
|
723
|
+
failed = dict(repo.get("preview_failed") or {})
|
|
724
|
+
for branch in sorted(heads):
|
|
725
|
+
if branch == repo["branch"] or branch in existing:
|
|
726
|
+
continue
|
|
727
|
+
if failed.get(branch) == heads[branch]:
|
|
728
|
+
continue # this commit already failed; wait for a new push to the branch
|
|
729
|
+
if len(existing) >= PREVIEW_LIMIT:
|
|
730
|
+
print(f"preview limit ({PREVIEW_LIMIT}) reached; {branch} gets no preview")
|
|
731
|
+
break
|
|
732
|
+
pname = preview_name(name, branch)
|
|
733
|
+
if config.get_app(state, pname):
|
|
734
|
+
continue
|
|
735
|
+
args = [*hoist, "up", repo["url"], "--branch", branch, "--name", pname, "--no-qr",
|
|
736
|
+
"--preview-of", name]
|
|
737
|
+
if not app.get("tunnel"):
|
|
738
|
+
args.append("--local")
|
|
739
|
+
for key, value in (app.get("env") or {}).items():
|
|
740
|
+
args += ["--env", f"{key}={value}"]
|
|
741
|
+
print(f"new branch {branch}: deploying preview {pname}")
|
|
742
|
+
result = subprocess.run(args, capture_output=True, text=True, timeout=1200)
|
|
743
|
+
print((result.stdout + result.stderr).strip()[-2000:])
|
|
744
|
+
with config.transaction() as fresh_state:
|
|
745
|
+
main = config.get_app(fresh_state, name)
|
|
746
|
+
if main and main.get("repo"):
|
|
747
|
+
record = main["repo"].setdefault("preview_failed", {})
|
|
748
|
+
if result.returncode == 0:
|
|
749
|
+
record.pop(branch, None)
|
|
750
|
+
else:
|
|
751
|
+
record[branch] = heads[branch]
|
|
752
|
+
for gone in [b for b in record if b not in heads]:
|
|
753
|
+
record.pop(gone)
|
|
754
|
+
if result.returncode == 0:
|
|
755
|
+
fresh = config.get_app(config.load(), pname) or {}
|
|
756
|
+
url = _app_url(fresh) if fresh else ""
|
|
757
|
+
notify.send(f"Preview of {branch} is live", f"{pname}: {url}", level="ok", app=pname)
|
|
758
|
+
else:
|
|
759
|
+
notify.send(f"Preview of {branch} failed to build", f"{pname} — fix the branch and push again.",
|
|
760
|
+
level="bad", app=name)
|
|
761
|
+
break # one new preview per tick
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def cmd_webhook(args: argparse.Namespace) -> int:
|
|
765
|
+
import secrets as _secrets
|
|
766
|
+
with config.transaction() as state:
|
|
767
|
+
app = config.get_app(state, args.name)
|
|
768
|
+
if app is None or not app.get("repo"):
|
|
769
|
+
raise UserError(f"{args.name!r} is not deployed from git")
|
|
770
|
+
if args.off:
|
|
771
|
+
app["repo"].pop("webhook_secret", None)
|
|
772
|
+
secret = None
|
|
773
|
+
else:
|
|
774
|
+
secret = _secrets.token_hex(20)
|
|
775
|
+
app["repo"]["webhook_secret"] = secret
|
|
776
|
+
base = (state.get("dash_url") or "https://<your published dashboard>").rstrip("/")
|
|
777
|
+
if secret is None:
|
|
778
|
+
ui.ok("webhook removed; pushes are picked up by the 20 s check")
|
|
779
|
+
return 0
|
|
780
|
+
ui.ok("add this webhook to the repo (GitHub: Settings → Webhooks; content type application/json):")
|
|
781
|
+
ui.step(f"Payload URL {base}/hooks/{args.name}")
|
|
782
|
+
ui.step(f"Secret {secret}")
|
|
783
|
+
ui.step("pushes then deploy within seconds; the 20 s check stays on as a fallback")
|
|
784
|
+
if "<" in base:
|
|
785
|
+
ui.step("the dashboard must be reachable by GitHub: hoist dash --public dash")
|
|
786
|
+
return 0
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def cmd_front(args: argparse.Namespace) -> int:
|
|
790
|
+
"""Run by the app's systemd unit: own the public port and forward to the container."""
|
|
791
|
+
import asyncio
|
|
792
|
+
app = config.get_app(config.load(), args.name)
|
|
793
|
+
if app is None or not app.get("front"):
|
|
794
|
+
raise UserError(f"{args.name!r} is not a seamless-rollover app")
|
|
795
|
+
cfg = app["front"]
|
|
796
|
+
workdir = Path(app["workdir"])
|
|
797
|
+
docker = front.Docker(containers.compose_project(args.name), lambda: workdir.resolve(),
|
|
798
|
+
env=dict(app.get("env") or {}))
|
|
799
|
+
return asyncio.run(front.serve(args.name, docker, cfg["bind"], int(app["port"]), int(cfg["inner"])))
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def cmd_stage(args: argparse.Namespace) -> int:
|
|
803
|
+
from .dash import team
|
|
804
|
+
if args.stage not in team.STAGES:
|
|
805
|
+
raise UserError(f"stage must be one of {', '.join(team.STAGES)}")
|
|
806
|
+
with config.transaction() as state:
|
|
807
|
+
app = config.get_app(state, args.name)
|
|
808
|
+
if app is None:
|
|
809
|
+
raise UserError(f"no hoisted app called {args.name!r}")
|
|
810
|
+
app["stage"] = args.stage
|
|
811
|
+
ui.ok(f"{args.name} is now in {args.stage}")
|
|
812
|
+
return 0
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def cmd_previews(args: argparse.Namespace) -> int:
|
|
816
|
+
with config.transaction() as state:
|
|
817
|
+
app = config.get_app(state, args.name)
|
|
818
|
+
if app is None or not app.get("repo"):
|
|
819
|
+
raise UserError(f"{args.name!r} is not deployed from git")
|
|
820
|
+
if app.get("preview_of"):
|
|
821
|
+
raise UserError(f"{args.name} is itself a preview")
|
|
822
|
+
if args.state == "on":
|
|
823
|
+
app["repo"]["previews"] = True
|
|
824
|
+
else:
|
|
825
|
+
app["repo"].pop("previews", None)
|
|
826
|
+
if args.state == "on":
|
|
827
|
+
ui.ok(f"every branch pushed to {app['repo']['url']} gets its own preview app "
|
|
828
|
+
f"(up to {PREVIEW_LIMIT}), removed when the branch is deleted")
|
|
829
|
+
else:
|
|
830
|
+
ui.ok("branch previews off — existing previews stay until you take them down")
|
|
831
|
+
return 0
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _set_flag(name: str, key: str, value: bool, *, repo: bool = False) -> dict:
|
|
835
|
+
with config.transaction() as state:
|
|
836
|
+
app = config.get_app(state, name)
|
|
837
|
+
if app is None:
|
|
838
|
+
raise UserError(f"no hoisted app called {name!r}")
|
|
839
|
+
holder = app.get("repo") if repo else app
|
|
840
|
+
if repo and holder is None:
|
|
841
|
+
raise UserError(f"{name} is not deployed from git")
|
|
842
|
+
if value:
|
|
843
|
+
holder[key] = True
|
|
844
|
+
else:
|
|
845
|
+
holder.pop(key, None)
|
|
846
|
+
return app
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def cmd_pause(args: argparse.Namespace) -> int:
|
|
850
|
+
_set_flag(args.name, "paused", True, repo=True)
|
|
851
|
+
ui.ok(f"auto-deploy paused for {args.name} — pushes are ignored until: hoist resume {args.name}")
|
|
852
|
+
return 0
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def cmd_resume(args: argparse.Namespace) -> int:
|
|
856
|
+
_set_flag(args.name, "paused", False, repo=True)
|
|
857
|
+
ui.ok(f"auto-deploy resumed for {args.name}")
|
|
858
|
+
return 0
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def cmd_stop(args: argparse.Namespace) -> int:
|
|
862
|
+
app = _set_flag(args.name, "stopped", True)
|
|
863
|
+
if app.get("adopted"):
|
|
864
|
+
raise UserError(f"{args.name} was adopted; hoist does not manage its process")
|
|
865
|
+
service.systemctl("stop", service.unit_name(args.name))
|
|
866
|
+
ui.ok(f"{args.name} stopped (its URL now answers 502) — start it with: hoist start {args.name}")
|
|
867
|
+
return 0
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def cmd_start(args: argparse.Namespace) -> int:
|
|
871
|
+
app = _set_flag(args.name, "stopped", False)
|
|
872
|
+
if app.get("adopted"):
|
|
873
|
+
raise UserError(f"{args.name} was adopted; hoist does not manage its process")
|
|
874
|
+
service.start(args.name)
|
|
875
|
+
_report_health(args.name, app["port"])
|
|
876
|
+
return 0
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def cmd_logs(args: argparse.Namespace) -> int:
|
|
880
|
+
state = config.load()
|
|
881
|
+
app = config.get_app(state, args.name)
|
|
882
|
+
if app is None:
|
|
883
|
+
raise UserError(f"no hoisted app called {args.name!r}")
|
|
884
|
+
if app.get("adopted"):
|
|
885
|
+
raise UserError(f"{args.name} was adopted; hoist does not manage its logs")
|
|
886
|
+
if getattr(args, "deploys", False):
|
|
887
|
+
if not app.get("repo"):
|
|
888
|
+
raise UserError(f"{args.name} is not deployed from git")
|
|
889
|
+
return service.logs(args.name, lines=args.lines, follow=args.follow,
|
|
890
|
+
unit=f"{source.sync_unit_base(args.name)}.service")
|
|
891
|
+
return service.logs(args.name, lines=args.lines, follow=args.follow)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def cmd_restart(args: argparse.Namespace) -> int:
|
|
895
|
+
state = config.load()
|
|
896
|
+
app = config.get_app(state, args.name)
|
|
897
|
+
if app is None:
|
|
898
|
+
raise UserError(f"no hoisted app called {args.name!r}")
|
|
899
|
+
if app.get("adopted"):
|
|
900
|
+
raise UserError(f"{args.name} was adopted; hoist does not manage its process")
|
|
901
|
+
service.restart(args.name)
|
|
902
|
+
ui.ok(f"{args.name} restarted")
|
|
903
|
+
return 0
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def cmd_url(args: argparse.Namespace) -> int:
|
|
907
|
+
state = config.load()
|
|
908
|
+
app = config.get_app(state, args.name)
|
|
909
|
+
if app is None:
|
|
910
|
+
raise UserError(f"no hoisted app called {args.name!r}")
|
|
911
|
+
print(_app_url(app))
|
|
912
|
+
return 0
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def cmd_qr(args: argparse.Namespace) -> int:
|
|
916
|
+
state = config.load()
|
|
917
|
+
app = config.get_app(state, args.name)
|
|
918
|
+
if app is None:
|
|
919
|
+
raise UserError(f"no hoisted app called {args.name!r}")
|
|
920
|
+
url = _app_url(app)
|
|
921
|
+
ui.banner(url)
|
|
922
|
+
_show_qr(url, args.ascii)
|
|
923
|
+
return 0
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def cmd_adopt(args: argparse.Namespace) -> int:
|
|
927
|
+
"""Expose something already running on a port, without managing its process."""
|
|
928
|
+
state = config.load()
|
|
929
|
+
name = detect.slug(args.name)
|
|
930
|
+
if not detect.port_in_use(args.port):
|
|
931
|
+
ui.warn(f"nothing is listening on port {args.port} yet")
|
|
932
|
+
|
|
933
|
+
cf_path = tunnel.find_config()
|
|
934
|
+
cf_text = cf_path.read_text()
|
|
935
|
+
domain = _resolve_domain(state, args.domain, cf_text)
|
|
936
|
+
hostname = args.hostname or f"{name}.{domain}"
|
|
937
|
+
|
|
938
|
+
updated = _apply_ingress(cf_path, cf_text, name, hostname, args.port)
|
|
939
|
+
_publish(updated, hostname)
|
|
940
|
+
|
|
941
|
+
app = {
|
|
942
|
+
"name": name,
|
|
943
|
+
"port": args.port,
|
|
944
|
+
"workdir": str(Path.cwd()),
|
|
945
|
+
"cmd": "",
|
|
946
|
+
"env": {},
|
|
947
|
+
"tunnel": True,
|
|
948
|
+
"hostname": hostname,
|
|
949
|
+
"adopted": True,
|
|
950
|
+
"created": config.now(),
|
|
951
|
+
"updated": config.now(),
|
|
952
|
+
}
|
|
953
|
+
config.put_app(state, app)
|
|
954
|
+
state["domain"] = domain
|
|
955
|
+
config.save(state)
|
|
956
|
+
|
|
957
|
+
url = _app_url(app)
|
|
958
|
+
ui.banner(url, "adopted — hoist does not manage this process")
|
|
959
|
+
if not args.no_qr:
|
|
960
|
+
_show_qr(url, args.ascii)
|
|
961
|
+
return 0
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def doctor_checks() -> list[dict]:
|
|
965
|
+
"""Every setup check as data: {level: ok|warn|fail, text, fix: [commands]}.
|
|
966
|
+
|
|
967
|
+
`hoist doctor` prints these; the dashboard's System page renders them.
|
|
968
|
+
"""
|
|
969
|
+
checks: list[dict] = []
|
|
970
|
+
|
|
971
|
+
def add(level: str, text: str, fix: list[str] | None = None) -> None:
|
|
972
|
+
checks.append({"level": level, "text": text, "fix": fix or []})
|
|
973
|
+
|
|
974
|
+
if service.have_systemd():
|
|
975
|
+
add("ok", "systemd --user available")
|
|
976
|
+
else:
|
|
977
|
+
add("fail", "systemd --user unavailable — hoist cannot manage services")
|
|
978
|
+
|
|
979
|
+
if service.lingering_enabled():
|
|
980
|
+
add("ok", "lingering enabled (services survive logout)")
|
|
981
|
+
else:
|
|
982
|
+
add("warn", "lingering off — services stop when you log out",
|
|
983
|
+
[f"sudo loginctl enable-linger {os.environ.get('USER', '$USER')}"])
|
|
984
|
+
|
|
985
|
+
if tunnel.have_cloudflared():
|
|
986
|
+
add("ok", "cloudflared installed")
|
|
987
|
+
else:
|
|
988
|
+
add("warn", "cloudflared not installed — only --local URLs will work")
|
|
989
|
+
|
|
990
|
+
try:
|
|
991
|
+
cf_path = tunnel.find_config()
|
|
992
|
+
text = cf_path.read_text()
|
|
993
|
+
add("ok", f"tunnel config: {cf_path}")
|
|
994
|
+
tid = tunnel.tunnel_id(text)
|
|
995
|
+
if tid:
|
|
996
|
+
add("ok", f"tunnel id: {tid}")
|
|
997
|
+
else:
|
|
998
|
+
add("warn", "no `tunnel:` id in config")
|
|
999
|
+
hosts = tunnel.hostnames(text)
|
|
1000
|
+
add("ok", f"{len(hosts)} ingress hostname(s): {', '.join(hosts) or '—'}")
|
|
1001
|
+
domain = tunnel.default_domain(text)
|
|
1002
|
+
if domain:
|
|
1003
|
+
add("ok", f"default domain: {domain}")
|
|
1004
|
+
else:
|
|
1005
|
+
add("warn", "no domain inferred — pass --domain once")
|
|
1006
|
+
if tunnel.needs_sudo(cf_path) and not tunnel.sudo_is_passwordless():
|
|
1007
|
+
user = os.environ.get("USER") or Path.home().name
|
|
1008
|
+
add("warn", f"{cf_path} needs root, and sudo will prompt for a password — "
|
|
1009
|
+
"publishing a new URL must then run in a real terminal",
|
|
1010
|
+
tunnel.passwordless_setup(cf_path, user))
|
|
1011
|
+
elif tunnel.needs_sudo(cf_path):
|
|
1012
|
+
add("ok", "sudo available without a password prompt")
|
|
1013
|
+
else:
|
|
1014
|
+
add("ok", "tunnel config is writable without sudo")
|
|
1015
|
+
except tunnel.TunnelError as exc:
|
|
1016
|
+
add("warn", str(exc))
|
|
1017
|
+
|
|
1018
|
+
running = tunnel.daemon_unit()
|
|
1019
|
+
if running:
|
|
1020
|
+
_unit, is_system = running
|
|
1021
|
+
add("ok", f"cloudflared running as {'system' if is_system else 'user'} unit")
|
|
1022
|
+
else:
|
|
1023
|
+
add("warn", "cloudflared is not running as a service")
|
|
1024
|
+
|
|
1025
|
+
state = config.load()
|
|
1026
|
+
add("ok", f"{len(state['apps'])} app(s) tracked in {config.STATE_FILE}")
|
|
1027
|
+
return checks
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
1031
|
+
problems = 0
|
|
1032
|
+
for check in doctor_checks():
|
|
1033
|
+
{"ok": ui.ok, "warn": ui.warn, "fail": ui.fail}[check["level"]](check["text"])
|
|
1034
|
+
if check["fix"]:
|
|
1035
|
+
if len(check["fix"]) > 1:
|
|
1036
|
+
ui.step("to fix it, run these once:")
|
|
1037
|
+
for line in check["fix"]:
|
|
1038
|
+
ui.step(f" {line}" if len(check["fix"]) > 1 else f"fix: {line}")
|
|
1039
|
+
problems += check["level"] == "fail"
|
|
1040
|
+
return 1 if problems else 0
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def cmd_db(args: argparse.Namespace) -> int:
|
|
1044
|
+
"""A database in one command: Docker, a private volume, a generated password."""
|
|
1045
|
+
try:
|
|
1046
|
+
containers.docker()
|
|
1047
|
+
except containers.ContainerError as exc:
|
|
1048
|
+
raise UserError(str(exc)) from exc
|
|
1049
|
+
name = detect.slug(args.name or args.engine)
|
|
1050
|
+
state = config.load()
|
|
1051
|
+
if config.get_app(state, name):
|
|
1052
|
+
raise UserError(f"{name} already exists — pick another --name")
|
|
1053
|
+
port = args.port or detect.free_port()
|
|
1054
|
+
if not detect.port_is_free(port):
|
|
1055
|
+
raise UserError(f"port {port} is already in use")
|
|
1056
|
+
db = containers.database_spec(args.engine, name, port, args.image)
|
|
1057
|
+
meta = containers.ENGINES[args.engine]
|
|
1058
|
+
ui.info(f"{ui.bold(name)} {ui.dim('·')} {meta['label']} {ui.dim('·')} 127.0.0.1:{port}")
|
|
1059
|
+
try:
|
|
1060
|
+
containers.pull(db["image"], log=ui.step)
|
|
1061
|
+
except containers.ContainerError as exc:
|
|
1062
|
+
raise UserError(str(exc)) from exc
|
|
1063
|
+
app = {
|
|
1064
|
+
"name": name, "port": port, "workdir": str(Path.home()),
|
|
1065
|
+
"cmd": containers.database_command(name, db), "env": {}, "tunnel": False,
|
|
1066
|
+
"hostname": None, "kind": "db", "db": db, "pre": containers.pre_start(name),
|
|
1067
|
+
"created": config.now(), "updated": config.now(),
|
|
1068
|
+
}
|
|
1069
|
+
service.write_unit(app)
|
|
1070
|
+
service.start(name)
|
|
1071
|
+
with config.transaction() as state:
|
|
1072
|
+
config.put_app(state, app)
|
|
1073
|
+
health = _report_health(name, port, timeout=180, check=lambda _p: containers.ready(name, db))
|
|
1074
|
+
ui.banner(db["url"], f"hoist link {name} <app> · hoist down {name}")
|
|
1075
|
+
ui.step("reachable from this machine only; data lives in the volume " + db["volume"])
|
|
1076
|
+
return 0 if health == "up" else 1
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def cmd_exec(args: argparse.Namespace) -> int:
|
|
1080
|
+
"""Run one command in an app's own environment: its folder and env for a
|
|
1081
|
+
process, its container for a database, its first service for a stack."""
|
|
1082
|
+
app = config.get_app(config.load(), args.name)
|
|
1083
|
+
if app is None:
|
|
1084
|
+
raise UserError(f"no hoisted app called {args.name!r}")
|
|
1085
|
+
if app.get("adopted"):
|
|
1086
|
+
raise UserError(f"{args.name} was adopted; hoist does not know where it runs")
|
|
1087
|
+
command = " ".join(args.command)
|
|
1088
|
+
if not command.strip():
|
|
1089
|
+
raise UserError("nothing to run")
|
|
1090
|
+
if app.get("kind") == "db":
|
|
1091
|
+
argv = [*shlex.split(containers.docker()), "exec", containers.container_name(args.name),
|
|
1092
|
+
"sh", "-c", command]
|
|
1093
|
+
env, cwd = None, None
|
|
1094
|
+
elif app.get("kind") == "compose":
|
|
1095
|
+
base = [*shlex.split(containers.docker()), "compose", "-p", containers.compose_project(args.name)]
|
|
1096
|
+
services = subprocess.run([*base, "ps", "--services"], capture_output=True, text=True,
|
|
1097
|
+
cwd=app["workdir"]).stdout.split()
|
|
1098
|
+
if not services:
|
|
1099
|
+
raise UserError(f"{args.name} has no running containers")
|
|
1100
|
+
target = args.service or next((s for s in ("web", "app", "api", "server") if s in services), services[0])
|
|
1101
|
+
argv = [*base, "exec", "-T", target, "sh", "-c", command]
|
|
1102
|
+
env, cwd = None, app["workdir"]
|
|
1103
|
+
else:
|
|
1104
|
+
argv = ["/bin/sh", "-lc", command]
|
|
1105
|
+
env = dict(os.environ, PORT=str(app["port"]), HOIST_APP=args.name, **(app.get("env") or {}))
|
|
1106
|
+
cwd = app["workdir"]
|
|
1107
|
+
print(f"$ {command}", flush=True)
|
|
1108
|
+
try:
|
|
1109
|
+
return subprocess.run(argv, env=env, cwd=cwd, timeout=args.timeout).returncode
|
|
1110
|
+
except subprocess.TimeoutExpired:
|
|
1111
|
+
ui.fail(f"stopped after {args.timeout}s")
|
|
1112
|
+
return 124
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def cmd_template(args: argparse.Namespace) -> int:
|
|
1116
|
+
if args.id == "add":
|
|
1117
|
+
if not args.source or not args.name:
|
|
1118
|
+
raise UserError("usage: hoist template add <compose file or URL> --name <id> [--label L] [--about A]")
|
|
1119
|
+
try:
|
|
1120
|
+
info = containers.add_user_template(args.name, args.source, args.label, args.about)
|
|
1121
|
+
except containers.ContainerError as exc:
|
|
1122
|
+
raise UserError(str(exc)) from exc
|
|
1123
|
+
ui.ok(f"saved as template {info['id']}; it will run these images: {', '.join(info['images']) or 'none found'}")
|
|
1124
|
+
ui.step("a Compose file runs whatever its images do — read it before you deploy")
|
|
1125
|
+
ui.step(f"deploy it with: hoist template {info['id']}")
|
|
1126
|
+
return 0
|
|
1127
|
+
if args.id == "remove":
|
|
1128
|
+
if not args.source or not containers.remove_user_template(args.source):
|
|
1129
|
+
raise UserError("no such template of yours — see: hoist template list")
|
|
1130
|
+
ui.ok(f"template {args.source} removed (apps already deployed from it keep running)")
|
|
1131
|
+
return 0
|
|
1132
|
+
catalogue = containers.catalogue()
|
|
1133
|
+
if args.id in (None, "list"):
|
|
1134
|
+
ui.table([[k, v["label"] + (" ★" if v.get("user") else ""), v["about"]] for k, v in catalogue.items()],
|
|
1135
|
+
["ID", "APP", "WHAT IT IS"])
|
|
1136
|
+
if any(v.get("user") for v in catalogue.values()):
|
|
1137
|
+
ui.step("★ = a template you added")
|
|
1138
|
+
return 0
|
|
1139
|
+
if args.id not in catalogue:
|
|
1140
|
+
raise UserError(f"no template {args.id!r} — see: hoist template list")
|
|
1141
|
+
try:
|
|
1142
|
+
containers.docker()
|
|
1143
|
+
except containers.ContainerError as exc:
|
|
1144
|
+
raise UserError(str(exc)) from exc
|
|
1145
|
+
spec = catalogue[args.id]
|
|
1146
|
+
name = detect.slug(args.name or args.id)
|
|
1147
|
+
if config.get_app(config.load(), name):
|
|
1148
|
+
raise UserError(f"{name} already exists — pick another --name")
|
|
1149
|
+
folder = containers.template_dir(name)
|
|
1150
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
1151
|
+
values = containers.template_secrets(args.id)
|
|
1152
|
+
compose = folder / "compose.yml"
|
|
1153
|
+
fd = os.open(compose, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
1154
|
+
with os.fdopen(fd, "w") as fh:
|
|
1155
|
+
fh.write(containers.template_compose(args.id, values))
|
|
1156
|
+
needs_password = any("{password}" in v for v in (spec.get("env") or {}).values())
|
|
1157
|
+
ui.info(f"{ui.bold(name)} {ui.dim('·')} {spec['label']} {ui.dim('·')} {spec['image']}")
|
|
1158
|
+
try:
|
|
1159
|
+
containers.pull(spec["image"], log=ui.step)
|
|
1160
|
+
except containers.ContainerError as exc:
|
|
1161
|
+
raise UserError(str(exc)) from exc
|
|
1162
|
+
up_args = argparse.Namespace(
|
|
1163
|
+
target=str(folder), name=name, port=None, cmd=None, domain=args.domain, hostname=None,
|
|
1164
|
+
env=None, local=args.local, no_qr=args.no_qr, ascii=False, branch=None, build=None,
|
|
1165
|
+
no_sync=True, preview_of=None)
|
|
1166
|
+
code = cmd_up(up_args)
|
|
1167
|
+
with config.transaction() as state:
|
|
1168
|
+
if name in state["apps"]:
|
|
1169
|
+
state["apps"][name]["template"] = args.id
|
|
1170
|
+
if needs_password:
|
|
1171
|
+
ui.ok(f"password for {spec['label']}: {values['password']} (shown once; it is in {compose})")
|
|
1172
|
+
return code
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _db_app(name: str) -> dict:
|
|
1176
|
+
app = config.get_app(config.load(), name)
|
|
1177
|
+
if app is None or app.get("kind") not in ("db", "compose"):
|
|
1178
|
+
raise UserError(f"{name!r} is not a database or a Docker stack, so it has nothing hoist can back up")
|
|
1179
|
+
return app
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def cmd_backup(args: argparse.Namespace) -> int:
|
|
1183
|
+
app = _db_app(args.name)
|
|
1184
|
+
base = containers.backup_unit_base(args.name)
|
|
1185
|
+
if args.offsite:
|
|
1186
|
+
with config.transaction() as state:
|
|
1187
|
+
db = state["apps"][args.name].setdefault("db", {}) if app.get("kind") == "db" \
|
|
1188
|
+
else state["apps"][args.name].setdefault("backup", {})
|
|
1189
|
+
if args.offsite == "off":
|
|
1190
|
+
db.pop("offsite", None)
|
|
1191
|
+
else:
|
|
1192
|
+
db["offsite"] = args.offsite
|
|
1193
|
+
ui.ok("off-site copies off" if args.offsite == "off" else f"every backup is also copied to {args.offsite}")
|
|
1194
|
+
return 0
|
|
1195
|
+
if args.daily or args.no_schedule:
|
|
1196
|
+
if args.daily:
|
|
1197
|
+
svc, timer = containers.render_backup_units(
|
|
1198
|
+
args.name, sys.executable, str(Path(__file__).resolve().parent.parent),
|
|
1199
|
+
os.environ.get("HOIST_HOME"))
|
|
1200
|
+
service.UNIT_DIR.mkdir(parents=True, exist_ok=True)
|
|
1201
|
+
(service.UNIT_DIR / f"{base}.service").write_text(svc)
|
|
1202
|
+
(service.UNIT_DIR / f"{base}.timer").write_text(timer)
|
|
1203
|
+
service.systemctl("daemon-reload")
|
|
1204
|
+
service.systemctl("enable", "--now", f"{base}.timer")
|
|
1205
|
+
with config.transaction() as state:
|
|
1206
|
+
holder = "db" if app.get("kind") == "db" else "backup"
|
|
1207
|
+
state["apps"][args.name].setdefault(holder, {})["daily_backup"] = True
|
|
1208
|
+
ui.ok(f"{args.name} is backed up every day; the newest {containers.BACKUP_KEEP} are kept")
|
|
1209
|
+
else:
|
|
1210
|
+
service.systemctl("disable", "--now", f"{base}.timer")
|
|
1211
|
+
for suffix in (".timer", ".service"):
|
|
1212
|
+
(service.UNIT_DIR / f"{base}{suffix}").unlink(missing_ok=True)
|
|
1213
|
+
service.systemctl("daemon-reload")
|
|
1214
|
+
with config.transaction() as state:
|
|
1215
|
+
holder = "db" if app.get("kind") == "db" else "backup"
|
|
1216
|
+
state["apps"][args.name].setdefault(holder, {}).pop("daily_backup", None)
|
|
1217
|
+
ui.ok(f"daily backups of {args.name} turned off (existing files kept)")
|
|
1218
|
+
return 0
|
|
1219
|
+
if args.list:
|
|
1220
|
+
rows = [[b["file"], f"{b['size'] / 1024:.1f} KB", time.strftime("%Y-%m-%d %H:%M", time.localtime(b["at"]))]
|
|
1221
|
+
for b in containers.list_backups(args.name)]
|
|
1222
|
+
if rows:
|
|
1223
|
+
ui.table(rows, ["FILE", "SIZE", "TAKEN"])
|
|
1224
|
+
else:
|
|
1225
|
+
ui.info("no backups yet")
|
|
1226
|
+
return 0
|
|
1227
|
+
if args.restore:
|
|
1228
|
+
target = containers.backup_dir(args.name) / Path(args.restore).name
|
|
1229
|
+
if not target.is_file():
|
|
1230
|
+
raise UserError(f"no backup called {args.restore!r} — see hoist backup {args.name} --list")
|
|
1231
|
+
ui.step(f"restoring {target.name} into {args.name}")
|
|
1232
|
+
if app.get("kind") == "compose":
|
|
1233
|
+
containers.restore_volumes(args.name, target, service.systemctl)
|
|
1234
|
+
else:
|
|
1235
|
+
containers.restore(args.name, app["db"], target, service.systemctl)
|
|
1236
|
+
ui.ok(f"{args.name} restored from {target.name}")
|
|
1237
|
+
notify.send(f"{args.name} restored from a backup", target.name, level="warn", app=args.name)
|
|
1238
|
+
return 0
|
|
1239
|
+
try:
|
|
1240
|
+
if app.get("kind") == "compose":
|
|
1241
|
+
path = containers.backup_volumes(args.name, log=ui.step)
|
|
1242
|
+
offsite = (app.get("backup") or {}).get("offsite")
|
|
1243
|
+
if offsite:
|
|
1244
|
+
containers.copy_offsite(path, offsite, args.name)
|
|
1245
|
+
else:
|
|
1246
|
+
path = containers.backup(args.name, app["db"])
|
|
1247
|
+
except containers.ContainerError as exc:
|
|
1248
|
+
notify.send(f"Backup of {args.name} failed", str(exc), level="bad", app=args.name)
|
|
1249
|
+
raise UserError(str(exc)) from exc
|
|
1250
|
+
ui.ok(f"backed up {args.name} → {path} ({path.stat().st_size / 1024:.1f} KB)")
|
|
1251
|
+
return 0
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def cmd_link(args: argparse.Namespace) -> int:
|
|
1255
|
+
"""Put a database's URL into an app's environment and restart the app."""
|
|
1256
|
+
with config.transaction() as state:
|
|
1257
|
+
db = config.get_app(state, args.db)
|
|
1258
|
+
app = config.get_app(state, args.app)
|
|
1259
|
+
if db is None or db.get("kind") != "db":
|
|
1260
|
+
raise UserError(f"{args.db!r} is not a hoist database")
|
|
1261
|
+
if app is None:
|
|
1262
|
+
raise UserError(f"no hoisted app called {args.app!r}")
|
|
1263
|
+
if app.get("adopted"):
|
|
1264
|
+
raise UserError(f"{args.app} was adopted; hoist does not manage its environment")
|
|
1265
|
+
var = args.var or db["db"]["env_var"]
|
|
1266
|
+
app.setdefault("env", {})[var] = db["db"]["url"]
|
|
1267
|
+
app["updated"] = config.now()
|
|
1268
|
+
service.write_unit(app)
|
|
1269
|
+
containers.refresh_generated_compose(app)
|
|
1270
|
+
if not app.get("stopped"):
|
|
1271
|
+
service.restart(args.app)
|
|
1272
|
+
ui.ok(f"{args.app} now has {var} pointing at {args.db} (restarted)")
|
|
1273
|
+
return 0
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def cmd_limit(args: argparse.Namespace) -> int:
|
|
1277
|
+
import re as _re
|
|
1278
|
+
with config.transaction() as state:
|
|
1279
|
+
app = config.get_app(state, args.name)
|
|
1280
|
+
if app is None:
|
|
1281
|
+
raise UserError(f"no hoisted app called {args.name!r}")
|
|
1282
|
+
if app.get("adopted"):
|
|
1283
|
+
raise UserError(f"{args.name} was adopted; hoist does not manage its process")
|
|
1284
|
+
if app.get("kind") == "compose":
|
|
1285
|
+
raise UserError("a Compose stack sets its limits per service in the compose file "
|
|
1286
|
+
"(deploy.resources.limits) — hoist cannot cap the containers from outside")
|
|
1287
|
+
limits = dict(app.get("limits") or {})
|
|
1288
|
+
for key, value, pattern, example in (("memory", args.memory, service.LIMIT_MEMORY, "512M"),
|
|
1289
|
+
("cpu", args.cpu, service.LIMIT_CPU, "50%")):
|
|
1290
|
+
if value is None:
|
|
1291
|
+
continue
|
|
1292
|
+
value = value.strip().upper()
|
|
1293
|
+
if value in ("", "NONE", "OFF"):
|
|
1294
|
+
limits.pop(key, None)
|
|
1295
|
+
elif not _re.match(pattern, value):
|
|
1296
|
+
raise UserError(f"--{key} expects something like {example}, got {value!r}")
|
|
1297
|
+
else:
|
|
1298
|
+
limits[key] = value
|
|
1299
|
+
if limits:
|
|
1300
|
+
app["limits"] = limits
|
|
1301
|
+
else:
|
|
1302
|
+
app.pop("limits", None)
|
|
1303
|
+
if app.get("kind") == "db":
|
|
1304
|
+
app["cmd"] = containers.database_command(args.name, app["db"], limits)
|
|
1305
|
+
service.write_unit(app)
|
|
1306
|
+
if not app.get("stopped"):
|
|
1307
|
+
service.restart(args.name)
|
|
1308
|
+
shown = ", ".join(f"{k} {v}" for k, v in limits.items()) or "no limits"
|
|
1309
|
+
ui.ok(f"{args.name}: {shown} (restarted)")
|
|
1310
|
+
return 0
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def cmd_notify(args: argparse.Namespace) -> int:
|
|
1314
|
+
state = config.load()
|
|
1315
|
+
if args.off:
|
|
1316
|
+
state.pop("notify", None)
|
|
1317
|
+
config.save(state)
|
|
1318
|
+
ui.ok("notifications off")
|
|
1319
|
+
return 0
|
|
1320
|
+
if args.url:
|
|
1321
|
+
if not args.url.startswith(("https://", "http://", "smtp://", "smtps://")):
|
|
1322
|
+
raise UserError("expected a webhook URL (https://…) or an email URL (smtps://user:pass@host:465/?to=…)")
|
|
1323
|
+
state["notify"] = {"url": args.url}
|
|
1324
|
+
config.save(state)
|
|
1325
|
+
ui.ok(f"notifications go to a {notify.kind(args.url)} webhook")
|
|
1326
|
+
current = (state.get("notify") or {}).get("url")
|
|
1327
|
+
if not current:
|
|
1328
|
+
ui.info("no webhook set — hoist notify <discord|slack|ntfy|telegram|any URL>")
|
|
1329
|
+
return 0
|
|
1330
|
+
if args.test or args.url:
|
|
1331
|
+
good, detail = notify.send("hoist is connected",
|
|
1332
|
+
"You will hear about deploys, failed builds and apps that stop answering.",
|
|
1333
|
+
level="ok")
|
|
1334
|
+
(ui.ok if good else ui.fail)(f"test message: {detail}")
|
|
1335
|
+
return 0 if good else 1
|
|
1336
|
+
ui.info(f"notifications go to a {notify.kind(current)} webhook")
|
|
1337
|
+
return 0
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
DASH_PORT = 7457
|
|
1341
|
+
DASH_TUNNEL_NAME = "_dashboard" # slug() can never produce "_", so no app collides
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _dash_unit_text(port: int, host: str) -> str:
|
|
1345
|
+
pkg_root = Path(__file__).resolve().parent.parent
|
|
1346
|
+
env = f"Environment=PYTHONPATH={pkg_root}\n"
|
|
1347
|
+
if os.environ.get("HOIST_HOME"):
|
|
1348
|
+
env += f"Environment=HOIST_HOME={os.environ['HOIST_HOME']}\n"
|
|
1349
|
+
return f"""[Unit]
|
|
1350
|
+
Description=hoist dashboard
|
|
1351
|
+
After=network-online.target
|
|
1352
|
+
|
|
1353
|
+
[Service]
|
|
1354
|
+
Type=simple
|
|
1355
|
+
{env}ExecStart={sys.executable} -m hoist dash --port {port} --host {host} --foreground
|
|
1356
|
+
Restart=on-failure
|
|
1357
|
+
RestartSec=2
|
|
1358
|
+
|
|
1359
|
+
[Install]
|
|
1360
|
+
WantedBy=default.target
|
|
1361
|
+
"""
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def cmd_dash(args: argparse.Namespace) -> int:
|
|
1365
|
+
from .dash import server
|
|
1366
|
+
|
|
1367
|
+
from .dash import team
|
|
1368
|
+
|
|
1369
|
+
token = server.load_token(reset=args.reset_token)
|
|
1370
|
+
port = args.port
|
|
1371
|
+
base = (config.load().get("dash_url") or f"http://127.0.0.1:{port}").rstrip("/")
|
|
1372
|
+
if args.add_user:
|
|
1373
|
+
try:
|
|
1374
|
+
member = team.add(args.add_user, args.role,
|
|
1375
|
+
[c for c in (args.can or "").split(",") if c],
|
|
1376
|
+
[a for a in (args.apps or "").split(",") if a],
|
|
1377
|
+
[s for s in (args.stages or "").split(",") if s])
|
|
1378
|
+
except team.TeamError as exc:
|
|
1379
|
+
raise UserError(str(exc)) from exc
|
|
1380
|
+
team.audit("owner", "add teammate", args.add_user.lower(), "from the CLI")
|
|
1381
|
+
ui.ok(f"{args.add_user} can sign in with this link (shown once):")
|
|
1382
|
+
ui.banner(f"{base}/?token={member}", f"hoist dash --remove-user {args.add_user} revokes it")
|
|
1383
|
+
return 0
|
|
1384
|
+
if args.remove_user:
|
|
1385
|
+
if not team.remove(args.remove_user):
|
|
1386
|
+
raise UserError(f"no teammate called {args.remove_user!r}")
|
|
1387
|
+
team.audit("owner", "remove teammate", args.remove_user, "from the CLI")
|
|
1388
|
+
ui.ok(f"{args.remove_user} is signed out and their link no longer works")
|
|
1389
|
+
return 0
|
|
1390
|
+
if args.users:
|
|
1391
|
+
people = team.users()
|
|
1392
|
+
def summary(i: dict) -> str:
|
|
1393
|
+
if i.get("role") != "custom":
|
|
1394
|
+
return i.get("role", "member")
|
|
1395
|
+
where = ','.join(i.get('apps', [])) or 'all apps'
|
|
1396
|
+
if i.get('stages'):
|
|
1397
|
+
where += f" in {','.join(i['stages'])}"
|
|
1398
|
+
return f"custom: {','.join(i.get('can', [])) or 'view'} on {where}"
|
|
1399
|
+
ui.table([["owner", "owner", "—"]] + [[n, summary(i), i.get("added", "")] for n, i in sorted(people.items())],
|
|
1400
|
+
["NAME", "ACCESS", "ADDED"])
|
|
1401
|
+
return 0
|
|
1402
|
+
|
|
1403
|
+
if args.service or args.public:
|
|
1404
|
+
unit = service.UNIT_DIR / "hoist--dash.service"
|
|
1405
|
+
service.UNIT_DIR.mkdir(parents=True, exist_ok=True)
|
|
1406
|
+
unit.write_text(_dash_unit_text(port, args.host))
|
|
1407
|
+
service.systemctl("daemon-reload")
|
|
1408
|
+
result = service.systemctl("enable", "--now", "hoist--dash.service")
|
|
1409
|
+
if result.returncode != 0:
|
|
1410
|
+
raise UserError(result.stderr.strip() or "could not start the dashboard service")
|
|
1411
|
+
service.systemctl("restart", "hoist--dash.service")
|
|
1412
|
+
ui.ok(f"dashboard running as a service on 127.0.0.1:{port} (survives reboots)")
|
|
1413
|
+
url = f"http://127.0.0.1:{port}"
|
|
1414
|
+
if args.public:
|
|
1415
|
+
state = config.load()
|
|
1416
|
+
cf_path = tunnel.find_config()
|
|
1417
|
+
cf_text = cf_path.read_text()
|
|
1418
|
+
domain = _resolve_domain(state, None, cf_text)
|
|
1419
|
+
hostname = args.public if "." in args.public else f"{args.public}.{domain}"
|
|
1420
|
+
updated = _apply_ingress(cf_path, cf_text, DASH_TUNNEL_NAME, hostname, port)
|
|
1421
|
+
_publish(updated, hostname)
|
|
1422
|
+
url = f"https://{hostname}"
|
|
1423
|
+
with config.transaction() as st:
|
|
1424
|
+
st["dash_url"] = url
|
|
1425
|
+
ui.banner(f"{url}/?token={token}", "open this once; the browser remembers you")
|
|
1426
|
+
return 0
|
|
1427
|
+
|
|
1428
|
+
if args.stop:
|
|
1429
|
+
service.systemctl("disable", "--now", "hoist--dash.service")
|
|
1430
|
+
(service.UNIT_DIR / "hoist--dash.service").unlink(missing_ok=True)
|
|
1431
|
+
service.systemctl("daemon-reload")
|
|
1432
|
+
ui.ok("dashboard service stopped and removed")
|
|
1433
|
+
return 0
|
|
1434
|
+
|
|
1435
|
+
if not detect.port_is_free(port):
|
|
1436
|
+
if args.foreground:
|
|
1437
|
+
raise UserError(f"port {port} is already in use")
|
|
1438
|
+
ui.info(f"the dashboard is already running on port {port}")
|
|
1439
|
+
ui.banner(f"http://127.0.0.1:{port}/?token={token}")
|
|
1440
|
+
return 0
|
|
1441
|
+
httpd = server.serve(args.host, port, token)
|
|
1442
|
+
login = f"http://127.0.0.1:{port}/?token={token}"
|
|
1443
|
+
if not args.foreground:
|
|
1444
|
+
ui.banner(login, "Ctrl+C to stop · hoist dash --service keeps it running")
|
|
1445
|
+
if args.open:
|
|
1446
|
+
subprocess.Popen(["xdg-open", login], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
1447
|
+
try:
|
|
1448
|
+
httpd.serve_forever()
|
|
1449
|
+
except KeyboardInterrupt:
|
|
1450
|
+
pass
|
|
1451
|
+
return 0
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
# --- argument parsing ---------------------------------------------------------
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1458
|
+
parser = argparse.ArgumentParser(
|
|
1459
|
+
prog="hoist",
|
|
1460
|
+
description="Put a local app on a public HTTPS URL, with one command.",
|
|
1461
|
+
)
|
|
1462
|
+
parser.add_argument("--version", action="version", version=f"hoist {__version__}")
|
|
1463
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1464
|
+
|
|
1465
|
+
def add_qr_flags(p: argparse.ArgumentParser) -> None:
|
|
1466
|
+
p.add_argument("--no-qr", action="store_true", help="do not print a QR code")
|
|
1467
|
+
p.add_argument("--ascii", action="store_true", help="QR without ANSI colour")
|
|
1468
|
+
|
|
1469
|
+
up = sub.add_parser("up", help="hoist a directory or git repo onto a public URL")
|
|
1470
|
+
up.add_argument("target", nargs="?",
|
|
1471
|
+
help="project directory (default: cwd) or git URL, e.g. github.com/team/app")
|
|
1472
|
+
up.add_argument("--name", help="app name and subdomain label")
|
|
1473
|
+
up.add_argument("--port", type=int, help="port to run on (default: auto)")
|
|
1474
|
+
up.add_argument("--cmd", help="start command; $PORT is substituted")
|
|
1475
|
+
up.add_argument("--domain", help="zone to put the subdomain under")
|
|
1476
|
+
up.add_argument("--hostname", help="full hostname, overriding <name>.<domain>")
|
|
1477
|
+
up.add_argument("--env", action="append", metavar="K=V", help="extra env var")
|
|
1478
|
+
up.add_argument("--local", action="store_true", help="LAN URL only, no tunnel")
|
|
1479
|
+
up.add_argument("--branch", help="git branch to deploy (repo URLs only)")
|
|
1480
|
+
up.add_argument("--build", help="build command for a repo, overriding detection")
|
|
1481
|
+
up.add_argument("--buildpack", nargs="?", const="auto", choices=["auto", "nixpacks", "pack"],
|
|
1482
|
+
help="build with a buildpack you already have installed (any language)")
|
|
1483
|
+
up.add_argument("--stage", help="production (default), staging, development or preview")
|
|
1484
|
+
up.add_argument("--preview-of", help=argparse.SUPPRESS)
|
|
1485
|
+
up.add_argument("--no-sync", action="store_true",
|
|
1486
|
+
help="deploy a repo once, without redeploying on push")
|
|
1487
|
+
add_qr_flags(up)
|
|
1488
|
+
up.set_defaults(func=cmd_up)
|
|
1489
|
+
|
|
1490
|
+
share = sub.add_parser("share", help="serve a folder of files publicly")
|
|
1491
|
+
share.add_argument("path", nargs="?", default=".", help="directory to serve")
|
|
1492
|
+
share.add_argument("--name", help="app name and subdomain label")
|
|
1493
|
+
share.add_argument("--port", type=int)
|
|
1494
|
+
share.add_argument("--domain")
|
|
1495
|
+
share.add_argument("--hostname")
|
|
1496
|
+
share.add_argument("--env", action="append", metavar="K=V")
|
|
1497
|
+
share.add_argument("--local", action="store_true")
|
|
1498
|
+
share.add_argument("--stage", help="production (default), staging, development or preview")
|
|
1499
|
+
add_qr_flags(share)
|
|
1500
|
+
share.set_defaults(func=cmd_share)
|
|
1501
|
+
|
|
1502
|
+
ls = sub.add_parser("ls", help="list hoisted apps")
|
|
1503
|
+
ls.set_defaults(func=cmd_ls)
|
|
1504
|
+
|
|
1505
|
+
down = sub.add_parser("down", help="stop an app and remove its ingress rule")
|
|
1506
|
+
down.add_argument("name")
|
|
1507
|
+
down.add_argument("--purge", action="store_true", help="also delete a database's or stack's data volumes")
|
|
1508
|
+
down.set_defaults(func=cmd_down)
|
|
1509
|
+
|
|
1510
|
+
sync = sub.add_parser("sync", help="redeploy a git-backed app if its branch moved")
|
|
1511
|
+
sync.add_argument("name")
|
|
1512
|
+
sync.add_argument("--force", action="store_true", help="rebuild even if up to date")
|
|
1513
|
+
sync.add_argument("--to", metavar="SHA", help="roll back to this commit (pauses auto-deploy)")
|
|
1514
|
+
sync.add_argument("--wait", action="store_true",
|
|
1515
|
+
help="if a deploy is running, wait for it instead of skipping (used by webhooks)")
|
|
1516
|
+
sync.set_defaults(func=cmd_sync)
|
|
1517
|
+
|
|
1518
|
+
for verb, func, text in (
|
|
1519
|
+
("pause", cmd_pause, "stop redeploying a repo app on push"),
|
|
1520
|
+
("resume", cmd_resume, "redeploy a repo app on push again"),
|
|
1521
|
+
("stop", cmd_stop, "stop an app's process, keeping its config and URL"),
|
|
1522
|
+
("start", cmd_start, "start a stopped app again"),
|
|
1523
|
+
):
|
|
1524
|
+
p = sub.add_parser(verb, help=text)
|
|
1525
|
+
p.add_argument("name")
|
|
1526
|
+
p.set_defaults(func=func)
|
|
1527
|
+
|
|
1528
|
+
logs = sub.add_parser("logs", help="show an app's logs")
|
|
1529
|
+
logs.add_argument("name")
|
|
1530
|
+
logs.add_argument("-n", "--lines", type=int, default=50)
|
|
1531
|
+
logs.add_argument("-f", "--follow", action="store_true")
|
|
1532
|
+
logs.add_argument("--deploys", action="store_true",
|
|
1533
|
+
help="show the push-to-deploy log instead of the app's")
|
|
1534
|
+
logs.set_defaults(func=cmd_logs)
|
|
1535
|
+
|
|
1536
|
+
restart = sub.add_parser("restart", help="restart an app")
|
|
1537
|
+
restart.add_argument("name")
|
|
1538
|
+
restart.set_defaults(func=cmd_restart)
|
|
1539
|
+
|
|
1540
|
+
url = sub.add_parser("url", help="print an app's URL")
|
|
1541
|
+
url.add_argument("name")
|
|
1542
|
+
url.set_defaults(func=cmd_url)
|
|
1543
|
+
|
|
1544
|
+
qr_cmd = sub.add_parser("qr", help="print an app's QR code")
|
|
1545
|
+
qr_cmd.add_argument("name")
|
|
1546
|
+
qr_cmd.add_argument("--ascii", action="store_true")
|
|
1547
|
+
qr_cmd.set_defaults(func=cmd_qr)
|
|
1548
|
+
|
|
1549
|
+
adopt = sub.add_parser("adopt", help="expose an already-running port")
|
|
1550
|
+
adopt.add_argument("name")
|
|
1551
|
+
adopt.add_argument("--port", type=int, required=True)
|
|
1552
|
+
adopt.add_argument("--domain")
|
|
1553
|
+
adopt.add_argument("--hostname")
|
|
1554
|
+
add_qr_flags(adopt)
|
|
1555
|
+
adopt.set_defaults(func=cmd_adopt)
|
|
1556
|
+
|
|
1557
|
+
db = sub.add_parser("db", help="start a database: postgres, mysql, redis or mongo")
|
|
1558
|
+
db.add_argument("engine", choices=sorted(containers.ENGINES))
|
|
1559
|
+
db.add_argument("--name", help="app name (default: the engine)")
|
|
1560
|
+
db.add_argument("--port", type=int, help="host port on 127.0.0.1 (default: free)")
|
|
1561
|
+
db.add_argument("--image", help="override the image, e.g. postgres:17")
|
|
1562
|
+
db.set_defaults(func=cmd_db)
|
|
1563
|
+
|
|
1564
|
+
ex = sub.add_parser("exec", help="run one command in an app's environment")
|
|
1565
|
+
ex.add_argument("name")
|
|
1566
|
+
ex.add_argument("command", nargs=argparse.REMAINDER, help="the command (quote it, or put it after --)")
|
|
1567
|
+
ex.add_argument("--service", help="Compose service to run in (default: the first)")
|
|
1568
|
+
ex.add_argument("--timeout", type=int, default=600)
|
|
1569
|
+
ex.set_defaults(func=cmd_exec)
|
|
1570
|
+
|
|
1571
|
+
tpl = sub.add_parser("template", help="one-click apps: uptime-kuma, n8n, ntfy, excalidraw...")
|
|
1572
|
+
tpl.add_argument("id", nargs="?", help="template id, `list`, `add` or `remove`")
|
|
1573
|
+
tpl.add_argument("source", nargs="?", help="with add: a Compose file or URL; with remove: the template id")
|
|
1574
|
+
tpl.add_argument("--name", help="app name (default: the template id); with add: the new template's id")
|
|
1575
|
+
tpl.add_argument("--label", help="with add: a display name")
|
|
1576
|
+
tpl.add_argument("--about", help="with add: a one-line description")
|
|
1577
|
+
tpl.add_argument("--local", action="store_true", help="LAN URL only, no tunnel")
|
|
1578
|
+
tpl.add_argument("--domain")
|
|
1579
|
+
add_qr_flags(tpl)
|
|
1580
|
+
tpl.set_defaults(func=cmd_template)
|
|
1581
|
+
|
|
1582
|
+
bk = sub.add_parser("backup", help="back up, list or restore a database")
|
|
1583
|
+
bk.add_argument("name")
|
|
1584
|
+
bk.add_argument("--list", action="store_true", help="list backups")
|
|
1585
|
+
bk.add_argument("--restore", metavar="FILE", help="load this backup into the database")
|
|
1586
|
+
bk.add_argument("--daily", action="store_true", help="back up every day")
|
|
1587
|
+
bk.add_argument("--no-schedule", action="store_true", help="stop daily backups")
|
|
1588
|
+
bk.add_argument("--offsite", metavar="TARGET",
|
|
1589
|
+
help="also copy every backup to an rclone remote (gdrive:hoist) or a folder; 'off' stops it")
|
|
1590
|
+
bk.set_defaults(func=cmd_backup)
|
|
1591
|
+
|
|
1592
|
+
link = sub.add_parser("link", help="give an app a database's URL")
|
|
1593
|
+
link.add_argument("db")
|
|
1594
|
+
link.add_argument("app")
|
|
1595
|
+
link.add_argument("--var", help="variable name (default: DATABASE_URL, REDIS_URL...)")
|
|
1596
|
+
link.set_defaults(func=cmd_link)
|
|
1597
|
+
|
|
1598
|
+
hook = sub.add_parser("webhook", help="deploy within seconds of a push (GitHub/GitLab webhook)")
|
|
1599
|
+
hook.add_argument("name")
|
|
1600
|
+
hook.add_argument("--off", action="store_true")
|
|
1601
|
+
hook.set_defaults(func=cmd_webhook)
|
|
1602
|
+
|
|
1603
|
+
fr = sub.add_parser("front", help=argparse.SUPPRESS)
|
|
1604
|
+
fr.add_argument("name")
|
|
1605
|
+
fr.set_defaults(func=cmd_front)
|
|
1606
|
+
|
|
1607
|
+
stg = sub.add_parser("stage", help="label an app production, staging, development or preview")
|
|
1608
|
+
stg.add_argument("name")
|
|
1609
|
+
stg.add_argument("stage", metavar="STAGE", help="production, staging, development or preview")
|
|
1610
|
+
stg.set_defaults(func=cmd_stage)
|
|
1611
|
+
|
|
1612
|
+
prev = sub.add_parser("previews", help="give every branch its own preview app")
|
|
1613
|
+
prev.add_argument("name")
|
|
1614
|
+
prev.add_argument("state", choices=["on", "off"])
|
|
1615
|
+
prev.set_defaults(func=cmd_previews)
|
|
1616
|
+
|
|
1617
|
+
lim = sub.add_parser("limit", help="cap an app's memory or CPU")
|
|
1618
|
+
lim.add_argument("name")
|
|
1619
|
+
lim.add_argument("--memory", help="e.g. 512M or 2G; 'off' removes it")
|
|
1620
|
+
lim.add_argument("--cpu", help="e.g. 50%% of one core, 200%% for two; 'off' removes it")
|
|
1621
|
+
lim.set_defaults(func=cmd_limit)
|
|
1622
|
+
|
|
1623
|
+
note = sub.add_parser("notify", help="send deploy and downtime alerts to a webhook")
|
|
1624
|
+
note.add_argument("url", nargs="?", help="Discord, Slack, ntfy, Telegram or any webhook URL")
|
|
1625
|
+
note.add_argument("--test", action="store_true", help="send a test message")
|
|
1626
|
+
note.add_argument("--off", action="store_true", help="stop sending notifications")
|
|
1627
|
+
note.set_defaults(func=cmd_notify)
|
|
1628
|
+
|
|
1629
|
+
dash = sub.add_parser("dash", help="open the web dashboard")
|
|
1630
|
+
dash.add_argument("--port", type=int, default=DASH_PORT)
|
|
1631
|
+
dash.add_argument("--host", default="127.0.0.1", help="interface to bind (default 127.0.0.1)")
|
|
1632
|
+
dash.add_argument("--open", action="store_true", help="open it in the browser")
|
|
1633
|
+
dash.add_argument("--service", action="store_true", help="keep it running as a user service")
|
|
1634
|
+
dash.add_argument("--public", metavar="NAME", help="also publish it, e.g. --public dash")
|
|
1635
|
+
dash.add_argument("--stop", action="store_true", help="stop and remove the dashboard service")
|
|
1636
|
+
dash.add_argument("--reset-token", action="store_true", help="sign every browser out")
|
|
1637
|
+
dash.add_argument("--add-user", metavar="NAME", help="give a teammate their own sign-in link")
|
|
1638
|
+
dash.add_argument("--role", default="member", choices=["member", "viewer", "custom"],
|
|
1639
|
+
help="with --add-user: member (everything but people), viewer (read only) or custom")
|
|
1640
|
+
dash.add_argument("--can", metavar="LIST",
|
|
1641
|
+
help="with --role custom: comma list of create,deploy,operate,config,backups,delete,terminal,secrets")
|
|
1642
|
+
dash.add_argument("--apps", metavar="LIST", help="with --role custom: only these apps (default: all)")
|
|
1643
|
+
dash.add_argument("--stages", metavar="LIST",
|
|
1644
|
+
help="with --role custom: only apps in these environments (production,staging,development,preview)")
|
|
1645
|
+
dash.add_argument("--remove-user", metavar="NAME", help="revoke a teammate's link")
|
|
1646
|
+
dash.add_argument("--users", action="store_true", help="list who has access")
|
|
1647
|
+
dash.add_argument("--foreground", action="store_true", help=argparse.SUPPRESS)
|
|
1648
|
+
dash.set_defaults(func=cmd_dash)
|
|
1649
|
+
|
|
1650
|
+
doctor = sub.add_parser("doctor", help="check the local setup")
|
|
1651
|
+
doctor.set_defaults(func=cmd_doctor)
|
|
1652
|
+
|
|
1653
|
+
return parser
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1657
|
+
args = build_parser().parse_args(argv)
|
|
1658
|
+
try:
|
|
1659
|
+
return int(args.func(args))
|
|
1660
|
+
except UserError as exc:
|
|
1661
|
+
ui.fail(str(exc))
|
|
1662
|
+
return 1
|
|
1663
|
+
except (tunnel.TunnelError, service.ServiceError, detect.DetectionError,
|
|
1664
|
+
source.SourceError, config.StateError, containers.ContainerError) as exc:
|
|
1665
|
+
ui.fail(str(exc))
|
|
1666
|
+
return 1
|
|
1667
|
+
except KeyboardInterrupt:
|
|
1668
|
+
return 130
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
if __name__ == "__main__":
|
|
1672
|
+
raise SystemExit(main())
|