lablink-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.
@@ -0,0 +1,441 @@
1
+ """Ships docker container logs from a BYO client to the allocator.
2
+
3
+ Invoked as: ``python -m lablink_cli.log_shipper <env_file>``
4
+ by ``lablink register``. Reads CLIENT_SECRET / ALLOCATOR_URL / VM_NAME from
5
+ the env file written by register, batches ``docker logs --follow`` output,
6
+ and POSTs to ``/api/vm-logs/<hostname>``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import queue
14
+ import re
15
+ import signal
16
+ import sys
17
+ import threading
18
+ import time
19
+ from datetime import datetime, timedelta, timezone
20
+ from pathlib import Path
21
+ from typing import Callable, Literal
22
+ from urllib.error import HTTPError, URLError
23
+ from urllib.request import Request
24
+ from urllib.request import urlopen as _stdlib_urlopen
25
+
26
+ from lablink_cli.api import USER_AGENT
27
+ from lablink_cli.docker import Docker, default_docker
28
+
29
+
30
+ def load_env(env_file: Path) -> dict[str, str]:
31
+ """Parse the BYO client.env file (KEY=VALUE per line, # comments)."""
32
+ text = Path(env_file).read_text()
33
+ env: dict[str, str] = {}
34
+ for raw in text.splitlines():
35
+ line = raw.strip()
36
+ if not line or line.startswith("#"):
37
+ continue
38
+ if "=" not in line:
39
+ continue
40
+ key, _, value = line.partition("=")
41
+ env[key.strip()] = value
42
+ return env
43
+
44
+
45
+ def read_last_shipped_ts(state_file: Path) -> str | None:
46
+ """Return the timestamp of the last successfully shipped line, or None.
47
+
48
+ Treats missing or corrupt state as None (first-attach behavior).
49
+ """
50
+ try:
51
+ data = json.loads(Path(state_file).read_text())
52
+ except (FileNotFoundError, json.JSONDecodeError):
53
+ return None
54
+ ts = data.get("last_shipped_ts")
55
+ return ts if isinstance(ts, str) else None
56
+
57
+
58
+ def write_last_shipped_ts(state_file: Path, ts: str) -> None:
59
+ """Persist last_shipped_ts atomically (write-and-rename)."""
60
+ path = Path(state_file)
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ tmp = path.with_suffix(path.suffix + ".tmp")
63
+ tmp.write_text(json.dumps({"last_shipped_ts": ts}))
64
+ tmp.replace(path)
65
+
66
+
67
+ MAX_RETRIES = 3
68
+ RETRY_BACKOFF_S = (1, 2, 4) # sleep before retry attempt 1, 2, 3
69
+ # The allocator routes logs to the docker_logs column when log_group ends
70
+ # with "-docker"; cloud_init otherwise (main.py:851). Manual/BYO clients
71
+ # only ship docker container output, so use a name that satisfies the
72
+ # suffix check.
73
+ LOG_GROUP = "manual-docker"
74
+
75
+ PostResult = Literal["ok", "drop", "fatal"]
76
+
77
+
78
+ def post_batch(
79
+ *,
80
+ allocator_url: str,
81
+ vm_name: str,
82
+ client_secret: str,
83
+ messages: list[str],
84
+ log_group: str = LOG_GROUP,
85
+ urlopen: Callable = _stdlib_urlopen,
86
+ sleep: Callable[[float], None] = time.sleep,
87
+ ) -> PostResult:
88
+ """POST a batch of log lines to /api/vm-logs/<vm_name>.
89
+
90
+ Returns ``"ok"`` on 2xx, ``"fatal"`` on 4xx (no retry — shipper should
91
+ exit), and ``"drop"`` after MAX_RETRIES of 5xx or network failures.
92
+ """
93
+ url = f"{allocator_url.rstrip('/')}/api/vm-logs/{vm_name}"
94
+ body = json.dumps({"log_group": log_group, "messages": messages}).encode()
95
+ headers = {
96
+ "Content-Type": "application/json",
97
+ "Authorization": f"Bearer {client_secret}",
98
+ # urllib's default "Python-urllib/x.y" is blocked with HTTP 403 by
99
+ # Cloudflare-proxied allocators (see api.py's USER_AGENT) — post_batch
100
+ # treats any 4xx as fatal, so without this every batch kills the
101
+ # shipper on its first POST.
102
+ "User-Agent": USER_AGENT,
103
+ }
104
+
105
+ for attempt in range(MAX_RETRIES):
106
+ if attempt > 0:
107
+ sleep(RETRY_BACKOFF_S[attempt - 1])
108
+ try:
109
+ req = Request(url, data=body, headers=headers, method="POST")
110
+ with urlopen(req, timeout=10) as resp:
111
+ if 200 <= resp.status < 300:
112
+ return "ok"
113
+ continue
114
+ except HTTPError as e:
115
+ if 400 <= e.code < 500:
116
+ return "fatal" # bad secret / unknown hostname — exit
117
+ continue
118
+ except URLError:
119
+ continue
120
+ return "drop"
121
+
122
+
123
+ BATCH_SIZE = 50
124
+ FLUSH_INTERVAL_S = 15
125
+
126
+
127
+ def should_flush(*, buffer_len: int, elapsed_s: float) -> bool:
128
+ """Return True if the buffer should be flushed now."""
129
+ if buffer_len == 0:
130
+ return False
131
+ return buffer_len >= BATCH_SIZE or elapsed_s >= FLUSH_INTERVAL_S
132
+
133
+
134
+ CONTAINER_NAME = "lablink-client"
135
+
136
+ # Strip RFC3339Nano fractional seconds (e.g.
137
+ # 2026-05-28T14:23:01.123456789Z → 2026-05-28T14:23:01Z) so admin views
138
+ # aren't cluttered with nanosecond noise. Matches log_shipper.sh:101.
139
+ _TS_RE = re.compile(
140
+ r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d+)?Z (.*)$"
141
+ )
142
+
143
+
144
+ def parse_docker_line(line: str) -> tuple[str | None, str]:
145
+ """Split a ``docker logs --timestamps`` line into ``(ts, message)``.
146
+
147
+ Returns ``(None, line)`` if no timestamp prefix is present.
148
+ """
149
+ m = _TS_RE.match(line)
150
+ if not m:
151
+ return None, line
152
+ return f"{m.group(1)}Z", m.group(2)
153
+
154
+
155
+ LOG_SHIPPER_DIR = Path.home() / ".lablink"
156
+ PID_FILE = LOG_SHIPPER_DIR / "log_shipper.pid"
157
+ STATE_FILE = LOG_SHIPPER_DIR / "log_shipper.state"
158
+ SELF_LOG_FILE = LOG_SHIPPER_DIR / "log_shipper.log"
159
+ FIRST_ATTACH_LOOKBACK_S = 60
160
+ CONTAINER_RESTART_WAIT_S = 5
161
+ INSPECT_RETRY_INTERVAL_S = 30
162
+ INSPECT_MAX_RETRIES = 5
163
+ # After this many consecutive "exited" inspections, give up — the container
164
+ # has stopped and docker is not restarting it, which means the user invoked
165
+ # `docker stop`. With `--restart unless-stopped`, a crashed container goes to
166
+ # "restarting" within ms, so consecutive "exited" reliably indicates user
167
+ # intent rather than a transient state.
168
+ MAX_EXITED_CONSECUTIVE = 3
169
+
170
+ SELF_LOG_MAX_BYTES = 1_000_000
171
+
172
+
173
+ def self_log(log_file: Path, message: str) -> None:
174
+ """Append a timestamped line to the shipper's own diagnostic log.
175
+
176
+ Rotates to <log>.1 (single rotation) when the file exceeds 1MB. This
177
+ is the shipper's only error channel; it runs detached so stdout/stderr
178
+ are discarded.
179
+ """
180
+ path = Path(log_file)
181
+ path.parent.mkdir(parents=True, exist_ok=True)
182
+ if path.exists() and path.stat().st_size >= SELF_LOG_MAX_BYTES:
183
+ rotated = path.with_suffix(path.suffix + ".1")
184
+ path.replace(rotated)
185
+ ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
186
+ with path.open("a") as f:
187
+ f.write(f"{ts} {message}\n")
188
+
189
+
190
+ def _initial_since() -> str:
191
+ """RFC3339 timestamp ~1 minute ago, for first-ever shipper attach."""
192
+ ts = datetime.now(timezone.utc) - timedelta(
193
+ seconds=FIRST_ATTACH_LOOKBACK_S
194
+ )
195
+ return ts.strftime("%Y-%m-%dT%H:%M:%SZ")
196
+
197
+
198
+ # Yielded when the flush window elapses with no new log line, so the read
199
+ # loop wakes up and re-evaluates should_flush().
200
+ TICK = object()
201
+
202
+
203
+ def _read_lines_from_popen(proc, *, timeout: float = FLUSH_INTERVAL_S):
204
+ """Yield a Popen's stdout line by line, plus TICK on each idle timeout.
205
+
206
+ A bare ``for line in proc.stdout`` only wakes when output arrives, so a
207
+ container that logs a burst at startup and then goes quiet holds its
208
+ buffer forever and the allocator never sees a single line. The shell
209
+ shipper avoids this with ``read -t "$FLUSH_INTERVAL"``
210
+ (log_shipper.sh:115); pipes aren't selectable on Windows — where BYO
211
+ clients actually run — so a reader thread plus a queue is the portable
212
+ equivalent.
213
+ """
214
+ if proc.stdout is None:
215
+ return
216
+ q: queue.Queue = queue.Queue()
217
+ eof = object()
218
+
219
+ def pump():
220
+ try:
221
+ for line in proc.stdout:
222
+ q.put(line.rstrip("\n"))
223
+ finally:
224
+ q.put(eof)
225
+
226
+ threading.Thread(target=pump, daemon=True).start()
227
+ while True:
228
+ try:
229
+ item = q.get(timeout=timeout)
230
+ except queue.Empty:
231
+ yield TICK
232
+ continue
233
+ if item is eof:
234
+ return
235
+ yield item
236
+
237
+
238
+ def run_shipper(
239
+ env_file: Path,
240
+ *,
241
+ _line_iter: Callable | None = None,
242
+ _sleep: Callable[[float], None] = time.sleep,
243
+ docker: Docker | None = None,
244
+ ) -> None:
245
+ """Main shipper loop. Returns when shipping should stop."""
246
+ docker = docker or default_docker()
247
+ env = load_env(env_file)
248
+ allocator_url = env["ALLOCATOR_URL"]
249
+ vm_name = env["VM_NAME"]
250
+ client_secret = env["CLIENT_SECRET"]
251
+
252
+ self_log(SELF_LOG_FILE, f"shipper starting for vm_name={vm_name}")
253
+
254
+ since = read_last_shipped_ts(STATE_FILE) or _initial_since()
255
+ self_log(SELF_LOG_FILE, f"attaching to docker logs --since {since}")
256
+
257
+ # ---- Attach loop: re-runs if container restarts ----
258
+ inspect_failures = 0
259
+ exited_consecutive = 0
260
+ while True:
261
+ if _line_iter is not None:
262
+ line_source = _line_iter()
263
+ proc = None
264
+ else:
265
+ proc = docker.follow_logs(CONTAINER_NAME, since=since)
266
+ line_source = _read_lines_from_popen(proc)
267
+
268
+ buffer: list[str] = []
269
+ buffer_first_ts: float | None = None
270
+ last_ts_in_batch: str | None = None
271
+
272
+ # ---- Inner read loop ----
273
+ try:
274
+ for line in line_source:
275
+ # TICK means the flush window elapsed with no new line —
276
+ # skip straight to the flush check below. should_flush()
277
+ # already no-ops on an empty buffer.
278
+ if line is not TICK:
279
+ ts, msg = parse_docker_line(line)
280
+ # Buffer the original tagged line, preserving the
281
+ # timestamp prefix so admin views show it (matches
282
+ # log_shipper.sh's docker --timestamps + sed pipeline).
283
+ if ts is not None:
284
+ buffer.append(f"{ts} {msg}")
285
+ last_ts_in_batch = ts
286
+ else:
287
+ buffer.append(msg)
288
+ if buffer_first_ts is None:
289
+ buffer_first_ts = time.monotonic()
290
+
291
+ elapsed = (
292
+ time.monotonic() - buffer_first_ts
293
+ if buffer_first_ts is not None
294
+ else 0
295
+ )
296
+ if should_flush(
297
+ buffer_len=len(buffer), elapsed_s=elapsed
298
+ ):
299
+ result = post_batch(
300
+ allocator_url=allocator_url,
301
+ vm_name=vm_name,
302
+ client_secret=client_secret,
303
+ messages=buffer,
304
+ )
305
+ if result == "ok" and last_ts_in_batch:
306
+ write_last_shipped_ts(
307
+ STATE_FILE, last_ts_in_batch
308
+ )
309
+ elif result == "fatal":
310
+ self_log(
311
+ SELF_LOG_FILE,
312
+ "POST returned fatal (4xx); exiting",
313
+ )
314
+ return
315
+ elif result == "drop":
316
+ self_log(
317
+ SELF_LOG_FILE,
318
+ f"dropped batch of {len(buffer)} after retries",
319
+ )
320
+ buffer = []
321
+ buffer_first_ts = None
322
+ last_ts_in_batch = None
323
+ finally:
324
+ if proc is not None:
325
+ try:
326
+ proc.terminate()
327
+ proc.wait(timeout=5)
328
+ except Exception:
329
+ pass
330
+
331
+ # Flush any tail buffer before deciding whether to reconnect.
332
+ if buffer:
333
+ result = post_batch(
334
+ allocator_url=allocator_url,
335
+ vm_name=vm_name,
336
+ client_secret=client_secret,
337
+ messages=buffer,
338
+ )
339
+ if result == "ok" and last_ts_in_batch:
340
+ write_last_shipped_ts(STATE_FILE, last_ts_in_batch)
341
+ elif result == "fatal":
342
+ self_log(
343
+ SELF_LOG_FILE,
344
+ "POST returned fatal during tail flush; exiting",
345
+ )
346
+ return
347
+
348
+ # docker logs --follow exited. Inspect to decide what to do.
349
+ status = docker.container_status(CONTAINER_NAME)
350
+ self_log(
351
+ SELF_LOG_FILE, f"docker logs ended; container status={status}"
352
+ )
353
+ if status == "missing":
354
+ self_log(SELF_LOG_FILE, "container missing; exiting")
355
+ return
356
+ if status == "daemon_error":
357
+ inspect_failures += 1
358
+ if inspect_failures >= INSPECT_MAX_RETRIES:
359
+ self_log(
360
+ SELF_LOG_FILE,
361
+ "daemon unreachable after max retries; exiting",
362
+ )
363
+ return
364
+ _sleep(INSPECT_RETRY_INTERVAL_S)
365
+ continue
366
+ inspect_failures = 0
367
+ if status == "exited":
368
+ exited_consecutive += 1
369
+ if exited_consecutive >= MAX_EXITED_CONSECUTIVE:
370
+ self_log(
371
+ SELF_LOG_FILE,
372
+ f"container stayed exited for {exited_consecutive} "
373
+ "consecutive checks; treating as user-initiated stop; "
374
+ "exiting",
375
+ )
376
+ return
377
+ _sleep(CONTAINER_RESTART_WAIT_S)
378
+ elif status == "restarting":
379
+ # docker is bringing it back — don't count toward the give-up
380
+ # threshold, just wait and reconnect.
381
+ exited_consecutive = 0
382
+ _sleep(CONTAINER_RESTART_WAIT_S)
383
+ else:
384
+ # status == "running" → reconnect immediately
385
+ exited_consecutive = 0
386
+ # update since for the reconnect so we don't re-ship
387
+ new_since = read_last_shipped_ts(STATE_FILE)
388
+ if new_since:
389
+ since = new_since
390
+ # If _line_iter is set (test), exit the outer loop after one pass to
391
+ # keep tests deterministic.
392
+ if _line_iter is not None:
393
+ return
394
+
395
+
396
+ def _handle_shutdown(signum, _frame) -> None:
397
+ """SIGTERM/SIGINT handler: unlink PID file and exit cleanly."""
398
+ try:
399
+ PID_FILE.unlink(missing_ok=True)
400
+ except OSError:
401
+ pass
402
+ self_log(SELF_LOG_FILE, f"received signal {signum}; exiting")
403
+ sys.exit(0)
404
+
405
+
406
+ def main(argv: list[str] | None = None) -> int:
407
+ """Entry point for ``python -m lablink_cli.log_shipper <env_file>``."""
408
+ args = argv if argv is not None else sys.argv[1:]
409
+ if len(args) != 1:
410
+ print(
411
+ "usage: python -m lablink_cli.log_shipper <env_file>",
412
+ file=sys.stderr,
413
+ )
414
+ return 2
415
+
416
+ env_file = Path(args[0])
417
+
418
+ LOG_SHIPPER_DIR.mkdir(parents=True, exist_ok=True)
419
+ PID_FILE.write_text(str(os.getpid()))
420
+
421
+ # Best-effort signal handlers. Windows lacks SIGTERM in the standard
422
+ # sense; signal.signal(SIGTERM, ...) works on POSIX but is a no-op or
423
+ # raises on Windows for some signals — guard with try/except.
424
+ for sig in (signal.SIGTERM, signal.SIGINT):
425
+ try:
426
+ signal.signal(sig, _handle_shutdown)
427
+ except (ValueError, AttributeError):
428
+ pass
429
+
430
+ try:
431
+ run_shipper(env_file)
432
+ finally:
433
+ try:
434
+ PID_FILE.unlink(missing_ok=True)
435
+ except OSError:
436
+ pass
437
+ return 0
438
+
439
+
440
+ if __name__ == "__main__":
441
+ raise SystemExit(main())
@@ -0,0 +1,55 @@
1
+ # Rendered by `lablink deploy` (manual provider) as
2
+ # `docker-compose.override.yml` — DO NOT edit by hand. Re-run `lablink
3
+ # deploy` to regenerate after config changes.
4
+ #
5
+ # Compose auto-loads `docker-compose.override.yml` from the project
6
+ # directory and deep-merges it over `docker-compose.yml`, so every
7
+ # `docker compose` invocation the CLI makes (up, down, logs) picks this
8
+ # up with no `-f` flags. It is written only when a tailnet join is
9
+ # needed — for mesh-overlay client connectivity or for Funnel
10
+ # participant exposure — and deleted on a redeploy that no longer needs
11
+ # one.
12
+ services:
13
+ # Joins the allocator's host to the tailnet so its nginx can reach
14
+ # mesh-overlay clients. network_mode: service:allocator means this
15
+ # container shares the allocator's network namespace — the allocator's
16
+ # own process sees the tailscale0 interface directly, no separate
17
+ # networking setup needed on the allocator service itself. Depends on
18
+ # allocator (not the other way around): sharing a network namespace
19
+ # requires that namespace to already exist.
20
+ tailscale:
21
+ image: tailscale/tailscale:latest
22
+ # Without this, a locally cached image from a prior pull silently wins
23
+ # even when it's the wrong architecture for the current host — confirmed
24
+ # live: a stale amd64-cached image ran QEMU-emulated on an Apple Silicon
25
+ # host and corrupted the Noise-protocol handshake (surfaced as
26
+ # `chacha20poly1305: message authentication failed`), even though the
27
+ # image is genuinely published multi-arch and a native arm64 pull joins
28
+ # the tailnet immediately. Matches the allocator service's own
29
+ # pull_policy in docker-compose.yml.
30
+ pull_policy: always
31
+ container_name: lablink-allocator-tailscale
32
+ network_mode: "service:allocator"
33
+ depends_on:
34
+ - allocator
35
+ environment:
36
+ - TS_AUTHKEY=${TS_AUTHKEY:-}
37
+ - TS_HOSTNAME=${TAILSCALE_HOSTNAME:-lablink-allocator}
38
+ - TS_STATE_DIR=/var/lib/tailscale
39
+ # containerboot defaults to --tun=userspace-networking, a
40
+ # software-only netstack with no real tailscale0 interface — nginx
41
+ # can't route to it. Confirmed live against a real tailnet: NET_ADMIN
42
+ # + /dev/net/tun alone were not enough; this must be set explicitly
43
+ # to get the kernel interface nginx depends on.
44
+ - TS_USERSPACE=false
45
+ cap_add:
46
+ - NET_ADMIN
47
+ - NET_RAW
48
+ devices:
49
+ - /dev/net/tun:/dev/net/tun
50
+ volumes:
51
+ - tailscale_state:/var/lib/tailscale
52
+ restart: unless-stopped
53
+
54
+ volumes:
55
+ tailscale_state:
@@ -0,0 +1,67 @@
1
+ # Rendered by `lablink deploy` (manual provider) — DO NOT edit by hand.
2
+ # Re-run `lablink deploy` to regenerate after config changes.
3
+ #
4
+ # The allocator image is monolithic: it bundles Flask + nginx + an
5
+ # internal Postgres. The single named volume below preserves DB state
6
+ # across container restarts. Mount `./config.yaml` at /config (the path
7
+ # the container's start.sh already reads from); everything else
8
+ # (admin creds, DB creds) lives inside that file.
9
+ services:
10
+ allocator:
11
+ image: ${ALLOCATOR_IMAGE}
12
+ # Mutable tags like `linux-amd64-latest[-test]` are republished by CI
13
+ # without changing the tag string, so the local cache would otherwise
14
+ # mask updates. `always` makes `docker compose up -d` re-pull every
15
+ # time and is what makes "push a new image, re-run lablink deploy"
16
+ # actually deploy the new image.
17
+ pull_policy: always
18
+ # The allocator image is published amd64-only; pin the platform so
19
+ # Apple Silicon (and other arm64 hosts) emulate via Rosetta instead
20
+ # of failing with "no matching manifest for linux/arm64/v8". This is
21
+ # a no-op on native amd64 hosts. Drop this once multi-arch images
22
+ # are published from lablink-template's CI.
23
+ platform: linux/amd64
24
+ # Pin the container name so the CLI's logs/status/_extract_register_token
25
+ # helpers can address it without knowing the compose project name.
26
+ container_name: lablink-allocator
27
+ volumes:
28
+ - ./config.yaml:/config/config.yaml:ro
29
+ # The CLI's render_compose_dir always materializes custom-startup.sh
30
+ # in this directory (empty when disabled), so this bind mount
31
+ # always resolves. The allocator reads it at register time and
32
+ # ships its base64 content to BYO clients via the register
33
+ # response — matching the AWS path where OpenTofu/user_data
34
+ # delivers the same file to the client container.
35
+ - ./custom-startup.sh:/config/custom-startup.sh:ro
36
+ # The allocator's real public URL, written by `lablink deploy` after it
37
+ # confirms Funnel is live. Behind Funnel the allocator can't derive this
38
+ # from the request (no X-Forwarded-Proto, and ssl.provider=none keeps the
39
+ # header-trust gate shut), so it would otherwise hand clients an http://
40
+ # URL that only 302-redirects. Always materialized by
41
+ # render_compose_dir, same as above — empty when this deployment isn't
42
+ # Funnel-exposed, and the allocator falls back to the request host then.
43
+ - ./allocator-url:/config/allocator-url:ro
44
+ - allocator_pgdata:/var/lib/postgresql
45
+ environment:
46
+ # Which participant-exposure mode start.sh should act on. Always
47
+ # present (compose has no conditionals; an unset variable makes
48
+ # `docker compose up` warn). The other two modes leave start.sh's
49
+ # connector block untouched.
50
+ - PARTICIPANT_EXPOSURE=${PARTICIPANT_EXPOSURE:-none}
51
+ # Cloudflare Tunnel token, for participant_exposure=cloudflare_tunnel.
52
+ # Comes from .env (written by `lablink deploy
53
+ # --cloudflare-tunnel-token`), never from config.yaml — it is a
54
+ # credential. start.sh exits non-zero if the mode is set and this is
55
+ # empty, rather than starting an unexposed allocator that looks fine.
56
+ - CLOUDFLARE_TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN:-}
57
+ ports:
58
+ # The container's nginx listens on :5000 (see lablink-nginx.conf in
59
+ # the allocator package). It is the only listener — there is no TLS
60
+ # terminator inside this image, so HTTPS is not exposed by the
61
+ # manual provider; front the stack with your own reverse proxy
62
+ # (Caddy, nginx, Cloudflare Tunnel) for public TLS.
63
+ - "${HTTP_PORT}:5000"
64
+ restart: unless-stopped
65
+
66
+ volumes:
67
+ allocator_pgdata: