191 lines
6.5 KiB
Python
191 lines
6.5 KiB
Python
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}")
|