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

1"""Docker container lifecycle management. 

2 

3Replaces docker-compose.yml by using Docker SDK to create and manage containers 

4with the exact same configuration. 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import logging 

11import os 

12import subprocess 

13import sys 

14import time 

15from pathlib import Path 

16from typing import TYPE_CHECKING, NoReturn 

17 

18from docker.errors import APIError, ImageNotFound, NotFound 

19from docker.types import DeviceRequest, Mount 

20 

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 

64 

65if TYPE_CHECKING: 

66 from docker.models.containers import Container 

67 from docker.models.images import Image 

68 

69 from ai_shell.config import AiShellConfig 

70 

71logger = logging.getLogger(__name__) 

72 

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) 

81 

82 

83def _host_port_available(port: int) -> bool: 

84 """Check whether *port* can be bound on the host running ai-shell. 

85 

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 

91 

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 

98 

99 

100def _exec_docker(args: list[str]) -> NoReturn: 

101 """Execute a docker CLI command with cross-platform TTY support. 

102 

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) 

111 

112 

113def _run_docker(args: list[str]) -> tuple[int, float]: 

114 """Run a docker CLI command and return (exit_code, elapsed_seconds). 

115 

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 

125 

126 

127def _run_docker_with_typeahead(args: list[str], typeahead: bytes) -> tuple[int, float]: 

128 """Run docker exec under a PTY, pre-injecting typeahead bytes. 

129 

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 

140 

141 logger.debug("run+pty: %s", " ".join(args)) 

142 sys.stdout.flush() 

143 sys.stderr.flush() 

144 

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) 

149 

150 # Match the PTY size to the host terminal so curses-based tools render correctly. 

151 try: 

152 import fcntl 

153 

154 size = fcntl.ioctl(stdout_fd, termios.TIOCGWINSZ, b"\x00" * 8) 

155 fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, size) 

156 except OSError: 

157 pass 

158 

159 def _on_winch(_signum: int, _frame: object) -> None: 

160 try: 

161 import fcntl 

162 

163 size = fcntl.ioctl(stdout_fd, termios.TIOCGWINSZ, b"\x00" * 8) 

164 fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, size) 

165 except OSError: 

166 pass 

167 

168 previous_winch = signal.signal(signal.SIGWINCH, _on_winch) 

169 

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) 

179 

180 try: 

181 tty.setraw(stdin_fd) 

182 if typeahead: 

183 os.write(master_fd, typeahead) 

184 

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() 

225 

226 elapsed = time.monotonic() - start 

227 return proc.returncode, elapsed 

228 

229 

230class ContainerManager: 

231 """Manages Docker containers for ai-shell. 

232 

233 Handles the dev container (per-project) and LLM stack (host-level singletons). 

234 """ 

235 

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 

245 

246 # ========================================================================= 

247 # Dev container (per-project) 

248 # ========================================================================= 

249 

250 def resolve_dev_container(self) -> tuple[str, Container | None]: 

251 """Resolve the dev container, checking both current and legacy names. 

252 

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 

262 

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 

269 

270 return name, None 

271 

272 def ensure_dev_container(self) -> str: 

273 """Get or create the dev container for the current project. 

274 

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. 

279 

280 Returns the container name. 

281 """ 

282 name, container = self.resolve_dev_container() 

283 

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 

314 

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 

319 

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 ) 

335 

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 ) 

342 

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__ 

347 

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 ) 

364 

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 

371 

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 ) 

386 

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) 

410 

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 ) 

424 

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) 

429 

430 return container 

431 

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. 

441 

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 

453 

454 from ai_shell import __version__ 

455 

456 template = ( 

457 resources.files("ai_shell.templates") 

458 .joinpath("container", "context.md") 

459 .read_text(encoding="utf-8") 

460 ) 

461 

462 port_rows = "\n".join( 

463 f"| {port} | http://localhost:{host_port} |" 

464 for port, host_port in sorted(port_map.items()) 

465 ) 

466 

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." 

483 

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 ) 

495 

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 ) 

501 

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 ) 

512 

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()) 

522 

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. 

532 

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"] 

540 

541 if sys.stdin.isatty(): 

542 args.append("-it") 

543 

544 if workdir: 

