|
9 | 9 | from abc import ABC, abstractmethod
|
10 | 10 | from typing import Union
|
11 | 11 |
|
| 12 | +import docker |
| 13 | +from docker.errors import ImageNotFound, NotFound, APIError |
12 | 14 | from packaging.specifiers import SpecifierSet
|
13 | 15 |
|
14 | 16 | log = logging.getLogger("floatLogger")
|
@@ -387,20 +389,123 @@ class DockerManager(EnvironmentManager):
|
387 | 389 | """
|
388 | 390 |
|
389 | 391 | def __init__(self, base_name: str, model_directory: str) -> None:
|
390 |
| - self.base_name = base_name |
| 392 | + self.base_name = base_name.replace(" ", "_") |
391 | 393 | self.model_directory = model_directory
|
392 | 394 |
|
393 |
| - def create_environment(self, force=False) -> None: |
394 |
| - pass |
| 395 | + # use a lower-case slug for tags |
| 396 | + slug = self.base_name.lower() |
| 397 | + self.image_tag = f"{slug}_image" |
| 398 | + self.container_name = f"{slug}_container" |
395 | 399 |
|
396 |
| - def env_exists(self) -> None: |
397 |
| - pass |
| 400 | + # Docker SDK client |
| 401 | + self.client = docker.from_env() |
398 | 402 |
|
399 |
| - def run_command(self, command) -> None: |
400 |
| - pass |
| 403 | + def create_environment(self, force: bool = False) -> None: |
| 404 | + """ |
| 405 | + Build (or rebuild) the Docker image for this model. |
| 406 | + """ |
| 407 | + |
| 408 | + # If forced, remove the existing image |
| 409 | + if force and self.env_exists(): |
| 410 | + log.info(f"[{self.base_name}] Removing existing image '{self.image_tag}'") |
| 411 | + try: |
| 412 | + self.client.images.remove(self.image_tag, force=True) |
| 413 | + except APIError as e: |
| 414 | + log.warning(f"[{self.base_name}] Could not remove image: {e}") |
| 415 | + |
| 416 | + # If image is missing or rebuild was requested, build it now |
| 417 | + if force or not self.env_exists(): |
| 418 | + build_path = os.path.abspath(self.model_directory) |
| 419 | + uid, gid = os.getuid(), os.getgid() |
| 420 | + build_args = { |
| 421 | + "USER_UID": str(uid), |
| 422 | + "USER_GID": str(gid), |
| 423 | + } |
| 424 | + log.info(f"[{self.base_name}] Building image '{self.image_tag}' from {build_path}") |
| 425 | + |
| 426 | + build_logs = self.client.api.build( |
| 427 | + path=build_path, |
| 428 | + tag=self.image_tag, |
| 429 | + rm=True, |
| 430 | + decode=True, |
| 431 | + buildargs=build_args, |
| 432 | + nocache=False # todo: create model arg for --no-cache |
| 433 | + ) |
| 434 | + |
| 435 | + # Stream each chunk |
| 436 | + for chunk in build_logs: |
| 437 | + if "stream" in chunk: |
| 438 | + for line in chunk["stream"].splitlines(): |
| 439 | + log.debug(f"[{self.base_name}][build] {line}") |
| 440 | + elif "errorDetail" in chunk: |
| 441 | + msg = chunk["errorDetail"].get("message", "").strip() |
| 442 | + log.error(f"[{self.base_name}][build error] {msg}") |
| 443 | + raise RuntimeError(f"Docker build error: {msg}") |
| 444 | + log.info(f"[{self.base_name}] Successfully built '{self.image_tag}'") |
| 445 | + |
| 446 | + def env_exists(self) -> bool: |
| 447 | + """ |
| 448 | + Checks if the Docker image with the given tag already exists. |
| 449 | +
|
| 450 | + Returns: |
| 451 | + bool: True if the Docker image exists, False otherwise. |
| 452 | + """ |
| 453 | + """ |
| 454 | + Returns True if an image with our tag already exists locally. |
| 455 | + """ |
| 456 | + try: |
| 457 | + self.client.images.get(self.image_tag) |
| 458 | + return True |
| 459 | + except ImageNotFound: |
| 460 | + return False |
| 461 | + |
| 462 | + def run_command(self, command=None) -> None: |
| 463 | + """ |
| 464 | + Runs the model’s Docker container with input/ and forecasts/ mounted. |
| 465 | + Streams logs and checks for non-zero exit codes. |
| 466 | + """ |
| 467 | + model_root = os.path.abspath(self.model_directory) |
| 468 | + mounts = { |
| 469 | + os.path.join(model_root, "input"): {'bind': '/app/input', 'mode': 'rw'}, |
| 470 | + os.path.join(model_root, "forecasts"): {'bind': '/app/forecasts', 'mode': 'rw'}, |
| 471 | + } |
| 472 | + |
| 473 | + uid, gid = os.getuid(), os.getgid() |
| 474 | + |
| 475 | + log.info(f"[{self.base_name}] Launching container {self.container_name}") |
| 476 | + |
| 477 | + try: |
| 478 | + container = self.client.containers.run( |
| 479 | + self.image_tag, |
| 480 | + remove=False, |
| 481 | + volumes=mounts, |
| 482 | + detach=True, |
| 483 | + user=f"{uid}:{gid}", |
| 484 | + ) |
| 485 | + except docker.errors.APIError as e: |
| 486 | + raise RuntimeError(f"[{self.base_name}] Failed to start container: {e}") |
| 487 | + |
| 488 | + # Log output live |
| 489 | + for line in container.logs(stream=True): |
| 490 | + log.info(f"[{self.base_name}] {line.decode().rstrip()}") |
| 491 | + |
| 492 | + # Wait for exit |
| 493 | + exit_code = container.wait().get("StatusCode", 1) |
| 494 | + |
| 495 | + # Clean up |
| 496 | + container.remove(force=True) |
| 497 | + |
| 498 | + if exit_code != 0: |
| 499 | + raise RuntimeError(f"[{self.base_name}] Container exited with code {exit_code}") |
| 500 | + |
| 501 | + log.info(f"[{self.base_name}] Container finished successfully.") |
401 | 502 |
|
402 | 503 | def install_dependencies(self) -> None:
|
403 |
| - pass |
| 504 | + """ |
| 505 | + Installs dependencies for Docker-based models. This is typically handled by the Dockerfile, |
| 506 | + so no additional action is needed here. |
| 507 | + """ |
| 508 | + log.info("No additional dependency installation required for Docker environments.") |
404 | 509 |
|
405 | 510 |
|
406 | 511 | class EnvironmentFactory:
|
|
0 commit comments