from __future__ import annotations import shutil from app.core.command_runner import CommandError, CommandRunner DOCKER_REQUIRED_MESSAGE = "Docker runtime is required" def image_reference(component: dict) -> str: image = component["image"] digest = component.get("digest") tag = component.get("tag") if "@" in image: return image if digest: return f"{_image_without_tag(image)}@{digest}" if _image_has_tag(image) or not tag: return image return f"{image}:{tag}" def _image_has_tag(image: str) -> bool: last_slash = image.rfind("/") last_colon = image.rfind(":") return last_colon > last_slash def _image_without_tag(image: str) -> str: if not _image_has_tag(image): return image return image[: image.rfind(":")] class DockerInstaller: def __init__(self, command_runner: CommandRunner) -> None: self.command_runner = command_runner def ensure_runtime(self, auto_install: bool = False) -> None: if not shutil.which("docker"): if not auto_install: raise RuntimeError(DOCKER_REQUIRED_MESSAGE) self.install_runtime() try: self._verify_runtime() return except CommandError as error: self._log("warning", self._runtime_error_message(error)) self._start_runtime() try: self._verify_runtime() except CommandError as error: raise RuntimeError(self._runtime_error_message(error)) from error def install_runtime(self) -> None: if not shutil.which("apt-get"): raise RuntimeError( f"{DOCKER_REQUIRED_MESSAGE}. Install Docker Engine on this client or disable Docker packages." ) self._log("info", "Docker runtime not found; installing docker.io") try: self.command_runner.run(["apt-get", "update"], timeout=600) self.command_runner.run( ["env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "docker.io"], timeout=1200, ) except CommandError as error: raise RuntimeError( f"{DOCKER_REQUIRED_MESSAGE}: failed to install docker.io with apt-get: " f"{self._command_error_detail(error)}" ) from error self._start_runtime() def _verify_runtime(self) -> None: self.command_runner.run(["docker", "version", "--format", "{{.Server.Version}}"]) def _start_runtime(self) -> None: if shutil.which("systemctl"): try: self.command_runner.run(["systemctl", "enable", "--now", "docker"], timeout=120) return except CommandError as error: self._log("warning", f"Could not start Docker with systemctl: {self._command_error_detail(error)}") if shutil.which("service"): try: self.command_runner.run(["service", "docker", "start"], timeout=120) except CommandError as error: self._log("warning", f"Could not start Docker service: {self._command_error_detail(error)}") def _runtime_error_message(self, error: CommandError) -> str: detail = self._command_error_detail(error) return f"{DOCKER_REQUIRED_MESSAGE}: {detail}" if detail else DOCKER_REQUIRED_MESSAGE def _command_error_detail(self, error: CommandError) -> str: output = (error.stderr or error.stdout or "").strip() if not output: return str(error) return output.splitlines()[-1].strip() def _log(self, level: str, message: str) -> None: if self.command_runner.task_id: self.command_runner.repository.add_log(self.command_runner.task_id, level, message) def pull_image(self, reference: str) -> None: self.command_runner.run(["docker", "pull", reference]) def recreate_container(self, app_id: str, component: dict) -> None: container_name = component["containerName"] reference = image_reference(component) self.remove_container(container_name) command = [ "docker", "run", "-d", "--name", container_name, "--restart", component.get("restartPolicy", "unless-stopped"), "--label", f"local-installer-agent.app-id={app_id}", "--label", f"local-installer-agent.component-id={component['componentId']}", ] for name, value in sorted((component.get("env") or {}).items()): command.extend(["-e", f"{name}={value}"]) for port in component.get("ports") or []: command.extend(["-p", port]) for volume in component.get("volumes") or []: command.extend(["-v", volume]) command.append(reference) self.command_runner.run(command) def remove_container(self, container_name: str, remove_volumes: bool = False) -> None: result = self.command_runner.run([ "docker", "ps", "-aq", "--filter", f"name=^/{container_name}$", ]) if result.stdout.strip(): command = ["docker", "rm", "-f"] if remove_volumes: command.append("-v") command.append(container_name) self.command_runner.run(command) def remove_labeled_containers(self, app_id: str, component_id: str, remove_volumes: bool = False) -> None: result = self.command_runner.run([ "docker", "ps", "-aq", "--filter", f"label=local-installer-agent.app-id={app_id}", "--filter", f"label=local-installer-agent.component-id={component_id}", ]) container_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()] if not container_ids: return command = ["docker", "rm", "-f"] if remove_volumes: command.append("-v") command.extend(container_ids) self.command_runner.run(command) def remove_image(self, reference: str) -> None: self.command_runner.run(["docker", "image", "rm", reference]) def assert_container_running(self, container_name: str) -> None: result = self.command_runner.run([ "docker", "inspect", "-f", "{{.State.Running}}", container_name, ]) if result.stdout.strip().lower() != "true": raise RuntimeError(f"Docker container is not running: {container_name}")