545 args.extend(["-w", workdir]) 

546 

547 if extra_env: 

548 for key, value in extra_env.items(): 

549 args.extend(["-e", f"{key}={value}"]) 

550 

551 args.append(container_name) 

552 args.extend(command) 

553 

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) 

557 

558 _exec_docker(args) 

559 

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). 

569 

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"] 

577 

578 if sys.stdin.isatty(): 

579 args.append("-it") 

580 

581 if workdir: 

582 args.extend(["-w", workdir]) 

583 

584 if extra_env: 

585 for key, value in extra_env.items(): 

586 args.extend(["-e", f"{key}={value}"]) 

587 

588 args.append(container_name) 

589 args.extend(command) 

590 

591 if typeahead and sys.platform != "win32" and sys.stdin.isatty(): 

592 return _run_docker_with_typeahead(args, typeahead) 

593 

594 return _run_docker(args) 

595 

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) 

614 

615 # ========================================================================= 

616 # LLM stack (host-level singletons) 

617 # ========================================================================= 

618 

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 ) 

627 

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*. 

632 

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 

648 

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. 

651 

652 Only acts when the configured tag is ``latest``. For pinned 

653 version tags the image is immutable so staleness doesn't apply. 

654 

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. 

658 

659 Returns True if the container was removed (caller must recreate). 

660 """ 

661 from ai_shell.cache import is_fresh, mark_fresh 

662 

663 tag = self.config.image_tag 

664 if tag != "latest": 

665 return False 

666 

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 

671 

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 

677 

678 mark_fresh("image-pull", image_ref) 

679 self._warn_if_image_below_minimum(pulled) 

680 

681 container_image_id = container.image.id 

682 pulled_image_id = pulled.id 

683 

684 if container_image_id == pulled_image_id: 

685 return False 

686 

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 

694 

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__ 

699 

700 labels = image.labels or {} 

701 image_version_str = labels.get("org.opencontainers.image.version", "") 

702 if not image_version_str: 

703 return 

704 

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 

710 

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 

715 

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 ) 

723 

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 

732 

733 def ensure_ollama(self) -> str: 

734 """Get or create the Ollama container with GPU auto-detection. 

735 

736 Recreates the container if GPU availability has changed since creation. 

737 """ 

738 gpu_available = detect_gpu() 

739 container = self._get_container(OLLAMA_CONTAINER) 

740 

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 

749 

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)") 

781 

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 } 

798 

799 if device_requests: 

800 kwargs["device_requests"] = device_requests 

801 if env: 

802 kwargs["environment"] = env 

803 

804 self.client.containers.run(**kwargs) 

805 logger.info("Ollama container created on port %d", self.config.ollama_port) 

806 return OLLAMA_CONTAINER 

807 

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. 

816 

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) 

825 

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 

831 

832 logger.info("Creating Open WebUI container") 

833 self._pull_image_if_needed(WEBUI_IMAGE) 

834 network_name = self._ensure_llm_network() 

835 

836 from dotenv import dotenv_values 

837 

838 from ai_shell.defaults import _resolve_env 

839 

840 dotenv: dict[str, str | None] = {} 

841 if env_file is not None: 

842 dotenv = dotenv_values(env_file) 

843 

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 ) 

885 

886 # External LLM providers — pass through API keys when available. 

887 openai_urls: list[str] = [] 

888 openai_keys: list[str] = [] 

889 

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) 

894 

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) 

899 

900 if openai_urls: 

901 environment["OPENAI_API_BASE_URLS"] = ";".join(openai_urls) 

902 environment["OPENAI_API_KEYS"] = ";".join(openai_keys) 

903 

904 anthropic_key = _resolve_env(dotenv, "ANTHROPIC_API_KEY") 

905 if anthropic_key: 

906 environment["ANTHROPIC_API_KEY"] = anthropic_key 

907 

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 ) 

924 

925 logger.info("Open WebUI container created on port %d", self.config.webui_port) 

926 return WEBUI_CONTAINER 

927 

928 def ensure_kokoro(self) -> str: 

929 """Get or create the Kokoro-FastAPI (local TTS) container. 

930 

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() 

949 

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"]])] 

960 

