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/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]}")