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
lablink_cli/docker.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""The CLI's single seam onto the `docker` binary.
|
|
2
|
+
|
|
3
|
+
Every docker invocation in this package goes through here. Two reasons:
|
|
4
|
+
|
|
5
|
+
1. The "returncode != 0 means not-found" convention lives once instead of
|
|
6
|
+
being re-derived at each call site.
|
|
7
|
+
2. Tests substitute :class:`NullDocker` instead of monkeypatching the global
|
|
8
|
+
``subprocess.run`` — which is what the old ``tests/conftest.py`` guard did,
|
|
9
|
+
after a green test run silently turned a live deployment's Funnel off.
|
|
10
|
+
|
|
11
|
+
Verbs return domain values. The three escape hatches — :meth:`Docker.compose`,
|
|
12
|
+
:meth:`Docker.exec_in`, :meth:`Docker.run_detached` — return a raw
|
|
13
|
+
:class:`Result` because their callers format messages from both the exit code
|
|
14
|
+
and stderr.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Literal, Sequence
|
|
24
|
+
|
|
25
|
+
ContainerStatus = Literal[
|
|
26
|
+
"running", "restarting", "exited", "missing", "daemon_error"
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
DOCKER_MISSING_MESSAGE = (
|
|
30
|
+
"docker not found on PATH. Install Docker Engine + the Compose plugin "
|
|
31
|
+
"(https://docs.docker.com/engine/install/) and re-run."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_INSPECT_TIMEOUT_S = 10
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DockerUnavailable(RuntimeError):
|
|
38
|
+
"""Raised when the `docker` binary is not on PATH."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str = DOCKER_MISSING_MESSAGE) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DockerDaemonError(RuntimeError):
|
|
45
|
+
"""Raised when the docker daemon cannot answer a query."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Result:
|
|
50
|
+
"""The outcome of a raw docker invocation."""
|
|
51
|
+
|
|
52
|
+
returncode: int
|
|
53
|
+
stdout: str = ""
|
|
54
|
+
stderr: str = ""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def ok(self) -> bool:
|
|
58
|
+
"""True if docker exited zero."""
|
|
59
|
+
return self.returncode == 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Docker:
|
|
63
|
+
"""Runs real `docker` commands."""
|
|
64
|
+
|
|
65
|
+
def path(self) -> str | None:
|
|
66
|
+
"""Absolute path to the docker binary, or None if not on PATH."""
|
|
67
|
+
return shutil.which("docker")
|
|
68
|
+
|
|
69
|
+
def available(self) -> bool:
|
|
70
|
+
"""True if the docker binary is on PATH."""
|
|
71
|
+
return self.path() is not None
|
|
72
|
+
|
|
73
|
+
def require(self) -> None:
|
|
74
|
+
"""Raise :class:`DockerUnavailable` if docker is not on PATH."""
|
|
75
|
+
if not self.available():
|
|
76
|
+
raise DockerUnavailable()
|
|
77
|
+
|
|
78
|
+
# -- verbs ---------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def container_status(self, name: str) -> ContainerStatus:
|
|
81
|
+
"""Map ``docker inspect`` output to a coarse status.
|
|
82
|
+
|
|
83
|
+
- "running" -> container is up
|
|
84
|
+
- "restarting" -> docker is bringing it back
|
|
85
|
+
- "exited" -> container is stopped
|
|
86
|
+
- "missing" -> no container with that name exists
|
|
87
|
+
- "daemon_error"-> docker daemon is unreachable
|
|
88
|
+
|
|
89
|
+
No ``require()`` guard, deliberately: a missing binary raises
|
|
90
|
+
``FileNotFoundError`` from the ``subprocess.run`` call below, which
|
|
91
|
+
is an ``OSError`` and so is already caught by the ``except
|
|
92
|
+
(TimeoutExpired, OSError)`` clause and reported as "daemon_error" —
|
|
93
|
+
an ordinary return value, not a raised ``DockerUnavailable``.
|
|
94
|
+
``doctor.py``'s client-side container check relies on exactly this:
|
|
95
|
+
it calls `container_status` as its *only* daemon probe (see
|
|
96
|
+
``_check_client_container``'s docstring), so on the very machine
|
|
97
|
+
`doctor` exists to diagnose — one with no `docker` on PATH — adding
|
|
98
|
+
`require()` here would turn that diagnosis into an unhandled
|
|
99
|
+
traceback instead of the "Docker daemon unreachable" line it prints
|
|
100
|
+
today.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
result = subprocess.run(
|
|
104
|
+
["docker", "inspect", name, "--format", "{{.State.Status}}"],
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
timeout=_INSPECT_TIMEOUT_S,
|
|
108
|
+
)
|
|
109
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
110
|
+
return "daemon_error"
|
|
111
|
+
|
|
112
|
+
if result.returncode == 0:
|
|
113
|
+
status = result.stdout.strip()
|
|
114
|
+
if status in ("running", "restarting", "exited"):
|
|
115
|
+
return status # type: ignore[return-value]
|
|
116
|
+
# Other statuses (created, paused, dead) — treat like exited.
|
|
117
|
+
return "exited"
|
|
118
|
+
|
|
119
|
+
stderr = (result.stderr or "").lower()
|
|
120
|
+
if "no such" in stderr or "no such object" in stderr:
|
|
121
|
+
return "missing"
|
|
122
|
+
return "daemon_error"
|
|
123
|
+
|
|
124
|
+
def inspect_format(self, name: str, template: str) -> str:
|
|
125
|
+
"""Return a Go-template field from ``docker inspect``.
|
|
126
|
+
|
|
127
|
+
Empty string when the object is absent or the template matched
|
|
128
|
+
nothing — callers treat both the same way.
|
|
129
|
+
"""
|
|
130
|
+
self.require()
|
|
131
|
+
result = subprocess.run(
|
|
132
|
+
["docker", "inspect", name, "--format", template],
|
|
133
|
+
capture_output=True,
|
|
134
|
+
text=True,
|
|
135
|
+
check=False,
|
|
136
|
+
)
|
|
137
|
+
if result.returncode != 0:
|
|
138
|
+
return ""
|
|
139
|
+
return result.stdout.strip()
|
|
140
|
+
|
|
141
|
+
def daemon_info(self, template: str) -> str:
|
|
142
|
+
"""Return a Go-template field from ``docker info``.
|
|
143
|
+
|
|
144
|
+
Raises :class:`DockerDaemonError` if the daemon cannot answer.
|
|
145
|
+
"""
|
|
146
|
+
self.require()
|
|
147
|
+
try:
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
["docker", "info", "--format", template],
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
check=True,
|
|
153
|
+
timeout=_INSPECT_TIMEOUT_S,
|
|
154
|
+
)
|
|
155
|
+
except (
|
|
156
|
+
subprocess.CalledProcessError,
|
|
157
|
+
subprocess.TimeoutExpired,
|
|
158
|
+
OSError,
|
|
159
|
+
) as e:
|
|
160
|
+
raise DockerDaemonError(str(e)) from e
|
|
161
|
+
return result.stdout.strip()
|
|
162
|
+
|
|
163
|
+
def volume_exists(self, name: str) -> bool:
|
|
164
|
+
"""True if the named volume is present.
|
|
165
|
+
|
|
166
|
+
``docker volume inspect`` exits non-zero for an unknown volume,
|
|
167
|
+
which is the only signal needed.
|
|
168
|
+
"""
|
|
169
|
+
self.require()
|
|
170
|
+
result = subprocess.run(
|
|
171
|
+
["docker", "volume", "inspect", name],
|
|
172
|
+
capture_output=True,
|
|
173
|
+
text=True,
|
|
174
|
+
check=False,
|
|
175
|
+
)
|
|
176
|
+
return result.returncode == 0
|
|
177
|
+
|
|
178
|
+
def remove_volume(self, name: str) -> Result:
|
|
179
|
+
"""Remove a volume. Callers format their own failure message."""
|
|
180
|
+
self.require()
|
|
181
|
+
return self._run(["docker", "volume", "rm", name])
|
|
182
|
+
|
|
183
|
+
def remove_container(self, name: str, *, force: bool = True) -> Result:
|
|
184
|
+
"""Remove a container.
|
|
185
|
+
|
|
186
|
+
``docker rm -f`` exits 0 whether or not the container existed; a
|
|
187
|
+
non-zero exit is a daemon-level failure.
|
|
188
|
+
"""
|
|
189
|
+
self.require()
|
|
190
|
+
argv = ["docker", "rm"]
|
|
191
|
+
if force:
|
|
192
|
+
argv.append("-f")
|
|
193
|
+
argv.append(name)
|
|
194
|
+
return self._run(argv)
|
|
195
|
+
|
|
196
|
+
def start_container(self, name: str) -> Result:
|
|
197
|
+
"""Start an existing, stopped container."""
|
|
198
|
+
self.require()
|
|
199
|
+
return self._run(["docker", "start", name])
|
|
200
|
+
|
|
201
|
+
def logs(
|
|
202
|
+
self,
|
|
203
|
+
name: str,
|
|
204
|
+
*,
|
|
205
|
+
tail: int | None = None,
|
|
206
|
+
merge_stderr: bool = False,
|
|
207
|
+
timeout: float | None = None,
|
|
208
|
+
) -> Result:
|
|
209
|
+
"""Snapshot a container's logs.
|
|
210
|
+
|
|
211
|
+
``merge_stderr`` folds stderr into stdout. Needed wherever the
|
|
212
|
+
container's Python logging goes to stderr: capturing the streams
|
|
213
|
+
separately and reading only stdout hides exactly the tracebacks the
|
|
214
|
+
caller is looking for.
|
|
215
|
+
|
|
216
|
+
No ``require()`` guard — like :meth:`container_status`, a missing
|
|
217
|
+
binary already falls out of the ``except (TimeoutExpired, OSError)``
|
|
218
|
+
below (``FileNotFoundError`` is an ``OSError``) as an ordinary
|
|
219
|
+
failed ``Result``. Adding ``require()`` would only turn that
|
|
220
|
+
swallowed error into a raised ``DockerUnavailable``, which is what
|
|
221
|
+
callers relied on *not* happening pre-refactor.
|
|
222
|
+
"""
|
|
223
|
+
argv = ["docker", "logs"]
|
|
224
|
+
if tail is not None:
|
|
225
|
+
argv += ["--tail", str(tail)]
|
|
226
|
+
argv.append(name)
|
|
227
|
+
|
|
228
|
+
kwargs: dict = {"text": True, "check": False}
|
|
229
|
+
if merge_stderr:
|
|
230
|
+
kwargs["stdout"] = subprocess.PIPE
|
|
231
|
+
kwargs["stderr"] = subprocess.STDOUT
|
|
232
|
+
else:
|
|
233
|
+
kwargs["capture_output"] = True
|
|
234
|
+
if timeout is not None:
|
|
235
|
+
kwargs["timeout"] = timeout
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
result = subprocess.run(argv, **kwargs)
|
|
239
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
240
|
+
return Result(returncode=1, stderr=str(e))
|
|
241
|
+
return Result(
|
|
242
|
+
returncode=result.returncode,
|
|
243
|
+
stdout=result.stdout or "",
|
|
244
|
+
stderr=result.stderr or "",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def follow_logs(
|
|
248
|
+
self, name: str, *, since: str | None = None
|
|
249
|
+
) -> subprocess.Popen:
|
|
250
|
+
"""Spawn ``docker logs --follow --timestamps [--since <ts>] <name>``.
|
|
251
|
+
|
|
252
|
+
Returns the Popen handle: the log shipper needs ``.terminate()`` and
|
|
253
|
+
``.poll()`` as well as incremental reads from ``.stdout``.
|
|
254
|
+
"""
|
|
255
|
+
self.require()
|
|
256
|
+
argv = ["docker", "logs", "--follow", "--timestamps"]
|
|
257
|
+
if since:
|
|
258
|
+
argv += ["--since", since]
|
|
259
|
+
argv.append(name)
|
|
260
|
+
return subprocess.Popen(
|
|
261
|
+
argv,
|
|
262
|
+
stdout=subprocess.PIPE,
|
|
263
|
+
stderr=subprocess.STDOUT,
|
|
264
|
+
text=True,
|
|
265
|
+
bufsize=1, # line-buffered
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# -- escape hatches ------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def compose(
|
|
271
|
+
self,
|
|
272
|
+
workdir: Path | str | None,
|
|
273
|
+
*args: str,
|
|
274
|
+
capture: bool = True,
|
|
275
|
+
) -> Result:
|
|
276
|
+
"""Run ``docker compose <args>`` in ``workdir``.
|
|
277
|
+
|
|
278
|
+
``workdir`` is explicit rather than ambient process cwd. Pass None
|
|
279
|
+
for subcommands that are not tied to a deployment directory
|
|
280
|
+
(``docker compose version``). ``capture=False`` streams output to the
|
|
281
|
+
terminal, which is what the deploy/destroy paths want.
|
|
282
|
+
"""
|
|
283
|
+
self.require()
|
|
284
|
+
return self._run(
|
|
285
|
+
["docker", "compose", *args], cwd=workdir, capture=capture
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def exec_in(self, container: str, argv: Sequence[str]) -> Result:
|
|
289
|
+
"""Run a command inside a running container."""
|
|
290
|
+
self.require()
|
|
291
|
+
return self._run(["docker", "exec", container, *argv])
|
|
292
|
+
|
|
293
|
+
def run_detached(self, argv: Sequence[str]) -> Result:
|
|
294
|
+
"""Run a fully-formed ``docker run`` argv, streaming to the terminal.
|
|
295
|
+
|
|
296
|
+
Output is not captured — image pull progress is meant to be visible —
|
|
297
|
+
so the returned Result carries only the exit code, except that
|
|
298
|
+
``_run``'s ``except OSError`` arm still populates ``stderr`` with the
|
|
299
|
+
exec failure text when docker itself could not be started (e.g. the
|
|
300
|
+
binary vanished between `require()` and this call). `register.py`'s
|
|
301
|
+
`_exec_docker` depends on that: a non-empty `stderr` is how it tells
|
|
302
|
+
"could not start docker" apart from "the container ran and failed."
|
|
303
|
+
Do not "correct" this to always leave `stderr` empty — that would
|
|
304
|
+
silently regress that error message.
|
|
305
|
+
"""
|
|
306
|
+
self.require()
|
|
307
|
+
return self._run(list(argv), capture=False)
|
|
308
|
+
|
|
309
|
+
# -- internals -----------------------------------------------------
|
|
310
|
+
|
|
311
|
+
def _run(
|
|
312
|
+
self,
|
|
313
|
+
argv: list[str],
|
|
314
|
+
*,
|
|
315
|
+
cwd: Path | str | None = None,
|
|
316
|
+
capture: bool = True,
|
|
317
|
+
) -> Result:
|
|
318
|
+
try:
|
|
319
|
+
result = subprocess.run(
|
|
320
|
+
argv,
|
|
321
|
+
cwd=cwd,
|
|
322
|
+
capture_output=capture,
|
|
323
|
+
text=True,
|
|
324
|
+
check=False,
|
|
325
|
+
)
|
|
326
|
+
except OSError as e:
|
|
327
|
+
return Result(returncode=1, stderr=str(e))
|
|
328
|
+
return Result(
|
|
329
|
+
returncode=result.returncode,
|
|
330
|
+
stdout=result.stdout or "",
|
|
331
|
+
stderr=result.stderr or "",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class NullDocker(Docker):
|
|
336
|
+
"""Answers every call as a machine with no such container or volume.
|
|
337
|
+
|
|
338
|
+
This is what tests get by default. It is the honest model for a
|
|
339
|
+
unit-test environment and it is what the callers already handle:
|
|
340
|
+
best-effort teardown stays silent, existence checks report False.
|
|
341
|
+
|
|
342
|
+
That model is internally split, deliberately. ``path()``/``available()``
|
|
343
|
+
say "docker is not installed"; ``require()``/``_run()`` say "docker is
|
|
344
|
+
installed and idle — nothing exists." A real :class:`Docker` never
|
|
345
|
+
disagrees with itself that way: ``available() is False`` there implies
|
|
346
|
+
`require()` raises. Do not "fix" this split by making it consistent —
|
|
347
|
+
the present-but-empty half is what the existing test suite's
|
|
348
|
+
expectations are built on. The consequence: a test must not rely on
|
|
349
|
+
this default adapter to exercise docker-*absence* — a call site
|
|
350
|
+
branching on `require()` takes the docker-present path here while one
|
|
351
|
+
branching on `available()` takes the docker-absent path, in the same
|
|
352
|
+
test run. Inject a fake whose `require()` raises instead (see
|
|
353
|
+
`test_reset_overlay.py`/`test_unregister.py`).
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
_NOT_FOUND = (
|
|
357
|
+
"Error response from daemon: No such container: "
|
|
358
|
+
"<docker disabled in tests>"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def path(self) -> str | None:
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
def available(self) -> bool:
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
def require(self) -> None:
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
def container_status(self, name: str) -> ContainerStatus:
|
|
371
|
+
return "missing"
|
|
372
|
+
|
|
373
|
+
def inspect_format(self, name: str, template: str) -> str:
|
|
374
|
+
return ""
|
|
375
|
+
|
|
376
|
+
def daemon_info(self, template: str) -> str:
|
|
377
|
+
raise DockerDaemonError(self._NOT_FOUND)
|
|
378
|
+
|
|
379
|
+
def volume_exists(self, name: str) -> bool:
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
def logs(
|
|
383
|
+
self,
|
|
384
|
+
name: str,
|
|
385
|
+
*,
|
|
386
|
+
tail: int | None = None,
|
|
387
|
+
merge_stderr: bool = False,
|
|
388
|
+
timeout: float | None = None,
|
|
389
|
+
) -> Result:
|
|
390
|
+
return Result(returncode=1, stderr=self._NOT_FOUND)
|
|
391
|
+
|
|
392
|
+
def follow_logs(
|
|
393
|
+
self, name: str, *, since: str | None = None
|
|
394
|
+
) -> subprocess.Popen:
|
|
395
|
+
raise DockerUnavailable(self._NOT_FOUND)
|
|
396
|
+
|
|
397
|
+
def _run(
|
|
398
|
+
self,
|
|
399
|
+
argv: list[str],
|
|
400
|
+
*,
|
|
401
|
+
cwd: Path | str | None = None,
|
|
402
|
+
capture: bool = True,
|
|
403
|
+
) -> Result:
|
|
404
|
+
return Result(returncode=1, stderr=self._NOT_FOUND)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
_default: Docker | None = None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def default_docker() -> Docker:
|
|
411
|
+
"""The process-wide default adapter.
|
|
412
|
+
|
|
413
|
+
Tests replace the module attribute ``_default`` rather than patching
|
|
414
|
+
``subprocess``.
|
|
415
|
+
"""
|
|
416
|
+
global _default
|
|
417
|
+
if _default is None:
|
|
418
|
+
_default = Docker()
|
|
419
|
+
return _default
|