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/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