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.
- lablink_cli/__init__.py +8 -0
- lablink_cli/api.py +428 -0
- lablink_cli/app.py +938 -0
- lablink_cli/byo_detect.py +112 -0
- lablink_cli/commands/__init__.py +0 -0
- lablink_cli/commands/cleanup.py +647 -0
- lablink_cli/commands/deploy.py +863 -0
- lablink_cli/commands/deploy_compose.py +1203 -0
- lablink_cli/commands/doctor.py +549 -0
- lablink_cli/commands/export_metrics.py +244 -0
- lablink_cli/commands/launch.py +236 -0
- lablink_cli/commands/logs.py +434 -0
- lablink_cli/commands/register.py +839 -0
- lablink_cli/commands/reset_overlay.py +109 -0
- lablink_cli/commands/setup.py +347 -0
- lablink_cli/commands/stats.py +133 -0
- lablink_cli/commands/status.py +934 -0
- lablink_cli/commands/unregister.py +188 -0
- lablink_cli/commands/utils.py +552 -0
- lablink_cli/config/__init__.py +0 -0
- lablink_cli/config/schema.py +212 -0
- lablink_cli/deployment_metrics.py +94 -0
- lablink_cli/docker.py +419 -0
- lablink_cli/log_shipper.py +441 -0
- lablink_cli/templates/docker-compose.tailscale-override.yml +55 -0
- lablink_cli/templates/docker-compose.yml +67 -0
- lablink_cli/tofu_source.py +169 -0
- lablink_cli/tui/__init__.py +0 -0
- lablink_cli/tui/logs_viewer.py +413 -0
- lablink_cli/tui/wizard.py +1814 -0
- lablink_cli-0.1.0.dist-info/METADATA +76 -0
- lablink_cli-0.1.0.dist-info/RECORD +35 -0
- lablink_cli-0.1.0.dist-info/WHEEL +5 -0
- lablink_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lablink_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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:
|