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/containers.py
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
"""Docker-backed apps: one-click databases and Compose stacks.
|
|
2
|
+
|
|
3
|
+
hoist still runs everything as a systemd user unit; for these the unit's
|
|
4
|
+
command is `docker run` or `docker compose up` in the foreground, so stop,
|
|
5
|
+
restart, logs, limits and the dashboard work exactly as for any other app.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import shlex
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ContainerError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@functools.lru_cache(maxsize=1)
|
|
24
|
+
def docker() -> str:
|
|
25
|
+
"""The docker invocation that actually reaches a daemon.
|
|
26
|
+
|
|
27
|
+
A machine with Docker Desktop installed but not running has its context
|
|
28
|
+
pointing at a dead socket while the system daemon is fine; fall back to
|
|
29
|
+
the `default` context instead of failing.
|
|
30
|
+
"""
|
|
31
|
+
if not shutil.which("docker"):
|
|
32
|
+
raise ContainerError("docker is not installed")
|
|
33
|
+
for candidate in (["docker"], ["docker", "--context", "default"]):
|
|
34
|
+
result = subprocess.run([*candidate, "info", "--format", "{{.ServerVersion}}"],
|
|
35
|
+
capture_output=True, text=True)
|
|
36
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
37
|
+
return " ".join(candidate)
|
|
38
|
+
raise ContainerError("docker is installed but no daemon answers — start Docker first")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def available() -> bool:
|
|
42
|
+
try:
|
|
43
|
+
docker()
|
|
44
|
+
return True
|
|
45
|
+
except ContainerError:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run(*args: str, timeout: int = 900) -> subprocess.CompletedProcess:
|
|
50
|
+
return subprocess.run([*shlex.split(docker()), *args], capture_output=True, text=True,
|
|
51
|
+
timeout=timeout)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- databases ------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
ENGINES: dict[str, dict] = {
|
|
57
|
+
"postgres": {
|
|
58
|
+
"label": "PostgreSQL", "image": "postgres:16-alpine", "port": 5432,
|
|
59
|
+
"data": "/var/lib/postgresql/data", "env_var": "DATABASE_URL",
|
|
60
|
+
"env": lambda db, pw: {"POSTGRES_USER": "hoist", "POSTGRES_PASSWORD": pw, "POSTGRES_DB": db},
|
|
61
|
+
"args": [],
|
|
62
|
+
"url": "postgres://hoist:{pw}@127.0.0.1:{port}/{db}",
|
|
63
|
+
"shell": "psql -U hoist {db}",
|
|
64
|
+
},
|
|
65
|
+
"mysql": {
|
|
66
|
+
"label": "MySQL", "image": "mysql:8.4", "port": 3306, "data": "/var/lib/mysql",
|
|
67
|
+
"env_var": "DATABASE_URL",
|
|
68
|
+
"env": lambda db, pw: {"MYSQL_ROOT_PASSWORD": pw, "MYSQL_DATABASE": db,
|
|
69
|
+
"MYSQL_USER": "hoist", "MYSQL_PASSWORD": pw},
|
|
70
|
+
"args": [],
|
|
71
|
+
"url": "mysql://hoist:{pw}@127.0.0.1:{port}/{db}",
|
|
72
|
+
"shell": "mysql -u hoist -p {db}",
|
|
73
|
+
},
|
|
74
|
+
"mariadb": {
|
|
75
|
+
"label": "MariaDB", "image": "mariadb:11", "port": 3306, "data": "/var/lib/mysql",
|
|
76
|
+
"env_var": "DATABASE_URL",
|
|
77
|
+
"env": lambda db, pw: {"MARIADB_ROOT_PASSWORD": pw, "MARIADB_DATABASE": db,
|
|
78
|
+
"MARIADB_USER": "hoist", "MARIADB_PASSWORD": pw},
|
|
79
|
+
"args": [],
|
|
80
|
+
"url": "mysql://hoist:{pw}@127.0.0.1:{port}/{db}",
|
|
81
|
+
"shell": "mariadb -u hoist -p {db}",
|
|
82
|
+
},
|
|
83
|
+
"redis": {
|
|
84
|
+
"label": "Redis", "image": "redis:7-alpine", "port": 6379, "data": "/data",
|
|
85
|
+
"env_var": "REDIS_URL",
|
|
86
|
+
"env": lambda db, pw: {},
|
|
87
|
+
"args": ["redis-server", "--appendonly", "yes", "--requirepass", "{pw}"],
|
|
88
|
+
"url": "redis://:{pw}@127.0.0.1:{port}/0",
|
|
89
|
+
"shell": "redis-cli -a <password>",
|
|
90
|
+
},
|
|
91
|
+
"mongo": {
|
|
92
|
+
"label": "MongoDB", "image": "mongo:7", "port": 27017, "data": "/data/db",
|
|
93
|
+
"env_var": "MONGODB_URL",
|
|
94
|
+
"env": lambda db, pw: {"MONGO_INITDB_ROOT_USERNAME": "hoist", "MONGO_INITDB_ROOT_PASSWORD": pw},
|
|
95
|
+
"args": [],
|
|
96
|
+
"url": "mongodb://hoist:{pw}@127.0.0.1:{port}/{db}?authSource=admin",
|
|
97
|
+
"shell": "mongosh -u hoist -p",
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def container_name(app: str) -> str:
|
|
103
|
+
return f"hoist-{app}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def volume_name(app: str) -> str:
|
|
107
|
+
return f"hoist-{app}-data"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def database_spec(engine: str, name: str, port: int, image: str | None = None) -> dict:
|
|
111
|
+
"""Everything hoist stores about a database app, password included."""
|
|
112
|
+
if engine not in ENGINES:
|
|
113
|
+
raise ContainerError(f"unknown engine {engine!r} — pick one of {', '.join(ENGINES)}")
|
|
114
|
+
meta = ENGINES[engine]
|
|
115
|
+
password = secrets.token_urlsafe(18).replace("-", "x").replace("_", "y")
|
|
116
|
+
dbname = re.sub(r"[^a-z0-9_]", "_", name)
|
|
117
|
+
return {
|
|
118
|
+
"engine": engine,
|
|
119
|
+
"image": image or meta["image"],
|
|
120
|
+
"inner_port": meta["port"],
|
|
121
|
+
"password": password,
|
|
122
|
+
"database": dbname,
|
|
123
|
+
"volume": volume_name(name),
|
|
124
|
+
"url": meta["url"].format(pw=password, port=port, db=dbname),
|
|
125
|
+
"env_var": meta["env_var"],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def database_command(name: str, db: dict, limits: dict | None = None) -> str:
|
|
130
|
+
meta = ENGINES[db["engine"]]
|
|
131
|
+
parts = [*shlex.split(docker()), "run", "--rm", "--name", container_name(name),
|
|
132
|
+
"-p", f"127.0.0.1:$PORT:{db['inner_port']}",
|
|
133
|
+
"-v", f"{db['volume']}:{meta['data']}"]
|
|
134
|
+
# A systemd limit would only cap the docker client; the container needs its own.
|
|
135
|
+
if limits and limits.get("memory"):
|
|
136
|
+
parts += ["--memory", limits["memory"].lower()]
|
|
137
|
+
if limits and limits.get("cpu"):
|
|
138
|
+
parts += ["--cpus", f"{int(limits['cpu'].rstrip('%')) / 100:g}"]
|
|
139
|
+
for key, value in meta["env"](db["database"], db["password"]).items():
|
|
140
|
+
parts += ["-e", f"{key}={value}"]
|
|
141
|
+
parts.append(db["image"])
|
|
142
|
+
parts += [a.format(pw=db["password"]) for a in meta["args"]]
|
|
143
|
+
# `$PORT` is left for the unit's shell to expand.
|
|
144
|
+
return " ".join(p if p.startswith("127.0.0.1:$PORT") else shlex.quote(p) for p in parts)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def ready(name: str, db: dict) -> bool:
|
|
148
|
+
"""Ask the engine itself whether it accepts connections."""
|
|
149
|
+
check = {
|
|
150
|
+
"postgres": ["pg_isready", "-U", "hoist", "-d", db["database"]],
|
|
151
|
+
"mysql": ["mysqladmin", "ping", "-u", "hoist", f"-p{db['password']}", "--silent"],
|
|
152
|
+
"mariadb": ["healthcheck.sh", "--connect"],
|
|
153
|
+
"redis": ["redis-cli", "--no-auth-warning", "-a", db["password"], "ping"],
|
|
154
|
+
"mongo": ["mongosh", "--quiet", "-u", "hoist", "-p", db["password"], "--authenticationDatabase",
|
|
155
|
+
"admin", "--eval", "db.adminCommand('ping').ok"],
|
|
156
|
+
}[db["engine"]]
|
|
157
|
+
try:
|
|
158
|
+
out = run("exec", container_name(name), *check, timeout=15)
|
|
159
|
+
except subprocess.TimeoutExpired:
|
|
160
|
+
return False
|
|
161
|
+
return out.returncode == 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def pull(image: str, log=print) -> None:
|
|
165
|
+
present = run("image", "inspect", image, timeout=30)
|
|
166
|
+
if present.returncode == 0:
|
|
167
|
+
log(f"image {image} is already here")
|
|
168
|
+
return
|
|
169
|
+
log(f"pulling {image} (first time only)")
|
|
170
|
+
proc = subprocess.Popen([*shlex.split(docker()), "pull", image], stdout=subprocess.PIPE,
|
|
171
|
+
stderr=subprocess.STDOUT, text=True)
|
|
172
|
+
assert proc.stdout is not None
|
|
173
|
+
last = ""
|
|
174
|
+
for line in proc.stdout:
|
|
175
|
+
line = line.strip()
|
|
176
|
+
if line and ("Pull complete" in line or "Digest" in line or "Status" in line) and line != last:
|
|
177
|
+
log(line)
|
|
178
|
+
last = line
|
|
179
|
+
if proc.wait() != 0:
|
|
180
|
+
raise ContainerError(f"docker pull {image} failed")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def remove_volume(name: str) -> bool:
|
|
184
|
+
return run("volume", "rm", "-f", volume_name(name), timeout=60).returncode == 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def pre_start(name: str) -> list[str]:
|
|
188
|
+
"""Commands systemd runs before starting: clear a container left behind."""
|
|
189
|
+
return [f"-{docker()} rm -f {container_name(name)}"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --- compose --------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
COMPOSE_FILES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def compose_file(directory: Path) -> Path | None:
|
|
198
|
+
for name in COMPOSE_FILES:
|
|
199
|
+
if (directory / name).is_file():
|
|
200
|
+
return directory / name
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def compose_project(app: str) -> str:
|
|
205
|
+
return f"hoist-{app}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def compose_uses_port_var(path: Path) -> bool:
|
|
209
|
+
return bool(re.search(r"\$\{?PORT\b", path.read_text()))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def compose_published_port(path: Path) -> int | None:
|
|
213
|
+
"""The first host port the stack publishes, from `ports:` entries."""
|
|
214
|
+
text = path.read_text()
|
|
215
|
+
for match in re.finditer(r"""^\s*-\s*["']?(?:[\d.]+:)?(\d{2,5}):\d{2,5}(?:/tcp)?["']?\s*$""",
|
|
216
|
+
text, re.MULTILINE):
|
|
217
|
+
return int(match.group(1))
|
|
218
|
+
for match in re.finditer(r"^\s*published:\s*[\"']?(\d{2,5})", text, re.MULTILINE):
|
|
219
|
+
return int(match.group(1))
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _file_flag(generated: bool) -> str:
|
|
224
|
+
return f" -f {GENERATED_COMPOSE}" if generated else ""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def compose_up_command(app: str, generated: bool = False) -> str:
|
|
228
|
+
return f"{docker()} compose -p {compose_project(app)}{_file_flag(generated)} up --remove-orphans"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def compose_build_step(app: str, generated: bool = False) -> str:
|
|
232
|
+
return f"{docker()} compose -p {compose_project(app)}{_file_flag(generated)} build"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def dockerfile_port(directory: Path) -> int:
|
|
236
|
+
"""The port a Dockerfile says it listens on (EXPOSE), else the common 8080."""
|
|
237
|
+
text = (directory / "Dockerfile").read_text()
|
|
238
|
+
match = re.search(r"^\s*EXPOSE\s+(\d+)", text, re.MULTILINE | re.IGNORECASE)
|
|
239
|
+
return int(match.group(1)) if match else 8080
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
GENERATED_COMPOSE = "compose.hoist.yml"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _env_lines(inner: int, env_keys) -> str:
|
|
246
|
+
"""The service's environment: PORT, plus each of the app's own variables.
|
|
247
|
+
|
|
248
|
+
The values are not written into the file; each is `${KEY:-}`, filled in from
|
|
249
|
+
the environment of whatever runs compose (the app's systemd unit), so a
|
|
250
|
+
secret never lands in a file on disk and editing it in the dashboard works.
|
|
251
|
+
"""
|
|
252
|
+
lines = f' environment:\n PORT: "{inner}"\n'
|
|
253
|
+
for key in sorted(set(env_keys or ())):
|
|
254
|
+
if key != "PORT":
|
|
255
|
+
lines += f" {key}: ${{{key}:-}}\n"
|
|
256
|
+
return lines
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def ensure_dockerfile_compose(directory: Path, env_keys=()) -> Path:
|
|
260
|
+
"""Wrap a lone Dockerfile in a one-service compose file hoist can run.
|
|
261
|
+
|
|
262
|
+
No `ports:` on purpose: `hoist front` owns the public port and forwards to
|
|
263
|
+
the container, which is what lets a redeploy start the new container beside
|
|
264
|
+
the old one with no host port to fight over.
|
|
265
|
+
"""
|
|
266
|
+
inner = dockerfile_port(directory)
|
|
267
|
+
path = directory / GENERATED_COMPOSE
|
|
268
|
+
path.write_text("# written by hoist for a repo with only a Dockerfile\n"
|
|
269
|
+
"services:\n app:\n build: .\n"
|
|
270
|
+
# An init process as PID 1 passes the stop signal on. Without it a
|
|
271
|
+
# plain `python`/`node` server ignores SIGTERM and every stop (so
|
|
272
|
+
# every redeploy) waits out Docker's 10 second grace period.
|
|
273
|
+
" init: true\n" + _env_lines(inner, env_keys))
|
|
274
|
+
return path
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# --- buildpacks (only if you already have the tool) ------------------------------
|
|
278
|
+
#
|
|
279
|
+
# hoist never installs build tooling for you. If `nixpacks` or `pack` (Cloud
|
|
280
|
+
# Native Buildpacks) is already on PATH, a project in any language can be built
|
|
281
|
+
# into an image and run like any other Docker app.
|
|
282
|
+
|
|
283
|
+
BUILDPACKS = ("nixpacks", "pack")
|
|
284
|
+
PACK_BUILDER = "paketobuildpacks/builder-jammy-base"
|
|
285
|
+
BUILDPACK_INNER_PORT = 8080
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def buildpack_tool(prefer: str | None = None) -> str | None:
|
|
289
|
+
"""The buildpack tool to use, or None when none is installed."""
|
|
290
|
+
order = [prefer] if prefer in BUILDPACKS else list(BUILDPACKS)
|
|
291
|
+
return next((t for t in order if shutil.which(t)), None)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def buildpack_image(app: str) -> str:
|
|
295
|
+
return f"hoist-{app}:latest"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def buildpack_build_step(tool: str, app: str) -> str:
|
|
299
|
+
if tool == "nixpacks":
|
|
300
|
+
return f"nixpacks build . --name {shlex.quote(buildpack_image(app))}"
|
|
301
|
+
return f"pack build {shlex.quote(buildpack_image(app))} --builder {PACK_BUILDER}"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def ensure_buildpack_compose(directory: Path, app: str, env_keys=()) -> Path:
|
|
305
|
+
"""A one-service compose file around the image the buildpack produces."""
|
|
306
|
+
path = directory / GENERATED_COMPOSE
|
|
307
|
+
path.write_text("# written by hoist for a project built with a buildpack\n"
|
|
308
|
+
f"services:\n app:\n image: {buildpack_image(app)}\n"
|
|
309
|
+
" init: true\n" + _env_lines(BUILDPACK_INNER_PORT, env_keys))
|
|
310
|
+
return path
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def refresh_generated_compose(app: dict) -> bool:
|
|
314
|
+
"""Rewrite a Dockerfile or buildpack app's generated compose file so its environment
|
|
315
|
+
lists the app's current variables. Call after the app's env changes, before the restart
|
|
316
|
+
that recreates the container. Returns False for apps that have no generated file."""
|
|
317
|
+
if not (app.get("dockerfile") or app.get("buildpack")):
|
|
318
|
+
return False
|
|
319
|
+
directory = Path(app["workdir"]).resolve()
|
|
320
|
+
keys = list((app.get("env") or {}).keys())
|
|
321
|
+
if app.get("buildpack"):
|
|
322
|
+
ensure_buildpack_compose(directory, app["name"], keys)
|
|
323
|
+
else:
|
|
324
|
+
ensure_dockerfile_compose(directory, keys)
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def compose_down(app: str, purge: bool = False) -> list[str]:
|
|
329
|
+
"""Stop and remove a stack. Its volumes are kept unless `purge`; returns the kept ones."""
|
|
330
|
+
subprocess.run([*shlex.split(docker()), "compose", "-p", compose_project(app), "down",
|
|
331
|
+
"--remove-orphans", *(["--volumes"] if purge else [])],
|
|
332
|
+
capture_output=True, text=True, timeout=180)
|
|
333
|
+
if purge:
|
|
334
|
+
return []
|
|
335
|
+
listing = run("volume", "ls", "-q", "--filter", f"label=com.docker.compose.project={compose_project(app)}",
|
|
336
|
+
timeout=30)
|
|
337
|
+
return listing.stdout.split()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# --- backups --------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
BACKUP_KEEP = 10
|
|
343
|
+
BACKUP_EXT = {"postgres": "dump", "mysql": "sql", "mariadb": "sql", "redis": "rdb", "mongo": "archive.gz"}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def backup_dir(app: str) -> Path:
|
|
347
|
+
from . import config
|
|
348
|
+
return config.STATE_DIR / "backups" / app
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def list_backups(app: str) -> list[dict]:
|
|
352
|
+
root = backup_dir(app)
|
|
353
|
+
if not root.is_dir():
|
|
354
|
+
return []
|
|
355
|
+
files = sorted((p for p in root.iterdir() if p.is_file() and not p.name.startswith(".")),
|
|
356
|
+
key=lambda p: p.name, reverse=True)
|
|
357
|
+
return [{"file": p.name, "size": p.stat().st_size, "at": p.stat().st_mtime} for p in files]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _exec(name: str, *cmd: str, env: dict | None = None) -> list[str]:
|
|
361
|
+
args = [*shlex.split(docker()), "exec", "-i"]
|
|
362
|
+
for key, value in (env or {}).items():
|
|
363
|
+
args += ["-e", f"{key}={value}"]
|
|
364
|
+
return [*args, container_name(name), *cmd]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def backup(name: str, db: dict) -> Path:
|
|
368
|
+
"""Dump a running database to a private file. Returns its path."""
|
|
369
|
+
import os
|
|
370
|
+
import time as _time
|
|
371
|
+
engine = db["engine"]
|
|
372
|
+
root = backup_dir(name)
|
|
373
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
374
|
+
os.chmod(root, 0o700)
|
|
375
|
+
dest = root / f"{_time.strftime('%Y%m%d-%H%M%S')}.{BACKUP_EXT[engine]}"
|
|
376
|
+
tmp = dest.with_name("." + dest.name + ".partial")
|
|
377
|
+
if engine == "redis":
|
|
378
|
+
# SAVE writes a consistent snapshot inside the container; copy it out.
|
|
379
|
+
out = subprocess.run(_exec(name, "redis-cli", "--no-auth-warning", "-a", db["password"], "SAVE"),
|
|
380
|
+
capture_output=True, text=True, timeout=600)
|
|
381
|
+
if out.returncode != 0 or "OK" not in out.stdout:
|
|
382
|
+
raise ContainerError(f"redis SAVE failed: {(out.stderr or out.stdout).strip()[:200]}")
|
|
383
|
+
copied = run("cp", f"{container_name(name)}:/data/dump.rdb", str(tmp), timeout=600)
|
|
384
|
+
if copied.returncode != 0:
|
|
385
|
+
raise ContainerError(f"could not copy the snapshot: {copied.stderr.strip()[:200]}")
|
|
386
|
+
else:
|
|
387
|
+
cmd = {
|
|
388
|
+
"postgres": _exec(name, "pg_dump", "-U", "hoist", "-Fc", db["database"]),
|
|
389
|
+
"mysql": _exec(name, "mysqldump", "-u", "hoist", "--single-transaction", "--routines",
|
|
390
|
+
db["database"], env={"MYSQL_PWD": db["password"]}),
|
|
391
|
+
"mariadb": _exec(name, "mariadb-dump", "-u", "hoist", "--single-transaction", "--routines",
|
|
392
|
+
db["database"], env={"MYSQL_PWD": db["password"]}),
|
|
393
|
+
"mongo": _exec(name, "mongodump", "--archive", "--gzip", "-u", "hoist", "-p", db["password"],
|
|
394
|
+
"--authenticationDatabase", "admin", "--quiet"),
|
|
395
|
+
}[engine]
|
|
396
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
397
|
+
with os.fdopen(fd, "wb") as fh:
|
|
398
|
+
proc = subprocess.run(cmd, stdout=fh, stderr=subprocess.PIPE, timeout=3600)
|
|
399
|
+
if proc.returncode != 0:
|
|
400
|
+
tmp.unlink(missing_ok=True)
|
|
401
|
+
raise ContainerError(f"{engine} dump failed: {proc.stderr.decode(errors='replace').strip()[:300]}")
|
|
402
|
+
os.chmod(tmp, 0o600)
|
|
403
|
+
if tmp.stat().st_size == 0:
|
|
404
|
+
tmp.unlink()
|
|
405
|
+
raise ContainerError("the dump came out empty")
|
|
406
|
+
os.replace(tmp, dest)
|
|
407
|
+
if db.get("offsite"):
|
|
408
|
+
copy_offsite(dest, db["offsite"], name)
|
|
409
|
+
for old in list_backups(name)[BACKUP_KEEP:]:
|
|
410
|
+
(root / old["file"]).unlink(missing_ok=True)
|
|
411
|
+
return dest
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def copy_offsite(file: Path, target: str, name: str) -> None:
|
|
415
|
+
"""Copy a backup off this machine: an rclone remote (`gdrive:hoist`) or a directory."""
|
|
416
|
+
if ":" in target and not target.startswith("/"):
|
|
417
|
+
if not shutil.which("rclone"):
|
|
418
|
+
raise ContainerError("rclone is not installed, so the off-site copy was skipped")
|
|
419
|
+
out = subprocess.run(["rclone", "copy", str(file), f"{target.rstrip('/')}/{name}"],
|
|
420
|
+
capture_output=True, text=True, timeout=1800)
|
|
421
|
+
if out.returncode != 0:
|
|
422
|
+
raise ContainerError(f"off-site copy failed: {out.stderr.strip()[-200:]}")
|
|
423
|
+
return
|
|
424
|
+
dest = Path(target).expanduser() / name
|
|
425
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
426
|
+
shutil.copy2(file, dest / file.name)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def restore(name: str, db: dict, file: Path, systemctl=None) -> None:
|
|
430
|
+
"""Load a backup into the running database (redis: replaces the data, restarts)."""
|
|
431
|
+
engine = db["engine"]
|
|
432
|
+
if engine == "redis":
|
|
433
|
+
if systemctl is None:
|
|
434
|
+
raise ContainerError("restoring redis needs to restart it")
|
|
435
|
+
systemctl("stop", f"hoist-{name}.service")
|
|
436
|
+
meta = ENGINES["redis"]
|
|
437
|
+
out = subprocess.run([*shlex.split(docker()), "run", "--rm", "-v", f"{db['volume']}:{meta['data']}",
|
|
438
|
+
"-v", f"{file.parent}:/backup:ro", db["image"], "sh", "-c",
|
|
439
|
+
# With appendonly on, Redis starts from the AOF and ignores
|
|
440
|
+
# dump.rdb. So load the snapshot with AOF off, have Redis
|
|
441
|
+
# rewrite the AOF from it, and shut down cleanly.
|
|
442
|
+
f"rm -rf /data/appendonlydir && cp /backup/{shlex.quote(file.name)} /data/dump.rdb"
|
|
443
|
+
" && redis-server --appendonly no --dir /data --daemonize yes"
|
|
444
|
+
" && until redis-cli ping >/dev/null 2>&1; do sleep 0.2; done"
|
|
445
|
+
" && redis-cli config set appendonly yes >/dev/null"
|
|
446
|
+
" && sleep 0.5"
|
|
447
|
+
" && while redis-cli info persistence | grep -Eq"
|
|
448
|
+
" 'aof_rewrite_in_progress:1|aof_rewrite_scheduled:1|loading:1'; do sleep 0.2; done"
|
|
449
|
+
" && redis-cli shutdown"],
|
|
450
|
+
capture_output=True, text=True, timeout=600)
|
|
451
|
+
systemctl("start", f"hoist-{name}.service")
|
|
452
|
+
if out.returncode != 0:
|
|
453
|
+
raise ContainerError(f"redis restore failed: {out.stderr.strip()[:200]}")
|
|
454
|
+
return
|
|
455
|
+
cmd = {
|
|
456
|
+
"postgres": _exec(name, "pg_restore", "-U", "hoist", "-d", db["database"], "--clean", "--if-exists",
|
|
457
|
+
"--no-owner"),
|
|
458
|
+
"mysql": _exec(name, "mysql", "-u", "hoist", db["database"], env={"MYSQL_PWD": db["password"]}),
|
|
459
|
+
"mariadb": _exec(name, "mariadb", "-u", "hoist", db["database"], env={"MYSQL_PWD": db["password"]}),
|
|
460
|
+
"mongo": _exec(name, "mongorestore", "--archive", "--gzip", "--drop", "-u", "hoist", "-p",
|
|
461
|
+
db["password"], "--authenticationDatabase", "admin", "--quiet"),
|
|
462
|
+
}[engine]
|
|
463
|
+
with open(file, "rb") as fh:
|
|
464
|
+
proc = subprocess.run(cmd, stdin=fh, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=3600)
|
|
465
|
+
if proc.returncode != 0:
|
|
466
|
+
raise ContainerError(f"{engine} restore failed: {proc.stderr.decode(errors='replace').strip()[:300]}")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def backup_unit_base(name: str) -> str:
|
|
470
|
+
return f"hoist-{name}--backup"
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def render_backup_units(name: str, python: str, pkg_root: str, hoist_home: str | None) -> tuple[str, str]:
|
|
474
|
+
env = f"Environment=PYTHONPATH={pkg_root}\n" + (f"Environment=HOIST_HOME={hoist_home}\n" if hoist_home else "")
|
|
475
|
+
service = (f"[Unit]\nDescription=hoist: back up {name}\n\n[Service]\nType=oneshot\n{env}"
|
|
476
|
+
f"ExecStart={python} -m hoist backup {name}\n")
|
|
477
|
+
timer = (f"[Unit]\nDescription=hoist: back up {name} every day\n\n[Timer]\nOnCalendar=daily\n"
|
|
478
|
+
f"RandomizedDelaySec=900\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n")
|
|
479
|
+
return service, timer
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# --- one-click templates ----------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
TEMPLATES: dict[str, dict] = {
|
|
485
|
+
"uptime-kuma": {"label": "Uptime Kuma", "about": "Status page and uptime monitors for your demos",
|
|
486
|
+
"image": "louislam/uptime-kuma:1", "port": 3001, "data": "/app/data"},
|
|
487
|
+
"n8n": {"label": "n8n", "about": "Workflow automation: webhooks, APIs, AI agents",
|
|
488
|
+
"image": "n8nio/n8n:latest", "port": 5678, "data": "/home/node/.n8n",
|
|
489
|
+
"env": {"N8N_SECURE_COOKIE": "false", "N8N_DIAGNOSTICS_ENABLED": "false"}},
|
|
490
|
+
"ntfy": {"label": "ntfy", "about": "Push notifications to any phone with one HTTP call",
|
|
491
|
+
"image": "binwiederhier/ntfy:latest", "port": 80, "data": "/var/cache/ntfy",
|
|
492
|
+
"command": ["serve", "--cache-file", "/var/cache/ntfy/cache.db"]},
|
|
493
|
+
"excalidraw": {"label": "Excalidraw", "about": "Whiteboard for architecture sketches and pitch diagrams",
|
|
494
|
+
"image": "excalidraw/excalidraw:latest", "port": 80},
|
|
495
|
+
"pocketbase": {"label": "PocketBase", "about": "A backend in one file: auth, database, realtime, admin UI",
|
|
496
|
+
"image": "ghcr.io/muchobien/pocketbase:latest", "port": 8090, "data": "/pb_data"},
|
|
497
|
+
"metabase": {"label": "Metabase", "about": "Dashboards and charts from your databases",
|
|
498
|
+
"image": "metabase/metabase:latest", "port": 3000, "data": "/metabase-data",
|
|
499
|
+
"env": {"MB_DB_FILE": "/metabase-data/metabase.db"}},
|
|
500
|
+
"gitea": {"label": "Gitea", "about": "Your own GitHub: repos, issues, pull requests",
|
|
501
|
+
"image": "gitea/gitea:1.22", "port": 3000, "data": "/data",
|
|
502
|
+
"env": {"USER_UID": "1000", "USER_GID": "1000"}},
|
|
503
|
+
"code-server": {"label": "code-server", "about": "VS Code in the browser for pair programming",
|
|
504
|
+
"image": "codercom/code-server:latest", "port": 8080, "data": "/home/coder",
|
|
505
|
+
"env": {"PASSWORD": "{password}"}},
|
|
506
|
+
"mailpit": {"label": "Mailpit", "about": "Catch and read the emails your app sends while you build it",
|
|
507
|
+
"image": "axllent/mailpit:latest", "port": 8025},
|
|
508
|
+
"it-tools": {"label": "IT Tools", "about": "Converters, encoders and generators developers keep googling",
|
|
509
|
+
"image": "corentinth/it-tools:latest", "port": 80},
|
|
510
|
+
"stirling-pdf": {"label": "Stirling PDF", "about": "Merge, split, OCR and convert PDFs",
|
|
511
|
+
"image": "stirlingtools/stirling-pdf:latest", "port": 8080},
|
|
512
|
+
"node-red": {"label": "Node-RED", "about": "Wire IoT devices and APIs together visually",
|
|
513
|
+
"image": "nodered/node-red:latest", "port": 1880, "data": "/data"},
|
|
514
|
+
"grafana": {"label": "Grafana", "about": "Dashboards for metrics and sensor data",
|
|
515
|
+
"image": "grafana/grafana:latest", "port": 3000, "data": "/var/lib/grafana",
|
|
516
|
+
"env": {"GF_SECURITY_ADMIN_PASSWORD": "{password}"}},
|
|
517
|
+
"filebrowser": {"label": "File Browser", "about": "Share and manage files from a web page",
|
|
518
|
+
"image": "filebrowser/filebrowser:latest", "port": 80, "data": "/srv"},
|
|
519
|
+
"changedetection": {"label": "changedetection.io", "about": "Get told when a web page changes",
|
|
520
|
+
"image": "ghcr.io/dgtlmoon/changedetection.io:latest", "port": 5000, "data": "/datastore"},
|
|
521
|
+
"jupyter": {"label": "Jupyter", "about": "Notebooks for the data and ML part of the project",
|
|
522
|
+
"image": "quay.io/jupyter/minimal-notebook:latest", "port": 8888, "data": "/home/jovyan/work",
|
|
523
|
+
"env": {"JUPYTER_TOKEN": "{password}"}},
|
|
524
|
+
"memos": {"label": "Memos", "about": "Team notes and a shared scratchpad",
|
|
525
|
+
"image": "neosmemo/memos:stable", "port": 5230, "data": "/var/opt/memos"},
|
|
526
|
+
"nocodb": {"label": "NocoDB", "about": "A spreadsheet-style database with a REST API",
|
|
527
|
+
"image": "nocodb/nocodb:latest", "port": 8080, "data": "/usr/app/data"},
|
|
528
|
+
"directus": {"label": "Directus", "about": "Headless CMS and admin panel with an API",
|
|
529
|
+
"image": "directus/directus:11", "port": 8055, "data": "/directus/database",
|
|
530
|
+
"env": {"KEY": "{random}", "SECRET": "{random}", "ADMIN_EMAIL": "admin@example.com",
|
|
531
|
+
"ADMIN_PASSWORD": "{password}", "DB_CLIENT": "sqlite3",
|
|
532
|
+
"DB_FILENAME": "/directus/database/data.db"}},
|
|
533
|
+
"linkding": {"label": "linkding", "about": "Shared bookmarks for the team's research",
|
|
534
|
+
"image": "sissbruecker/linkding:latest", "port": 9090, "data": "/etc/linkding/data"},
|
|
535
|
+
"searxng": {"label": "SearXNG", "about": "Private metasearch your app can call",
|
|
536
|
+
"image": "searxng/searxng:latest", "port": 8080, "data": "/etc/searxng"},
|
|
537
|
+
"ghost": {"label": "Ghost", "about": "A blog or landing page with an editor",
|
|
538
|
+
"image": "ghost:5-alpine", "port": 2368, "data": "/var/lib/ghost/content",
|
|
539
|
+
"env": {"NODE_ENV": "development", "database__client": "sqlite3",
|
|
540
|
+
"database__connection__filename": "/var/lib/ghost/content/data/ghost.db"}},
|
|
541
|
+
"vaultwarden": {"label": "Vaultwarden", "about": "Self-hosted password manager, Bitwarden compatible",
|
|
542
|
+
"image": "vaultwarden/server:latest", "port": 80, "data": "/data"},
|
|
543
|
+
"adminer": {"label": "Adminer", "about": "Browse and edit any SQL database from a web page",
|
|
544
|
+
"image": "adminer:latest", "port": 8080},
|
|
545
|
+
"pgadmin": {"label": "pgAdmin", "about": "The full PostgreSQL admin console",
|
|
546
|
+
"image": "dpage/pgadmin4:latest", "port": 80, "data": "/var/lib/pgadmin",
|
|
547
|
+
"env": {"PGADMIN_DEFAULT_EMAIL": "admin@example.com", "PGADMIN_DEFAULT_PASSWORD": "{password}"}},
|
|
548
|
+
"redis-commander": {"label": "Redis Commander", "about": "Browse the keys of a Redis you started with hoist",
|
|
549
|
+
"image": "rediscommander/redis-commander:latest", "port": 8081},
|
|
550
|
+
"drawio": {"label": "draw.io", "about": "Diagrams, flowcharts and architecture drawings",
|
|
551
|
+
"image": "jgraph/drawio:latest", "port": 8080},
|
|
552
|
+
"flowise": {"label": "Flowise", "about": "Build LLM chains and agents by dragging blocks",
|
|
553
|
+
"image": "flowiseai/flowise:latest", "port": 3000, "data": "/root/.flowise"},
|
|
554
|
+
"langflow": {"label": "Langflow", "about": "Visual builder for RAG pipelines and AI agents",
|
|
555
|
+
"image": "langflowai/langflow:latest", "port": 7860, "data": "/app/langflow"},
|
|
556
|
+
"ollama": {"label": "Ollama (container)", "about": "Run open LLMs in Docker, with its own model store",
|
|
557
|
+
"image": "ollama/ollama:latest", "port": 11434, "data": "/root/.ollama"},
|
|
558
|
+
"chromadb": {"label": "Chroma", "about": "Vector database for embeddings and RAG",
|
|
559
|
+
"image": "chromadb/chroma:latest", "port": 8000, "data": "/chroma/chroma"},
|
|
560
|
+
"qdrant": {"label": "Qdrant", "about": "Vector search engine with a REST and gRPC API",
|
|
561
|
+
"image": "qdrant/qdrant:latest", "port": 6333, "data": "/qdrant/storage"},
|
|
562
|
+
"whoami": {"label": "whoami", "about": "A tiny web server that prints its request: test routing and tunnels",
|
|
563
|
+
"image": "traefik/whoami:latest", "port": 80},
|
|
564
|
+
"gotify": {"label": "Gotify", "about": "Self-hosted push notification server with an app",
|
|
565
|
+
"image": "gotify/server:latest", "port": 80, "data": "/app/data"},
|
|
566
|
+
"webhook-tester": {"label": "Webhook Tester", "about": "A URL that shows every request sent to it",
|
|
567
|
+
"image": "tarampampam/webhook-tester:2", "port": 8080, "data": "/data"},
|
|
568
|
+
"json-server": {"label": "JSON Server", "about": "A fake REST API from a db.json file in minutes",
|
|
569
|
+
"image": "clue/json-server:latest", "port": 80, "data": "/data"},
|
|
570
|
+
"pairdrop": {"label": "PairDrop", "about": "Send files between phones and laptops on one network",
|
|
571
|
+
"image": "lscr.io/linuxserver/pairdrop:latest", "port": 3000},
|
|
572
|
+
"syncthing": {"label": "Syncthing", "about": "Keep folders in sync across the team's machines",
|
|
573
|
+
"image": "syncthing/syncthing:latest", "port": 8384, "data": "/var/syncthing"},
|
|
574
|
+
"nginx": {"label": "nginx", "about": "A static web server for a folder of HTML",
|
|
575
|
+
"image": "nginx:alpine", "port": 80, "data": "/usr/share/nginx/html"},
|
|
576
|
+
"caddy": {"label": "Caddy", "about": "A web server with a friendly config file",
|
|
577
|
+
"image": "caddy:2", "port": 80, "data": "/data"},
|
|
578
|
+
"cyberchef": {"label": "CyberChef", "about": "The cyber Swiss Army knife: encode, decode, hash, analyse",
|
|
579
|
+
"image": "mpepping/cyberchef:latest", "port": 8000},
|
|
580
|
+
"trilium": {"label": "Trilium Notes", "about": "A hierarchical note-taking app for research",
|
|
581
|
+
"image": "triliumnext/trilium:latest", "port": 8080, "data": "/home/node/trilium-data"},
|
|
582
|
+
"vikunja": {"label": "Vikunja", "about": "Tasks, lists and kanban for the team",
|
|
583
|
+
"image": "vikunja/vikunja:latest", "port": 3456, "data": "/app/vikunja/files"},
|
|
584
|
+
"kanboard": {"label": "Kanboard", "about": "A simple kanban board",
|
|
585
|
+
"image": "kanboard/kanboard:latest", "port": 80, "data": "/var/www/app/data"},
|
|
586
|
+
"openspeedtest": {"label": "OpenSpeedTest", "about": "Measure the venue Wi-Fi speed from a phone",
|
|
587
|
+
"image": "openspeedtest/latest:latest", "port": 3000},
|
|
588
|
+
"open-webui": {"label": "Open WebUI", "about": "ChatGPT-style chat for the Ollama models on this machine",
|
|
589
|
+
"image": "ghcr.io/open-webui/open-webui:main", "port": 8080, "data": "/app/backend/data",
|
|
590
|
+
"env": {"OLLAMA_BASE_URL": "http://host.docker.internal:11434"}, "host_gateway": True},
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# --- templates you add yourself -----------------------------------------------------
|
|
595
|
+
#
|
|
596
|
+
# `hoist template add <compose file or URL>` stores any Compose file as a template.
|
|
597
|
+
# Each lives in ~/.config/hoist/templates.d/<id>.json (the label and description)
|
|
598
|
+
# next to <id>.yml (the file itself), so the catalogue is open-ended.
|
|
599
|
+
|
|
600
|
+
USER_TEMPLATE_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
|
|
601
|
+
MAX_COMPOSE_BYTES = 256 * 1024
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def user_template_dir() -> Path:
|
|
605
|
+
from . import config
|
|
606
|
+
return config.STATE_DIR / "templates.d"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def catalogue() -> dict[str, dict]:
|
|
610
|
+
"""Built-in templates plus the ones added with `hoist template add`."""
|
|
611
|
+
import json
|
|
612
|
+
merged = dict(TEMPLATES)
|
|
613
|
+
folder = user_template_dir()
|
|
614
|
+
if folder.is_dir():
|
|
615
|
+
for meta in sorted(folder.glob("*.json")):
|
|
616
|
+
try:
|
|
617
|
+
data = json.loads(meta.read_text())
|
|
618
|
+
tid = meta.stem
|
|
619
|
+
if USER_TEMPLATE_ID.match(tid) and (folder / f"{tid}.yml").is_file():
|
|
620
|
+
merged[tid] = {"label": str(data.get("label") or tid)[:60],
|
|
621
|
+
"about": str(data.get("about") or "Added by you")[:160],
|
|
622
|
+
"image": ", ".join(data.get("images") or ["your compose file"])[:120],
|
|
623
|
+
"user": True}
|
|
624
|
+
except (OSError, ValueError):
|
|
625
|
+
continue
|
|
626
|
+
return merged
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def compose_images(text: str) -> list[str]:
|
|
630
|
+
return sorted({m.group(1) for m in re.finditer(r"^\s*image:\s*[\"']?([^\s\"'#]+)", text, re.MULTILINE)})
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def add_user_template(tid: str, source: str, label: str | None, about: str | None) -> dict:
|
|
634
|
+
"""Fetch (URL) or read (path) a Compose file and keep it as a template."""
|
|
635
|
+
import json
|
|
636
|
+
import os
|
|
637
|
+
import urllib.request
|
|
638
|
+
if not USER_TEMPLATE_ID.match(tid) or tid in TEMPLATES:
|
|
639
|
+
raise ContainerError("pick a new id: lowercase letters, digits and dashes (built-in ids are taken)")
|
|
640
|
+
if source.startswith(("http://", "https://")):
|
|
641
|
+
req = urllib.request.Request(source, headers={"User-Agent": "hoist"})
|
|
642
|
+
try:
|
|
643
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
644
|
+
raw = resp.read(MAX_COMPOSE_BYTES + 1)
|
|
645
|
+
except Exception as exc: # noqa: BLE001
|
|
646
|
+
raise ContainerError(f"could not download it: {getattr(exc, 'reason', exc)}") from exc
|
|
647
|
+
else:
|
|
648
|
+
path = Path(source).expanduser()
|
|
649
|
+
if not path.is_file():
|
|
650
|
+
raise ContainerError(f"{source} is not a file or a URL")
|
|
651
|
+
raw = path.read_bytes()[:MAX_COMPOSE_BYTES + 1]
|
|
652
|
+
if len(raw) > MAX_COMPOSE_BYTES:
|
|
653
|
+
raise ContainerError("that file is over 256 KB; a Compose file should be much smaller")
|
|
654
|
+
text = raw.decode("utf-8", "replace")
|
|
655
|
+
if not re.search(r"^services:\s*$", text, re.MULTILINE):
|
|
656
|
+
raise ContainerError("that does not look like a Compose file (no `services:` section)")
|
|
657
|
+
images = compose_images(text)
|
|
658
|
+
folder = user_template_dir()
|
|
659
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
660
|
+
fd = os.open(folder / f"{tid}.yml", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
661
|
+
with os.fdopen(fd, "w") as fh:
|
|
662
|
+
fh.write(text)
|
|
663
|
+
(folder / f"{tid}.json").write_text(json.dumps(
|
|
664
|
+
{"label": label or tid, "about": about or "Added by you", "images": images}, indent=2) + "\n")
|
|
665
|
+
return {"id": tid, "images": images}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def remove_user_template(tid: str) -> bool:
|
|
669
|
+
folder = user_template_dir()
|
|
670
|
+
found = False
|
|
671
|
+
for suffix in (".json", ".yml"):
|
|
672
|
+
path = folder / f"{tid}{suffix}"
|
|
673
|
+
if path.is_file():
|
|
674
|
+
path.unlink()
|
|
675
|
+
found = True
|
|
676
|
+
return found
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def template_secrets(template_id: str) -> dict[str, str]:
|
|
680
|
+
"""Fresh values for a template's placeholders: {password} is shown to the
|
|
681
|
+
user once; each {random} is its own secret nobody needs to know."""
|
|
682
|
+
return {"password": secrets.token_urlsafe(12).replace("-", "x").replace("_", "y")}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def template_compose(template_id: str, values: dict[str, str] | None = None) -> str:
|
|
686
|
+
"""A compose file for a template: one service, its port on ${PORT}, a named volume."""
|
|
687
|
+
user_file = user_template_dir() / f"{template_id}.yml"
|
|
688
|
+
if template_id not in TEMPLATES and user_file.is_file():
|
|
689
|
+
return user_file.read_text() # your own compose file runs exactly as written
|
|
690
|
+
spec = TEMPLATES[template_id]
|
|
691
|
+
values = values or template_secrets(template_id)
|
|
692
|
+
|
|
693
|
+
def fill(value: str) -> str:
|
|
694
|
+
value = value.replace("{password}", values["password"])
|
|
695
|
+
while "{random}" in value:
|
|
696
|
+
value = value.replace("{random}", secrets.token_hex(24), 1)
|
|
697
|
+
return value
|
|
698
|
+
lines = ["services:", " app:", f" image: {spec['image']}",
|
|
699
|
+
f' ports: ["127.0.0.1:${{PORT}}:{spec["port"]}"]']
|
|
700
|
+
if spec.get("data"):
|
|
701
|
+
lines.append(f' volumes: ["data:{spec["data"]}"]')
|
|
702
|
+
if spec.get("env"):
|
|
703
|
+
lines.append(" environment:")
|
|
704
|
+
lines += [f' {k}: "{fill(v)}"' for k, v in spec["env"].items()]
|
|
705
|
+
if spec.get("command"):
|
|
706
|
+
lines.append(" command: [" + ", ".join(f'"{c}"' for c in spec["command"]) + "]")
|
|
707
|
+
if spec.get("host_gateway"):
|
|
708
|
+
lines.append(' extra_hosts: ["host.docker.internal:host-gateway"]')
|
|
709
|
+
if spec.get("data"):
|
|
710
|
+
lines += ["volumes:", " data: {}"]
|
|
711
|
+
return "\n".join(lines) + "\n"
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def template_dir(app: str) -> Path:
|
|
715
|
+
from . import config
|
|
716
|
+
return config.STATE_DIR / "templates" / app
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
# --- volume backups for stacks --------------------------------------------------
|
|
720
|
+
|
|
721
|
+
TAR_IMAGE = "busybox:1.36"
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def stack_volumes(app: str) -> list[str]:
|
|
725
|
+
out = run("volume", "ls", "-q", "--filter", f"label=com.docker.compose.project={compose_project(app)}",
|
|
726
|
+
timeout=30)
|
|
727
|
+
return sorted(out.stdout.split())
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def backup_volumes(app: str, log=print) -> Path:
|
|
731
|
+
"""Pause the stack, tar every volume it owns into one archive, unpause."""
|
|
732
|
+
import os
|
|
733
|
+
import time as _time
|
|
734
|
+
volumes = stack_volumes(app)
|
|
735
|
+
if not volumes:
|
|
736
|
+
raise ContainerError(f"{app} has no Docker volumes, so there is nothing to back up")
|
|
737
|
+
pull(TAR_IMAGE, log=lambda _m: None)
|
|
738
|
+
root = backup_dir(app)
|
|
739
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
740
|
+
os.chmod(root, 0o700)
|
|
741
|
+
dest = root / f"{_time.strftime('%Y%m%d-%H%M%S')}.tar.gz"
|
|
742
|
+
partial = root / f".{dest.name}.partial"
|
|
743
|
+
mounts = [arg for v in volumes for arg in ("-v", f"{v}:/v/{v}:ro")]
|
|
744
|
+
base = [*shlex.split(docker()), "compose", "-p", compose_project(app)]
|
|
745
|
+
subprocess.run([*base, "pause"], capture_output=True, text=True, timeout=60)
|
|
746
|
+
try:
|
|
747
|
+
log(f"archiving {len(volumes)} volume(s): {', '.join(volumes)}")
|
|
748
|
+
# The archive streams out on stdout into a file this user creates, so it
|
|
749
|
+
# is never owned by the container's root.
|
|
750
|
+
fd = os.open(partial, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
751
|
+
with os.fdopen(fd, "wb") as fh:
|
|
752
|
+
out = subprocess.run([*shlex.split(docker()), "run", "--rm", *mounts, TAR_IMAGE,
|
|
753
|
+
"tar", "czf", "-", "-C", "/v", "."],
|
|
754
|
+
stdout=fh, stderr=subprocess.PIPE, timeout=3600)
|
|
755
|
+
finally:
|
|
756
|
+
subprocess.run([*base, "unpause"], capture_output=True, text=True, timeout=60)
|
|
757
|
+
if out.returncode != 0 or partial.stat().st_size == 0:
|
|
758
|
+
partial.unlink(missing_ok=True)
|
|
759
|
+
raise ContainerError(f"volume backup failed: {out.stderr.decode(errors='replace').strip()[:300]}")
|
|
760
|
+
os.replace(partial, dest)
|
|
761
|
+
for old in list_backups(app)[BACKUP_KEEP:]:
|
|
762
|
+
(root / old["file"]).unlink(missing_ok=True)
|
|
763
|
+
return dest
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def restore_volumes(app: str, file: Path, systemctl) -> None:
|
|
767
|
+
volumes = stack_volumes(app)
|
|
768
|
+
if not volumes:
|
|
769
|
+
raise ContainerError(f"{app} has no volumes to restore into")
|
|
770
|
+
systemctl("stop", f"hoist-{app}.service")
|
|
771
|
+
subprocess.run([*shlex.split(docker()), "compose", "-p", compose_project(app), "stop"],
|
|
772
|
+
capture_output=True, text=True, timeout=120)
|
|
773
|
+
mounts = [arg for v in volumes for arg in ("-v", f"{v}:/v/{v}")]
|
|
774
|
+
clear = " && ".join(f"find /v/{shlex.quote(v)} -mindepth 1 -delete" for v in volumes)
|
|
775
|
+
with open(file, "rb") as fh:
|
|
776
|
+
out = subprocess.run([*shlex.split(docker()), "run", "--rm", "-i", *mounts, TAR_IMAGE,
|
|
777
|
+
"sh", "-c", f"{clear} && tar xzf - -C /v"],
|
|
778
|
+
stdin=fh, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=3600)
|
|
779
|
+
systemctl("start", f"hoist-{app}.service")
|
|
780
|
+
if out.returncode != 0:
|
|
781
|
+
raise ContainerError(f"volume restore failed: {out.stderr.decode(errors='replace').strip()[:300]}")
|