Source code for kelvin.sdk.commands.app_images

"""App images command business logic.

Handles local application image management operations.
These are Docker-only operations that don't require authentication.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Optional, cast

import structlog
import structlog.stdlib

from kelvin.sdk.commands._shared import KSDK_LABELS
from kelvin.sdk.exceptions import CLIError
from kelvin.sdk.services.docker import DockerService

logger = cast(structlog.stdlib.BoundLogger, structlog.get_logger(__name__))


# ============ Constants ============

# Default path inside container where app files are located
DEFAULT_CONTAINER_APP_PATH = "/opt/kelvin/app"


# ============ Models ============


[docs] @dataclass class LocalImage: """Local application image information.""" name: str version: str tag: str created_at: Optional[str] size: int
[docs] @dataclass class ImageListResult: """Result of listing local images.""" images: list[LocalImage]
[docs] @dataclass class ImageRemoveResult: """Result of removing a local image.""" image_name: str removed: bool
[docs] @dataclass class ImageUnpackResult: """Result of unpacking an image.""" image_name: str container_path: str output_path: str
# ============ Exceptions ============
[docs] class AppImageError(CLIError): """Base exception for app image operations."""
[docs] class InvalidImageNameError(AppImageError): """Raised when image name format is invalid.""" exit_code: int = 64 # EX_USAGE image_name: str def __init__(self, image_name: str): self.image_name = image_name super().__init__(f"Invalid image name '{image_name}'. Expected format: name:version (e.g., my-app:1.0.0)")
# ============ Commands ============
[docs] class AppImageCommands: """App image command business logic. Handles local Docker image operations. These are local-only operations that don't require authentication to the platform. """ def __init__(self, docker_service: DockerService) -> None: self._docker_service: DockerService = docker_service
[docs] def list_images(self) -> ImageListResult: """List all locally built application images. Lists Docker images that were built by KSDK (identified by labels). Returns: ImageListResult containing list of local images. """ logger.info("listing local app images") docker_images = self._docker_service.list_images(labels=KSDK_LABELS) images: list[LocalImage] = [] for docker_image in docker_images: # Filter out images with <none> tags for tag in docker_image.tags: if "<none>" in tag: continue # Parse name:version from tag if ":" in tag: name, version = tag.rsplit(":", 1) else: name, version = tag, "latest" # Format created_at created_str = None if docker_image.created_at: created_str = docker_image.created_at.strftime("%Y-%m-%d %H:%M:%S") images.append( LocalImage( name=name, version=version, tag=tag, created_at=created_str, size=docker_image.size, ) ) logger.info("found local app images", count=len(images)) return ImageListResult(images=images)
[docs] def remove_image(self, app_name_with_version: str) -> ImageRemoveResult: """Remove an application image from the local registry. Args: app_name_with_version: Image name with version (e.g., "my-app:1.0.0"). Returns: ImageRemoveResult indicating success. Raises: InvalidImageNameError: If image name format is invalid. DockerImageNotFoundError: If image does not exist. """ # Validate format if ":" not in app_name_with_version: raise InvalidImageNameError(app_name_with_version) logger.info("removing local app image", image=app_name_with_version) self._docker_service.remove_image(app_name_with_version) logger.info("removed local app image", image=app_name_with_version) return ImageRemoveResult(image_name=app_name_with_version, removed=True)
[docs] def unpack_image( self, app_name_with_version: str, output_dir: Path, container_dir: Optional[str] = None, ) -> ImageUnpackResult: """Extract content from an application image. Extracts files from a Docker image to a local directory. Args: app_name_with_version: Image name with version (e.g., "my-app:1.0.0"). output_dir: Directory to extract files to. container_dir: Path inside container to extract (default: /app). Returns: ImageUnpackResult with extraction details. Raises: InvalidImageNameError: If image name format is invalid. DockerImageNotFoundError: If image does not exist. """ # Validate format if ":" not in app_name_with_version: raise InvalidImageNameError(app_name_with_version) # Default container path container_path = container_dir or DEFAULT_CONTAINER_APP_PATH logger.info( "unpacking app image", image=app_name_with_version, container_path=container_path, output_dir=str(output_dir), ) self._docker_service.extract_from_image( image_name=app_name_with_version, container_path=container_path, output_path=output_dir, ) logger.info( "unpacked app image", image=app_name_with_version, output_dir=str(output_dir), ) return ImageUnpackResult( image_name=app_name_with_version, container_path=container_path, output_path=str(output_dir), )