961 self.client.containers.run(**kwargs) 

962 logger.info("Kokoro container created on port %d", self.config.kokoro_port) 

963 return KOKORO_CONTAINER 

964 

965 def ensure_whisper(self) -> str: 

966 """Get or create the Speaches (local STT) container. 

967 

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() 

989 

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 } 

996 

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"]])] 

1015 

1016 self.client.containers.run(**kwargs) 

1017 logger.info("Whisper container created on port %d", self.config.whisper_port) 

1018 return WHISPER_CONTAINER 

1019 

1020 def ensure_voice_agent(self) -> str: 

1021 """Get or create the voice-agent container. 

1022 

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 

1038 

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() 

1042 

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 } 

1061 

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 

1065 

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") 

1075 

1076 def ensure_comfyui(self, env_file: Path | None = None) -> str: 

1077 """Get or create the ComfyUI image-generation container. 

1078 

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 

1088 

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 

1099 

1100 if not gpu_available: 

1101 raise GpuRequiredError("ComfyUI") 

1102 

1103 logger.info("Creating ComfyUI container") 

1104 self._pull_image_if_needed(COMFYUI_IMAGE) 

1105 network_name = self._ensure_llm_network() 

1106 

1107 dotenv: dict[str, str | None] = {} 

1108 if env_file is not None: 

1109 dotenv = dotenv_values(env_file) 

1110 

1111 hf_token = _resolve_env(dotenv, "HF_TOKEN") or _resolve_env( 

1112 dotenv, "HUGGING_FACE_HUB_TOKEN" 

1113 ) 

1114 

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 

1135 

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 ) 

1153 

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 } 

1165 

1166 self.client.containers.run(**kwargs) 

1167 logger.info("ComfyUI container created on port %d", self.config.comfyui_port) 

1168 return COMFYUI_CONTAINER 

1169 

1170 def ensure_n8n(self, env_file: Path | None = None) -> str: 

1171 """Get or create the n8n workflow automation container. 

1172 

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) 

1180 

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 

1186 

1187 logger.info("Creating n8n container") 

1188 self._pull_image_if_needed(N8N_IMAGE) 

1189 network_name = self._ensure_llm_network() 

1190 

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 ) 

1196 

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 ) 

1201 

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 ) 

1213 

1214 logger.info("n8n container created on port %d", self.config.n8n_port) 

1215 

1216 if created and workflow_dir.is_dir(): 

1217 self._seed_n8n_workflows() 

1218 

1219 return N8N_CONTAINER 

1220 

1221 def _seed_n8n_workflows(self) -> None: 

1222 """Import starter workflow templates into a freshly-created n8n. 

1223 

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 

1231 

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 

1244 

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 

1250 

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 

1256 

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()) 

1265 

1266 # Write seed marker so we don't re-import on next restart. 

1267 container.exec_run("touch /home/node/.n8n/.workflows-seeded") 

1268 

1269 def exec_in_ollama(self, command: list[str]) -> str: 

1270 """Run a command in the Ollama container and return stdout. 

1271 

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) 

1277 

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 

1287 

1288 # ========================================================================= 

1289 # Container lifecycle 

1290 # ========================================================================= 

1291 

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) 

1300 

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) 

1311 

1312 def remove_volume(self, name: str) -> bool: 

1313 """Remove a named Docker volume. 

1314 

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 

1324 

1325 def container_ports(self, name: str) -> dict[str, str] | None: 

1326 """Get the port mappings for a container. 

1327 

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 

1342 

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] 

1349 

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) 

1362 

1363 # ========================================================================= 

1364 # Internal helpers 

1365 # ========================================================================= 

1366 

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 

1373 

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 

1382 

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. 

1386 

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. 

1390 

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 

1398 

1399 update_script = "/usr/local/bin/update-tools.sh" 

1400 

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 

1412 

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 

1421 

1422 # Tool is stale — update it in foreground (--tool also backgrounds the rest) 

1423 from rich.console import Console 

1424 

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) 

1438 

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. 

1441 

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 

1453 

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 

1460 

1461 def _pull_image_if_needed(self, image: str) -> None: 

1462 """Pull a Docker image if not available locally. 

1463 

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" 

1470 

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 

1479 

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