Coverage for src/ai_shell/container.py: 75%
648 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-13 23:19 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-13 23:19 +0000
1"""Docker container lifecycle management.
3Replaces docker-compose.yml by using Docker SDK to create and manage containers
4with the exact same configuration.
5"""
7from __future__ import annotations
9import json
10import logging
11import os
12import subprocess
13import sys
14import time
15from pathlib import Path
16from typing import TYPE_CHECKING, NoReturn
18from docker.errors import APIError, ImageNotFound, NotFound
19from docker.types import DeviceRequest, Mount
21import docker
22from ai_shell.defaults import (
23 COMFYUI_CONTAINER,
24 COMFYUI_DATA_VOLUME,
25 COMFYUI_IMAGE,
26 KOKORO_CONTAINER,
27 KOKORO_IMAGE_CPU,
28 KOKORO_IMAGE_GPU,
29 LLM_NETWORK,
30 N8N_CONTAINER,
31 N8N_IMAGE,
32 OLLAMA_CONTAINER,
33 OLLAMA_CPU_SHARES,
34 OLLAMA_DATA_VOLUME,
35 OLLAMA_IMAGE,
36 OLLAMA_VRAM_BUFFER_BYTES,
37 SHM_SIZE,
38 VOICE_AGENT_CONTAINER,
39 VOICE_AGENT_DATA_VOLUME,
40 VOICE_AGENT_IMAGE,
41 WEBUI_CONTAINER,
42 WEBUI_DATA_VOLUME,
43 WEBUI_IMAGE,
44 WHISPER_CONTAINER,
45 WHISPER_DATA_VOLUME,
46 WHISPER_IMAGE_CPU,
47 WHISPER_IMAGE_GPU,
48 _resolve_env,
49 build_dev_environment,
50 build_dev_mounts,
51 build_n8n_environment,
52 build_n8n_mounts,
53 dev_container_name,
54 docker_tcp_fallback_host,
55 project_dev_port_map,
56)
57from ai_shell.exceptions import (
58 ContainerNotFoundError,
59 DockerNotAvailableError,
60 GpuRequiredError,
61 ImagePullError,
62)
63from ai_shell.gpu import detect_gpu, get_vram_info
65if TYPE_CHECKING:
66 from docker.models.containers import Container
67 from docker.models.images import Image
69 from ai_shell.config import AiShellConfig
71logger = logging.getLogger(__name__)
73# Substrings identifying a container start failure caused by host port
74# bindings (Docker's own allocator vs. OS-level refusal, e.g. another
75# process or a Windows winnat excluded port range).
76_PORT_BIND_ERRORS = (
77 "port is already allocated",
78 "address already in use",
79 "failed to bind host port",
80)
83def _host_port_available(port: int) -> bool:
84 """Check whether *port* can be bound on the host running ai-shell.
86 The CLI runs on the same host where Docker publishes ports, so a brief
87 test-bind sees both live listeners and OS-level reservations (Windows
88 winnat excluded port ranges) that Docker's allocator cannot know about.
89 """
90 import socket
92 try:
93 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
94 sock.bind(("0.0.0.0", port)) # nosec B104
95 except OSError:
96 return False
97 return True
100def _exec_docker(args: list[str]) -> NoReturn:
101 """Execute a docker CLI command with cross-platform TTY support.
103 Uses subprocess.run instead of os.execvp for Windows compatibility.
104 On Windows, os.execvp doesn't truly replace the process, causing TTY issues.
105 """
106 logger.debug("exec: %s", " ".join(args))
107 sys.stdout.flush()
108 sys.stderr.flush()
109 result = subprocess.run(args)
110 sys.exit(result.returncode)
113def _run_docker(args: list[str]) -> tuple[int, float]:
114 """Run a docker CLI command and return (exit_code, elapsed_seconds).
116 Unlike _exec_docker, this does NOT call sys.exit().
117 """
118 logger.debug("run: %s", " ".join(args))
119 sys.stdout.flush()
120 sys.stderr.flush()
121 start = time.monotonic()
122 result = subprocess.run(args)
123 elapsed = time.monotonic() - start
124 return result.returncode, elapsed
127def _run_docker_with_typeahead(args: list[str], typeahead: bytes) -> tuple[int, float]:
128 """Run docker exec under a PTY, pre-injecting typeahead bytes.
130 Used when the user typed during the slow startup phase: those bytes need to
131 be replayed into the inner process exactly as if they had been typed once
132 the shell attached. Standard subprocess inheritance can't do that because
133 we need to inject our own bytes ahead of the live stdin stream.
134 """
135 import pty
136 import select
137 import signal
138 import termios
139 import tty
141 logger.debug("run+pty: %s", " ".join(args))
142 sys.stdout.flush()
143 sys.stderr.flush()
145 master_fd, slave_fd = pty.openpty()
146 stdin_fd = sys.stdin.fileno()
147 stdout_fd = sys.stdout.fileno()
148 original_termios = termios.tcgetattr(stdin_fd)
150 # Match the PTY size to the host terminal so curses-based tools render correctly.
151 try:
152 import fcntl
154 size = fcntl.ioctl(stdout_fd, termios.TIOCGWINSZ, b"\x00" * 8)
155 fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, size)
156 except OSError:
157 pass
159 def _on_winch(_signum: int, _frame: object) -> None:
160 try:
161 import fcntl
163 size = fcntl.ioctl(stdout_fd, termios.TIOCGWINSZ, b"\x00" * 8)
164 fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, size)
165 except OSError:
166 pass
168 previous_winch = signal.signal(signal.SIGWINCH, _on_winch)
170 start = time.monotonic()
171 proc = subprocess.Popen(
172 args,
173 stdin=slave_fd,
174 stdout=slave_fd,
175 stderr=slave_fd,
176 close_fds=True,
177 )
178 os.close(slave_fd)
180 try:
181 tty.setraw(stdin_fd)
182 if typeahead:
183 os.write(master_fd, typeahead)
185 while True:
186 if proc.poll() is not None:
187 # Drain any final output.
188 try:
189 while True:
190 chunk = os.read(master_fd, 4096)
191 if not chunk:
192 break
193 os.write(stdout_fd, chunk)
194 except OSError:
195 pass
196 break
197 try:
198 ready, _, _ = select.select([master_fd, stdin_fd], [], [], 0.1)
199 except (OSError, ValueError):
200 break
201 if master_fd in ready:
202 try:
203 chunk = os.read(master_fd, 4096)
204 except OSError:
205 chunk = b""
206 if not chunk:
207 break
208 os.write(stdout_fd, chunk)
209 if stdin_fd in ready:
210 try:
211 chunk = os.read(stdin_fd, 4096)
212 except OSError:
213 chunk = b""
214 if chunk:
215 os.write(master_fd, chunk)
216 finally:
217 termios.tcsetattr(stdin_fd, termios.TCSADRAIN, original_termios)
218 signal.signal(signal.SIGWINCH, previous_winch)
219 try:
220 os.close(master_fd)
221 except OSError:
222 pass
223 if proc.poll() is None:
224 proc.wait()
226 elapsed = time.monotonic() - start
227 return proc.returncode, elapsed
230class ContainerManager:
231 """Manages Docker containers for ai-shell.
233 Handles the dev container (per-project) and LLM stack (host-level singletons).
234 """
236 def __init__(self, config: AiShellConfig) -> None:
237 self.config = config
238 try:
239 self.client = docker.from_env() # type: ignore[attr-defined]
240 self.client.ping()
241 except docker.errors.DockerException as e:
242 raise DockerNotAvailableError(
243 f"Docker is not available. Is the Docker daemon running?\n Error: {e}"
244 ) from e
246 # =========================================================================
247 # Dev container (per-project)
248 # =========================================================================
250 def resolve_dev_container(self) -> tuple[str, Container | None]:
251 """Resolve the dev container, checking both current and legacy names.
253 Returns ``(name, container)`` where *container* is ``None`` when no
254 matching container exists. When no container is found under either
255 name, the current hash-based name is returned so callers can use it
256 for creation.
257 """
258 name = dev_container_name(self.config.project_name, self.config.project_dir)
259 container = self._get_container(name)
260 if container is not None:
261 return name, container
263 legacy_name = dev_container_name(self.config.project_name)
264 legacy_container = self._get_container(legacy_name)
265 if legacy_container is not None and self._container_matches_project(
266 legacy_container, self.config.project_dir
267 ):
268 return legacy_name, legacy_container
270 return name, None
272 def ensure_dev_container(self) -> str:
273 """Get or create the dev container for the current project.
275 If the container exists but is stopped, it is started.
276 If it doesn't exist, it is created with the full configuration.
277 If using the ``latest`` tag and a newer image is available, the
278 existing container is replaced automatically.
280 Returns the container name.
281 """
282 name, container = self.resolve_dev_container()
284 if container is not None:
285 if self._recreate_if_image_stale(container, name):
286 # Container was removed; fall through to create a new one.
287 container = None
288 else:
289 if container.status != "running":
290 logger.info("Starting existing container: %s", name)
291 try:
292 container.start()
293 except APIError as e:
294 # A port-binding failure means the container's baked
295 # host ports conflict with the current host state
296 # (another container, a live listener, or a Windows
297 # winnat excluded range). Retrying the same bindings
298 # can never succeed — recreate so ports re-resolve
299 # against what the host will actually accept.
300 msg = str(e).lower()
301 if not any(s in msg for s in _PORT_BIND_ERRORS):
302 raise
303 logger.warning(
304 "Container %s failed to start due to a host port "
305 "conflict; recreating with fresh port assignments. "
306 "Error: %s",
307 name,
308 e,
309 )
310 container.remove(force=True)
311 container = None
312 if container is not None:
313 return name
315 logger.info("Creating dev container: %s", name)
316 self._pull_image_if_needed(self.config.full_image)
317 self._create_dev_container(name)
318 return name
320 def _create_dev_container(self, name: str) -> Container:
321 """Create the dev container with all docker-compose config."""
322 mounts = build_dev_mounts(
323 self.config.project_dir,
324 self.config.project_name,
325 extra_node_modules_paths=self.config.node_modules_paths,
326 isolate_home_paths=set(self.config.isolate_home_paths),
327 )
328 environment = build_dev_environment(
329 self.config.extra_env,
330 self.config.project_dir,
331 project_name=self.config.project_name,
332 aws_profile=self.config.ai_profile,
333 aws_region=self.config.aws_region,
334 )
336 port_map = project_dev_port_map(
337 self.config.project_dir,
338 self.config.dev_ports,
339 self.config.project_name,
340 is_available=_host_port_available,
341 )
343 # MOTD metadata — injected at creation time so the in-container
344 # motd.sh script can display version, container identity, and port
345 # mappings without querying Docker from inside the container.
346 from ai_shell import __version__
348 environment["AUGINT_SHELL_VERSION"] = __version__
349 environment["AUGINT_CONTAINER_NAME"] = name
350 environment["AUGINT_PROJECT_NAME"] = self.config.project_name
351 environment["AUGINT_DEV_PORTS"] = ",".join(
352 f"{port}:{host_port}" for port, host_port in sorted(port_map.items())
353 )
354 environment["AUGINT_LLM_PORTS"] = ",".join(
355 [
356 f"ollama:{self.config.ollama_port}",
357 f"webui:{self.config.webui_port}",
358 f"kokoro:{self.config.kokoro_port}",
359 f"whisper:{self.config.whisper_port}",
360 f"n8n:{self.config.n8n_port}",
361 f"comfyui:{self.config.comfyui_port}",
362 ]
363 )
365 # On hosts without a Unix socket (Windows), point the container's
366 # docker CLI at the daemon's tcp endpoint so Docker works out of the
367 # box instead of failing on the nonexistent default socket.
368 docker_host = docker_tcp_fallback_host()
369 if docker_host is not None:
370 environment["DOCKER_HOST"] = docker_host
372 # Add any extra volumes from config
373 for vol_spec in self.config.extra_volumes:
374 parts = vol_spec.split(":")
375 if len(parts) >= 2:
376 source, target = parts[0], parts[1]
377 read_only = len(parts) > 2 and parts[2] == "ro"
378 mounts.append(
379 Mount(
380 target=target,
381 source=source,
382 type="bind",
383 read_only=read_only,
384 )
385 )
387 container: Container = self.client.containers.run(
388 image=self.config.full_image,
389 name=name,
390 mounts=mounts,
391 environment=environment,
392 working_dir=f"/root/projects/{self.config.project_name}",
393 command="tail -f /dev/null",
394 stdin_open=True,
395 tty=True,
396 shm_size=SHM_SIZE,
397 init=True,
398 extra_hosts={"host.docker.internal": "host-gateway"},
399 ports={
400 f"{port}/tcp": (
401 ("0.0.0.0", host_port) # nosec B104
402 if self.config.project_dir
403 else None
404 )
405 for port, host_port in port_map.items()
406 },
407 detach=True,
408 )
409 logger.info("Container created: %s", name)
411 subprocess.run(
412 [
413 "docker",
414 "exec",
415 name,
416 "sh",
417 "-c",
418 "echo 'export PATH=\"/root/.local/bin:/root/.opencode/bin:$PATH\"'"
419 " > /etc/profile.d/ai-shell-path.sh",
420 ],
421 check=False,
422 capture_output=True,
423 )
425 try:
426 self._write_container_context(container, name, port_map, mounts, docker_host)
427 except (APIError, OSError) as e:
428 logger.warning("Failed to write container context files: %s", e)
430 return container
432 def _write_container_context(
433 self,
434 container: Container,
435 name: str,
436 port_map: dict[int, int],
437 mounts: list[Mount],
438 docker_host: str | None,
439 ) -> None:
440 """Write container-local CLAUDE.md/AGENTS.md above the project mount.
442 Coding agents (Claude Code, codex, opencode) automatically load these
443 ancestor files into context, so environment facts — the port map,
444 Docker access, host gateway, bind mounts — are always visible to them
445 without relying on the (human-only) MOTD. ``/root/projects`` itself is
446 container-local, so the files never appear in the host project or its
447 repo.
448 """
449 import io
450 import platform
451 import tarfile
452 from importlib import resources
454 from ai_shell import __version__
456 template = (
457 resources.files("ai_shell.templates")
458 .joinpath("container", "context.md")
459 .read_text(encoding="utf-8")
460 )
462 port_rows = "\n".join(
463 f"| {port} | http://localhost:{host_port} |"
464 for port, host_port in sorted(port_map.items())
465 )
467 if docker_host is not None:
468 docker_section = (
469 f"The host Docker daemon is available; `DOCKER_HOST={docker_host}` is preset "
470 "in the environment, so `docker` commands work as-is. Containers you list or "
471 "start are siblings of this container, not children: volume paths you pass to "
472 "`docker run` are host paths, and published ports appear on the host."
473 )
474 elif any(m.get("Target") == "/var/run/docker.sock" for m in mounts):
475 docker_section = (
476 "The host Docker daemon is available via the mounted `/var/run/docker.sock`. "
477 "Containers you list or start are siblings of this container, not children: "
478 "volume paths you pass to `docker run` are host paths, and published ports "
479 "appear on the host."
480 )
481 else:
482 docker_section = "No Docker access is configured in this container."
484 llm_rows = "\n".join(
485 f"- {svc}: `host.docker.internal:{port}`"
486 for svc, port in (
487 ("ollama", self.config.ollama_port),
488 ("open-webui", self.config.webui_port),
489 ("kokoro TTS", self.config.kokoro_port),
490 ("whisper STT", self.config.whisper_port),
491 ("n8n", self.config.n8n_port),
492 ("comfyui", self.config.comfyui_port),
493 )
494 )
496 mount_rows = "\n".join(
497 f"- `{m.get('Target')}`{' (read-only)' if m.get('ReadOnly') else ''}"
498 for m in mounts
499 if m.get("Type") == "bind"
500 )
502 content = template.format(
503 version=__version__,
504 container_name=name,
505 project_name=self.config.project_name,
506 host_os=platform.system(),
507 port_rows=port_rows,
508 docker_section=docker_section,
509 llm_rows=llm_rows,
510 mount_rows=mount_rows,
511 )
513 data = content.encode("utf-8")
514 buf = io.BytesIO()
515 with tarfile.open(fileobj=buf, mode="w") as tar:
516 for fname in ("CLAUDE.md", "AGENTS.md"):
517 info = tarfile.TarInfo(name=fname)
518 info.size = len(data)
519 tar.addfile(info, io.BytesIO(data))
520 buf.seek(0)
521 container.put_archive("/root/projects", buf.getvalue())
523 def exec_interactive(
524 self,
525 container_name: str,
526 command: list[str],
527 extra_env: dict[str, str] | None = None,
528 workdir: str | None = None,
529 typeahead: bytes = b"",
530 ) -> NoReturn:
531 """Execute an interactive command in a container.
533 Uses subprocess.run for cross-platform TTY compatibility.
534 Detects whether stdin is a TTY to decide on -i/-t flags.
535 If *workdir* is given it is passed as ``-w`` to ``docker exec``.
536 When *typeahead* is non-empty and stdin is a TTY, runs the docker exec
537 under a PTY so the captured bytes can be replayed into the inner process.
538 """
539 args = ["docker", "exec"]
541 if sys.stdin.isatty():
542 args.append("-it")
544 if workdir:
545 args.extend(["-w", workdir])
547 if extra_env:
548 for key, value in extra_env.items():
549 args.extend(["-e", f"{key}={value}"])
551 args.append(container_name)
552 args.extend(command)
554 if typeahead and sys.platform != "win32" and sys.stdin.isatty():
555 exit_code, _ = _run_docker_with_typeahead(args, typeahead)
556 sys.exit(exit_code)
558 _exec_docker(args)
560 def run_interactive(
561 self,
562 container_name: str,
563 command: list[str],
564 extra_env: dict[str, str] | None = None,
565 workdir: str | None = None,
566 typeahead: bytes = b"",
567 ) -> tuple[int, float]:
568 """Execute an interactive command, returning (exit_code, elapsed_seconds).
570 Same as exec_interactive but does not call sys.exit().
571 Used for retry logic (e.g., claude -c fallback).
572 If *workdir* is given it is passed as ``-w`` to ``docker exec``.
573 When *typeahead* is non-empty and stdin is a TTY, runs the docker exec
574 under a PTY so the captured bytes can be replayed into the inner process.
575 """
576 args = ["docker", "exec"]
578 if sys.stdin.isatty():
579 args.append("-it")
581 if workdir:
582 args.extend(["-w", workdir])
584 if extra_env:
585 for key, value in extra_env.items():
586 args.extend(["-e", f"{key}={value}"])
588 args.append(container_name)
589 args.extend(command)
591 if typeahead and sys.platform != "win32" and sys.stdin.isatty():
592 return _run_docker_with_typeahead(args, typeahead)
594 return _run_docker(args)
596 def exec_detached(
597 self,
598 container_name: str,
599 command: list[str],
600 extra_env: dict[str, str] | None = None,
601 workdir: str | None = None,
602 ) -> subprocess.CompletedProcess[bytes]:
603 """Run a command in a container without waiting (docker exec -d)."""
604 args = ["docker", "exec", "-d"]
605 if workdir:
606 args.extend(["-w", workdir])
607 if extra_env:
608 for key, value in extra_env.items():
609 args.extend(["-e", f"{key}={value}"])
610 args.append(container_name)
611 args.extend(command)
612 logger.debug("exec-detached: %s", " ".join(args))
613 return subprocess.run(args, check=True)
615 # =========================================================================
616 # LLM stack (host-level singletons)
617 # =========================================================================
619 @staticmethod
620 def _container_has_gpu(container: Container) -> bool:
621 """Return True if *container* was created with a GPU device request."""
622 device_requests = container.attrs.get("HostConfig", {}).get("DeviceRequests") or []
623 return any(
624 "gpu" in (cap for caps in (dr.get("Capabilities") or []) for cap in caps)
625 for dr in device_requests
626 )
628 def _recreate_if_gpu_changed(
629 self, container: Container, gpu_available: bool, label: str
630 ) -> bool:
631 """Remove *container* if its GPU state doesn't match *gpu_available*.
633 Returns True if the container was removed (caller must recreate).
634 """
635 has_gpu = self._container_has_gpu(container)
636 if has_gpu == gpu_available:
637 return False
638 want = "GPU" if gpu_available else "CPU-only"
639 had = "GPU" if has_gpu else "CPU-only"
640 logger.warning(
641 "%s container has %s but system now offers %s — recreating",
642 label,
643 had,
644 want,
645 )
646 container.remove(force=True)
647 return True
649 def _recreate_if_image_stale(self, container: Container, name: str) -> bool:
650 """Pull the latest image and recreate the container if it is outdated.
652 Only acts when the configured tag is ``latest``. For pinned
653 version tags the image is immutable so staleness doesn't apply.
655 The pull is skipped when ``config.image_pull_cache_ttl`` seconds have
656 not yet elapsed since the last successful pull (default 15 min) so
657 normal launches don't pay the network round-trip.
659 Returns True if the container was removed (caller must recreate).
660 """
661 from ai_shell.cache import is_fresh, mark_fresh
663 tag = self.config.image_tag
664 if tag != "latest":
665 return False
667 image_ref = self.config.full_image
668 if is_fresh("image-pull", image_ref, self.config.image_pull_cache_ttl):
669 logger.debug("Image-pull cache fresh for %s — skipping pull", image_ref)
670 return False
672 try:
673 pulled = self.client.images.pull(*image_ref.rsplit(":", 1))
674 except APIError:
675 logger.debug("Could not pull %s — skipping staleness check", image_ref)
676 return False
678 mark_fresh("image-pull", image_ref)
679 self._warn_if_image_below_minimum(pulled)
681 container_image_id = container.image.id
682 pulled_image_id = pulled.id
684 if container_image_id == pulled_image_id:
685 return False
687 logger.warning(
688 "Dev container %s uses an outdated image — recreating with %s",
689 name,
690 image_ref,
691 )
692 container.remove(force=True)
693 return True
695 @staticmethod
696 def _warn_if_image_below_minimum(image: Image) -> None:
697 """Log a warning if the pulled image version is below the CLI version."""
698 from ai_shell import __version__
700 labels = image.labels or {}
701 image_version_str = labels.get("org.opencontainers.image.version", "")
702 if not image_version_str:
703 return
705 def _parse_version(v: str) -> tuple[int, ...] | None:
706 try:
707 return tuple(int(x) for x in v.split("."))
708 except (ValueError, AttributeError):
709 return None
711 image_ver = _parse_version(image_version_str)
712 cli_ver = _parse_version(__version__)
713 if image_ver is None or cli_ver is None:
714 return
716 if image_ver < cli_ver:
717 logger.warning(
718 "Container image version %s is older than CLI version %s "
719 "— rebuild the image to get the latest tools",
720 image_version_str,
721 __version__,
722 )
724 def _ensure_llm_network(self) -> str:
725 """Get or create the shared Docker network for the LLM stack."""
726 try:
727 self.client.networks.get(LLM_NETWORK)
728 except NotFound:
729 logger.info("Creating LLM network: %s", LLM_NETWORK)
730 self.client.networks.create(LLM_NETWORK, driver="bridge")
731 return LLM_NETWORK
733 def ensure_ollama(self) -> str:
734 """Get or create the Ollama container with GPU auto-detection.
736 Recreates the container if GPU availability has changed since creation.
737 """
738 gpu_available = detect_gpu()
739 container = self._get_container(OLLAMA_CONTAINER)
741 if container is not None:
742 if self._recreate_if_gpu_changed(container, gpu_available, "Ollama"):
743 pass # fall through to creation
744 else:
745 if container.status != "running":
746 logger.info("Starting existing Ollama container")
747 container.start()
748 return OLLAMA_CONTAINER
750 logger.info("Creating Ollama container")
751 self._pull_image_if_needed(OLLAMA_IMAGE)
752 network_name = self._ensure_llm_network()
753 device_requests = None
754 env: dict[str, str] = {
755 "OLLAMA_CONTEXT_LENGTH": str(self.config.context_size),
756 # Flash attention trims activation memory at no quality cost and
757 # is a prerequisite for KV cache quantization.
758 "OLLAMA_FLASH_ATTENTION": "1",
759 # Quantize the KV cache to 8-bit; near-lossless, halves cache
760 # size. Combined with Ollama's dynamic GPU/CPU offload, this
761 # buys significant headroom for large models without hard-pinning
762 # a num_gpu value in any Modelfile.
763 "OLLAMA_KV_CACHE_TYPE": "q8_0",
764 }
765 if gpu_available:
766 device_requests = [DeviceRequest(count=1, capabilities=[["gpu"]])]
767 vram = get_vram_info()
768 if vram:
769 overhead = vram["used"] + OLLAMA_VRAM_BUFFER_BYTES
770 env["OLLAMA_GPU_OVERHEAD"] = str(overhead)
771 logger.info(
772 "VRAM: %.1f GiB total, %.1f GiB free. Reserving %.1f GiB overhead for Ollama.",
773 vram["total"] / 1024**3,
774 vram["free"] / 1024**3,
775 overhead / 1024**3,
776 )
777 else:
778 logger.info("GPU detected - Ollama will use NVIDIA GPU")
779 else:
780 logger.warning("No GPU detected - Ollama will run on CPU (slower inference)")
782 kwargs: dict = {
783 "image": OLLAMA_IMAGE,
784 "name": OLLAMA_CONTAINER,
785 "ports": {"11434/tcp": ("0.0.0.0", self.config.ollama_port)}, # nosec B104
786 "mounts": [
787 Mount(
788 target="/root/.ollama",
789 source=OLLAMA_DATA_VOLUME,
790 type="volume",
791 )
792 ],
793 "restart_policy": {"Name": "unless-stopped"},
794 "detach": True,
795 "network": network_name,
796 "cpu_shares": OLLAMA_CPU_SHARES,
797 }
799 if device_requests:
800 kwargs["device_requests"] = device_requests
801 if env:
802 kwargs["environment"] = env
804 self.client.containers.run(**kwargs)
805 logger.info("Ollama container created on port %d", self.config.ollama_port)
806 return OLLAMA_CONTAINER
808 def ensure_webui(
809 self,
810 voice_enabled: bool = False,
811 whisper_enabled: bool = False,
812 image_gen_enabled: bool = False,
813 env_file: Path | None = None,
814 ) -> str:
815 """Get or create the Open WebUI container.
817 When *voice_enabled* is True, pre-wires Kokoro TTS as the speech
818 backend. When *whisper_enabled* is True, pre-wires Speaches STT
819 as the transcription backend. When *image_gen_enabled* is True,
820 pre-wires ComfyUI as the image-generation backend. API keys from
821 *env_file* (or host environment) are passed through so WebUI can
822 offer external LLM providers alongside Ollama.
823 """
824 container = self._get_container(WEBUI_CONTAINER)
826 if container is not None:
827 if container.status != "running":
828 logger.info("Starting existing WebUI container")
829 container.start()
830 return WEBUI_CONTAINER
832 logger.info("Creating Open WebUI container")
833 self._pull_image_if_needed(WEBUI_IMAGE)
834 network_name = self._ensure_llm_network()
836 from dotenv import dotenv_values
838 from ai_shell.defaults import _resolve_env
840 dotenv: dict[str, str | None] = {}
841 if env_file is not None:
842 dotenv = dotenv_values(env_file)
844 environment: dict[str, str] = {
845 "OLLAMA_BASE_URL": f"http://{OLLAMA_CONTAINER}:11434",
846 "WEBUI_AUTH": "false",
847 # DEFAULT_MODELS is a PersistentConfig: env seeds the DB on first
848 # boot and UI edits win after that. Point new chats at the
849 # primary chat slot; users can pick the secondary (uncensored)
850 # from the model dropdown.
851 "DEFAULT_MODELS": self.config.primary_chat_model,
852 }
853 if voice_enabled:
854 environment.update(
855 {
856 "AUDIO_TTS_ENGINE": "openai",
857 "AUDIO_TTS_OPENAI_API_BASE_URL": f"http://{KOKORO_CONTAINER}:8880/v1",
858 "AUDIO_TTS_OPENAI_API_KEY": "dummy",
859 "AUDIO_TTS_MODEL": "kokoro",
860 "AUDIO_TTS_VOICE": self.config.kokoro_voice,
861 }
862 )
863 if whisper_enabled:
864 environment.update(
865 {
866 "AUDIO_STT_ENGINE": "openai",
867 "AUDIO_STT_OPENAI_API_BASE_URL": f"http://{WHISPER_CONTAINER}:8000/v1",
868 "AUDIO_STT_OPENAI_API_KEY": "dummy",
869 "AUDIO_STT_MODEL": self.config.whisper_model,
870 }
871 )
872 if image_gen_enabled:
873 # ENABLE_IMAGE_GENERATION + IMAGE_GENERATION_ENGINE=comfyui are
874 # PersistentConfig keys; they seed the DB on first boot. Users can
875 # later override the default workflow or model via Settings > Images.
876 environment.update(
877 {
878 "ENABLE_IMAGE_GENERATION": "true",
879 "IMAGE_GENERATION_ENGINE": "comfyui",
880 "COMFYUI_BASE_URL": f"http://{COMFYUI_CONTAINER}:8188",
881 "IMAGE_SIZE": "1024x1024",
882 "IMAGE_STEPS": "25",
883 }
884 )
886 # External LLM providers — pass through API keys when available.
887 openai_urls: list[str] = []
888 openai_keys: list[str] = []
890 openai_key = _resolve_env(dotenv, "OPENAI_API_KEY")
891 if openai_key:
892 openai_urls.append("https://api.openai.com/v1")
893 openai_keys.append(openai_key)
895 gh_token = _resolve_env(dotenv, "GH_TOKEN")
896 if gh_token:
897 openai_urls.append("https://models.inference.ai.azure.com/v1")
898 openai_keys.append(gh_token)
900 if openai_urls:
901 environment["OPENAI_API_BASE_URLS"] = ";".join(openai_urls)
902 environment["OPENAI_API_KEYS"] = ";".join(openai_keys)
904 anthropic_key = _resolve_env(dotenv, "ANTHROPIC_API_KEY")
905 if anthropic_key:
906 environment["ANTHROPIC_API_KEY"] = anthropic_key
908 self.client.containers.run(
909 image=WEBUI_IMAGE,
910 name=WEBUI_CONTAINER,
911 ports={"8080/tcp": ("0.0.0.0", self.config.webui_port)}, # nosec B104
912 environment=environment,
913 mounts=[
914 Mount(
915 target="/app/backend/data",
916 source=WEBUI_DATA_VOLUME,
917 type="volume",
918 )
919 ],
920 restart_policy={"Name": "unless-stopped"},
921 detach=True,
922 network=network_name,
923 )
925 logger.info("Open WebUI container created on port %d", self.config.webui_port)
926 return WEBUI_CONTAINER
928 def ensure_kokoro(self) -> str:
929 """Get or create the Kokoro-FastAPI (local TTS) container.
931 Exposes an OpenAI-compatible ``/v1/audio/speech`` endpoint on the
932 configured port. GPU image is used when NVIDIA is detected;
933 otherwise the CPU image. Recreates if GPU availability has changed.
934 """
935 gpu_available = detect_gpu()
936 container = self._get_container(KOKORO_CONTAINER)
937 if container is not None:
938 if self._recreate_if_gpu_changed(container, gpu_available, "Kokoro"):
939 pass # fall through to creation
940 else:
941 if container.status != "running":
942 logger.info("Starting existing Kokoro container")
943 container.start()
944 return KOKORO_CONTAINER
945 image = KOKORO_IMAGE_GPU if gpu_available else KOKORO_IMAGE_CPU
946 logger.info("Creating Kokoro container (%s)", "GPU" if gpu_available else "CPU")
947 self._pull_image_if_needed(image)
948 network_name = self._ensure_llm_network()
950 kwargs: dict = {
951 "image": image,
952 "name": KOKORO_CONTAINER,
953 "ports": {"8880/tcp": ("0.0.0.0", self.config.kokoro_port)}, # nosec B104
954 "restart_policy": {"Name": "unless-stopped"},
955 "detach": True,
956 "network": network_name,
957 }
958 if gpu_available:
959 kwargs["device_requests"] = [DeviceRequest(count=1, capabilities=[["gpu"]])]
961 self.client.containers.run(**kwargs)
962 logger.info("Kokoro container created on port %d", self.config.kokoro_port)
963 return KOKORO_CONTAINER
965 def ensure_whisper(self) -> str:
966 """Get or create the Speaches (local STT) container.
968 Exposes an OpenAI-compatible ``/v1/audio/transcriptions`` endpoint on
969 the configured port. GPU image is used when NVIDIA is detected;
970 otherwise the CPU image. Recreates if GPU availability has changed.
971 The Hugging Face model cache persists in a named volume (Speaches runs
972 as ``ubuntu`` UID 1000 — a named volume inherits the correct ownership;
973 bind-mounting a host dir here would require an explicit chown).
974 """
975 gpu_available = detect_gpu()
976 container = self._get_container(WHISPER_CONTAINER)
977 if container is not None:
978 if self._recreate_if_gpu_changed(container, gpu_available, "Whisper"):
979 pass # fall through to creation
980 else:
981 if container.status != "running":
982 logger.info("Starting existing Whisper container")
983 container.start()
984 return WHISPER_CONTAINER
985 image = WHISPER_IMAGE_GPU if gpu_available else WHISPER_IMAGE_CPU
986 logger.info("Creating Whisper container (%s)", "GPU" if gpu_available else "CPU")
987 self._pull_image_if_needed(image)
988 network_name = self._ensure_llm_network()
990 # PRELOAD_MODELS uses pydantic-settings JSON array syntax, not CSV.
991 # json.dumps guarantees correct escaping for any model id.
992 environment = {
993 "WHISPER__INFERENCE_DEVICE": "auto",
994 "PRELOAD_MODELS": json.dumps([self.config.whisper_model]),
995 }
997 kwargs: dict = {
998 "image": image,
999 "name": WHISPER_CONTAINER,
1000 "ports": {"8000/tcp": ("0.0.0.0", self.config.whisper_port)}, # nosec B104
1001 "environment": environment,
1002 "mounts": [
1003 Mount(
1004 target="/home/ubuntu/.cache/huggingface/hub",
1005 source=WHISPER_DATA_VOLUME,
1006 type="volume",
1007 )
1008 ],
1009 "restart_policy": {"Name": "unless-stopped"},
1010 "detach": True,
1011 "network": network_name,
1012 }
1013 if gpu_available:
1014 kwargs["device_requests"] = [DeviceRequest(count=1, capabilities=[["gpu"]])]
1016 self.client.containers.run(**kwargs)
1017 logger.info("Whisper container created on port %d", self.config.whisper_port)
1018 return WHISPER_CONTAINER
1020 def ensure_voice_agent(self) -> str:
1021 """Get or create the voice-agent container.
1023 The image is **built locally** from ``docker/voice-agent/`` on first
1024 call because Phase 2 doesn't publish it. Later phases may switch to
1025 a pulled tag. Phase 2 scope: no filesystem / auth / provider-key
1026 mounts — those land in Phases 3-4. A named volume is mounted at
1027 ``/data`` so the Phase 5 sqlite file will survive container
1028 recreations from the start. Service-discovery URLs for sibling
1029 stacks (ComfyUI, etc.) are exported so later phases can dispatch
1030 tool calls without re-reading config.
1031 """
1032 container = self._get_container(VOICE_AGENT_CONTAINER)
1033 if container is not None:
1034 if container.status != "running":
1035 logger.info("Starting existing voice-agent container")
1036 container.start()
1037 return VOICE_AGENT_CONTAINER
1039 logger.info("Creating voice-agent container")
1040 self._build_image_if_needed(VOICE_AGENT_IMAGE, self._voice_agent_build_context())
1041 network_name = self._ensure_llm_network()
1043 kwargs: dict = {
1044 "image": VOICE_AGENT_IMAGE,
1045 "name": VOICE_AGENT_CONTAINER,
1046 "ports": {"8000/tcp": ("0.0.0.0", self.config.voice_agent.port)}, # nosec B104
1047 "environment": {
1048 "COMFYUI_BASE_URL": f"http://{COMFYUI_CONTAINER}:8188",
1049 },
1050 "mounts": [
1051 Mount(
1052 target="/data",
1053 source=VOICE_AGENT_DATA_VOLUME,
1054 type="volume",
1055 )
1056 ],
1057 "restart_policy": {"Name": "unless-stopped"},
1058 "detach": True,
1059 "network": network_name,
1060 }
1062 self.client.containers.run(**kwargs)
1063 logger.info("voice-agent container created on port %d", self.config.voice_agent.port)
1064 return VOICE_AGENT_CONTAINER
1066 @staticmethod
1067 def _voice_agent_build_context() -> str:
1068 """Return the path to the voice-agent Dockerfile build context."""
1069 # Package layout: src/ai_shell/container.py — the Dockerfile lives in
1070 # <repo root>/docker/voice-agent. When installed as a wheel the user
1071 # is expected to have the source checked out; voice-agent is
1072 # experimental and locally built for now.
1073 here = Path(__file__).resolve()
1074 return str(here.parents[2] / "docker" / "voice-agent")
1076 def ensure_comfyui(self, env_file: Path | None = None) -> str:
1077 """Get or create the ComfyUI image-generation container.
1079 GPU-required (the ai-dock image has no CPU variant). On first
1080 boot, ai-dock runs the bind-mounted provisioning script which
1081 downloads SDXL unconditionally and FLUX.1-dev when ``HF_TOKEN``
1082 is present in *env_file* or the host environment. Model files
1083 persist in a named volume so subsequent containers start without
1084 re-downloading ~25 GB. Recreates the container when GPU
1085 availability toggles, matching the Kokoro/Whisper pattern.
1086 """
1087 from dotenv import dotenv_values
1089 gpu_available = detect_gpu()
1090 container = self._get_container(COMFYUI_CONTAINER)
1091 if container is not None:
1092 if self._recreate_if_gpu_changed(container, gpu_available, "ComfyUI"):
1093 pass # fall through to creation
1094 else:
1095 if container.status != "running":
1096 logger.info("Starting existing ComfyUI container")
1097 container.start()
1098 return COMFYUI_CONTAINER
1100 if not gpu_available:
1101 raise GpuRequiredError("ComfyUI")
1103 logger.info("Creating ComfyUI container")
1104 self._pull_image_if_needed(COMFYUI_IMAGE)
1105 network_name = self._ensure_llm_network()
1107 dotenv: dict[str, str | None] = {}
1108 if env_file is not None:
1109 dotenv = dotenv_values(env_file)
1111 hf_token = _resolve_env(dotenv, "HF_TOKEN") or _resolve_env(
1112 dotenv, "HUGGING_FACE_HUB_TOKEN"
1113 )
1115 environment: dict[str, str] = {
1116 # --lowvram keeps FLUX's 12B weights offloading through CPU RAM so
1117 # Ollama can keep a chat model resident on the same GPU. --listen
1118 # binds on 0.0.0.0 so other containers on the LLM network can reach
1119 # the API.
1120 "CLI_ARGS": "--lowvram --listen 0.0.0.0",
1121 # ai-dock runs PROVISIONING_SCRIPT once per workspace. We bind-mount
1122 # our own script at a known path rather than hosting one remotely.
1123 "PROVISIONING_SCRIPT": "/opt/augint/provision.sh",
1124 # Local-dev deployment: disable ai-dock's Caddy basic-auth layer so
1125 # the port-8188 service is reachable without redirect-to-/login.
1126 # Matches WEBUI_AUTH=false on Open WebUI.
1127 "WEB_ENABLE_AUTH": "false",
1128 "CF_QUICK_TUNNELS": "false",
1129 }
1130 if hf_token:
1131 # ai-dock's provisioner reads HF_TOKEN; upstream HF libs read
1132 # HUGGING_FACE_HUB_TOKEN. Set both to avoid edge cases.
1133 environment["HF_TOKEN"] = hf_token
1134 environment["HUGGING_FACE_HUB_TOKEN"] = hf_token
1136 provision_path = Path(__file__).parent / "assets" / "comfyui" / "provision.sh"
1137 mounts: list[Mount] = [
1138 Mount(
1139 target="/opt/ComfyUI/models",
1140 source=COMFYUI_DATA_VOLUME,
1141 type="volume",
1142 )
1143 ]
1144 if provision_path.is_file():
1145 mounts.append(
1146 Mount(
1147 target="/opt/augint/provision.sh",
1148 source=str(provision_path),
1149 type="bind",
1150 read_only=True,
1151 )
1152 )
1154 kwargs: dict = {
1155 "image": COMFYUI_IMAGE,
1156 "name": COMFYUI_CONTAINER,
1157 "ports": {"8188/tcp": ("0.0.0.0", self.config.comfyui_port)}, # nosec B104
1158 "environment": environment,
1159 "mounts": mounts,
1160 "restart_policy": {"Name": "unless-stopped"},
1161 "detach": True,
1162 "network": network_name,
1163 "device_requests": [DeviceRequest(count=1, capabilities=[["gpu"]])],
1164 }
1166 self.client.containers.run(**kwargs)
1167 logger.info("ComfyUI container created on port %d", self.config.comfyui_port)
1168 return COMFYUI_CONTAINER
1170 def ensure_n8n(self, env_file: Path | None = None) -> str:
1171 """Get or create the n8n workflow automation container.
1173 Pre-wires service discovery URLs (Ollama, Kokoro, Speaches,
1174 Voice Agent, WebUI) and passes through API keys (OpenAI,
1175 Anthropic, GitHub, AWS) from *env_file* or the host environment.
1176 Credential directories (``~/.aws``, ``~/.config/gh``) are mounted
1177 read-only so n8n's AWS and GitHub nodes authenticate automatically.
1178 """
1179 container = self._get_container(N8N_CONTAINER)
1181 if container is not None:
1182 if container.status != "running":
1183 logger.info("Starting existing n8n container")
1184 container.start()
1185 return N8N_CONTAINER
1187 logger.info("Creating n8n container")
1188 self._pull_image_if_needed(N8N_IMAGE)
1189 network_name = self._ensure_llm_network()
1191 environment = build_n8n_environment(
1192 env_file=env_file,
1193 aws_profile=self.config.ai_profile,
1194 aws_region=self.config.aws_region,
1195 )
1197 workflow_dir = Path(__file__).parent / "templates" / "n8n" / "workflows"
1198 mounts = build_n8n_mounts(
1199 workflow_dir=workflow_dir if workflow_dir.is_dir() else None,
1200 )
1202 created = True
1203 self.client.containers.run(
1204 image=N8N_IMAGE,
1205 name=N8N_CONTAINER,
1206 ports={"5678/tcp": ("0.0.0.0", self.config.n8n_port)}, # nosec B104
1207 environment=environment,
1208 mounts=mounts,
1209 restart_policy={"Name": "unless-stopped"},
1210 detach=True,
1211 network=network_name,
1212 )
1214 logger.info("n8n container created on port %d", self.config.n8n_port)
1216 if created and workflow_dir.is_dir():
1217 self._seed_n8n_workflows()
1219 return N8N_CONTAINER
1221 def _seed_n8n_workflows(self) -> None:
1222 """Import starter workflow templates into a freshly-created n8n.
1224 Workflows are mounted at ``/workflows`` inside the container. We
1225 wait for n8n to be ready, then use ``n8n import:workflow`` to load
1226 each JSON file. Failures are logged but never fatal.
1227 """
1228 container = self._get_container(N8N_CONTAINER)
1229 if container is None:
1230 return
1232 # Wait for n8n to become ready (max ~30 s).
1233 for _i in range(15):
1234 try:
1235 exit_code, _ = container.exec_run("curl -sf http://localhost:5678/healthz")
1236 if exit_code == 0:
1237 break
1238 except Exception:
1239 pass
1240 time.sleep(2)
1241 else:
1242 logger.warning("n8n did not become healthy in 30 s; skipping workflow seed")
1243 return
1245 # Check for the seed marker to avoid duplicate imports.
1246 exit_code, _ = container.exec_run("test -f /home/node/.n8n/.workflows-seeded")
1247 if exit_code == 0:
1248 logger.debug("n8n workflows already seeded; skipping")
1249 return
1251 # Import each workflow template.
1252 exit_code, output = container.exec_run("ls /workflows/")
1253 if exit_code != 0:
1254 logger.debug("No /workflows directory in n8n container")
1255 return
1257 for line in output.decode().strip().splitlines():
1258 fname = line.strip()
1259 if not fname.endswith(".json"):
1260 continue
1261 logger.info("Importing n8n workflow: %s", fname)
1262 exit_code, out = container.exec_run(f"n8n import:workflow --input=/workflows/{fname}")
1263 if exit_code != 0:
1264 logger.warning("Failed to import %s: %s", fname, out.decode())
1266 # Write seed marker so we don't re-import on next restart.
1267 container.exec_run("touch /home/node/.n8n/.workflows-seeded")
1269 def exec_in_ollama(self, command: list[str]) -> str:
1270 """Run a command in the Ollama container and return stdout.
1272 Used for: ollama pull, ollama list, ollama create.
1273 """
1274 container = self._get_container(OLLAMA_CONTAINER)
1275 if container is None or container.status != "running":
1276 raise ContainerNotFoundError(OLLAMA_CONTAINER)
1278 exit_code, output = container.exec_run(
1279 cmd=command,
1280 stdout=True,
1281 stderr=True,
1282 )
1283 decoded: str = output.decode("utf-8", errors="replace")
1284 if exit_code != 0:
1285 logger.error("Command failed in ollama: %s\n%s", " ".join(command), decoded)
1286 return decoded
1288 # =========================================================================
1289 # Container lifecycle
1290 # =========================================================================
1292 def stop_container(self, name: str) -> None:
1293 """Stop a container by name."""
1294 container = self._get_container(name)
1295 if container is None:
1296 raise ContainerNotFoundError(name)
1297 if container.status == "running":
1298 container.stop()
1299 logger.info("Stopped container: %s", name)
1301 def remove_container(self, name: str) -> None:
1302 """Remove a container by name, stopping it first if running."""
1303 container = self._get_container(name)
1304 if container is None:
1305 raise ContainerNotFoundError(name)
1306 if container.status == "running":
1307 container.stop()
1308 logger.info("Stopped container: %s", name)
1309 container.remove()
1310 logger.info("Removed container: %s", name)
1312 def remove_volume(self, name: str) -> bool:
1313 """Remove a named Docker volume.
1315 Returns True if a volume was removed, False if it did not exist.
1316 """
1317 try:
1318 volume = self.client.volumes.get(name)
1319 except NotFound:
1320 return False
1321 volume.remove()
1322 logger.info("Removed volume: %s", name)
1323 return True
1325 def container_ports(self, name: str) -> dict[str, str] | None:
1326 """Get the port mappings for a container.
1328 Returns a dict mapping container ports (e.g. '3000/tcp') to host
1329 addresses (e.g. '0.0.0.0:49152'), or None if the container doesn't exist.
1330 """
1331 container = self._get_container(name)
1332 if container is None:
1333 return None
1334 container.reload()
1335 ports_data = container.attrs.get("NetworkSettings", {}).get("Ports") or {}
1336 result: dict[str, str] = {}
1337 for container_port, bindings in sorted(ports_data.items()):
1338 if bindings:
1339 binding = bindings[0]
1340 result[container_port] = f"{binding['HostIp']}:{binding['HostPort']}"
1341 return result
1343 def container_status(self, name: str) -> str | None:
1344 """Get the status of a container, or None if it doesn't exist."""
1345 container = self._get_container(name)
1346 if container is None:
1347 return None
1348 return container.status # type: ignore[no-any-return]
1350 def container_logs(self, name: str, follow: bool = False, tail: int = 100) -> None:
1351 """Print container logs. If follow=True, streams via docker CLI."""
1352 if follow:
1353 # Use docker CLI for streaming
1354 args = ["docker", "logs", "-f", name]
1355 _exec_docker(args)
1356 else:
1357 container = self._get_container(name)
1358 if container is None:
1359 raise ContainerNotFoundError(name)
1360 logs = container.logs(tail=tail).decode("utf-8", errors="replace")
1361 print(logs)
1363 # =========================================================================
1364 # Internal helpers
1365 # =========================================================================
1367 def _get_container(self, name: str) -> Container | None:
1368 """Get a container by name, or None if it doesn't exist."""
1369 try:
1370 return self.client.containers.get(name)
1371 except NotFound:
1372 return None
1374 def _container_matches_project(self, container: Container, project_dir: Path) -> bool:
1375 """Check whether a container's project mount matches *project_dir*."""
1376 resolved_project_dir = str(project_dir.resolve())
1377 mounts = container.attrs.get("Mounts", [])
1378 for mount in mounts:
1379 if mount.get("Source") == resolved_project_dir:
1380 return True
1381 return False
1383 # AUTO-UPDATE: Pre-launch tool freshness check
1384 def ensure_tool_fresh(self, container_name: str, tool_name: str) -> None:
1385 """Check if a tool is stale and update it before launch.
1387 Runs ``update-tools.sh --check <tool>`` inside the container.
1388 If stale (exit code 1), runs ``--tool <tool>`` in the foreground
1389 (blocking) which also kicks off background updates for other tools.
1391 Silently does nothing if ``update-tools.sh`` is not present in the
1392 container (backward compatibility with older images), or if
1393 ``config.skip_updates`` is True (``--skip-updates`` flag).
1394 """
1395 if self.config.skip_updates:
1396 logger.debug("Skipping tool freshness check (--skip-updates)")
1397 return
1399 update_script = "/usr/local/bin/update-tools.sh"
1401 # Check if update script exists in the container
1402 check_exists = subprocess.run(
1403 ["docker", "exec", container_name, "test", "-x", update_script],
1404 capture_output=True,
1405 )
1406 if check_exists.returncode != 0:
1407 logger.debug(
1408 "update-tools.sh not found in %s, skipping freshness check",
1409 container_name,
1410 )
1411 return
1413 # Check freshness
1414 check_result = subprocess.run(
1415 ["docker", "exec", container_name, update_script, "--check", tool_name],
1416 capture_output=True,
1417 )
1418 if check_result.returncode == 0:
1419 logger.debug("Tool %s is fresh, skipping update", tool_name)
1420 return
1422 # Tool is stale — update it in foreground (--tool also backgrounds the rest)
1423 from rich.console import Console
1425 console = Console(stderr=True)
1426 with console.status(f"[bold]Updating {tool_name}...[/bold]", spinner="dots"):
1427 update_result = subprocess.run(
1428 ["docker", "exec", container_name, update_script, "--tool", tool_name],
1429 capture_output=True,
1430 text=True,
1431 timeout=300, # 5 minute timeout
1432 )
1433 if update_result.returncode == 0:
1434 console.print(f"[green]Updated {tool_name}[/green]")
1435 else:
1436 console.print(f"[yellow]Update for {tool_name} had issues, continuing anyway[/yellow]")
1437 logger.debug("Update stderr: %s", update_result.stderr)
1439 def _build_image_if_needed(self, image: str, context_path: str) -> None:
1440 """Build a Docker image locally if it isn't already present.
1442 Used for images we don't pull from a registry (experimental
1443 components shipped as a Dockerfile in this repo). The local tag
1444 is cached between runs; a rebuild requires removing the image
1445 first (``docker rmi <tag>``).
1446 """
1447 try:
1448 self.client.images.get(image)
1449 logger.debug("Image already built: %s", image)
1450 return
1451 except ImageNotFound:
1452 pass
1454 logger.info("Building image: %s from %s ...", image, context_path)
1455 try:
1456 self.client.images.build(path=context_path, tag=image, rm=True)
1457 logger.info("Image built: %s", image)
1458 except APIError as e:
1459 raise ImagePullError(image, f"build failed: {e}") from e
1461 def _pull_image_if_needed(self, image: str) -> None:
1462 """Pull a Docker image if not available locally.
1464 For the ``latest`` tag, always pull to ensure the freshest digest
1465 since the local cache may be stale. If the pull fails but a
1466 cached copy exists, falls back to the cached version with a
1467 warning.
1468 """
1469 tag = image.rsplit(":", 1)[-1] if ":" in image else "latest"
1471 # AUTO-UPDATE: Always pull 'latest' to get fresh images
1472 if tag != "latest":
1473 try:
1474 self.client.images.get(image)
1475 logger.debug("Image already available: %s", image)
1476 return
1477 except ImageNotFound:
1478 pass
1480 logger.info("Pulling image: %s (this may take a while)...", image)
1481 try:
1482 pulled = self.client.images.pull(*image.rsplit(":", 1))
1483 self._warn_if_image_below_minimum(pulled)
1484 logger.info("Image pulled: %s", image)
1485 except APIError as e:
1486 if tag == "latest":
1487 try:
1488 self.client.images.get(image)
1489 logger.warning("Failed to pull latest image, using cached version: %s", e)
1490 return
1491 except ImageNotFound:
1492 pass
1493 raise ImagePullError(image, str(e)) from e