diff --git a/ramalama/annotations.py b/ramalama/annotations.py index 2abd2c881..7c49cee8b 100644 --- a/ramalama/annotations.py +++ b/ramalama/annotations.py @@ -3,15 +3,15 @@ # https://github.com/CloudNativeAI/model-spec # ArtifactTypeModelManifest specifies the media type for a model manifest. -ArtifactTypeModelManifest = "application/vnd.cnai.model.manifest.v1+json" +ArtifactTypeModelManifest = "application/vnd.cncf.model.manifest.v1+json" # ArtifactTypeModelLayer is the media type used for layers referenced by the # manifest. -ArtifactTypeModelLayer = "application/vnd.cnai.model.layer.v1.tar" +ArtifactTypeModelLayer = "application/vnd.cncf.model.layer.v1.tar" # ArtifactTypeModelLayerGzip is the media type used for gzipped layers # referenced by the manifest. -ArtifactTypeModelLayerGzip = "application/vnd.cnai.model.layer.v1.tar+gzip" +ArtifactTypeModelLayerGzip = "application/vnd.cncf.model.layer.v1.tar+gzip" # AnnotationCreated is the annotation key for the date and time on which the # model was built (date-time string as defined by RFC 3339). @@ -76,7 +76,7 @@ # DEPRECATED: Migrate to AnnotationFilepath # AnnotationModel is the annotation key for the layer is a model file (boolean), # such as `true` or `false`. -AnnotationModel = "org.cnai.model.model" +AnnotationModel = "org.cncf.model.model" # AnnotationFilepath is the annotation key for the file path of the layer. -AnnotationFilepath = "org.cnai.model.filepath" +AnnotationFilepath = "org.cncf.model.filepath" diff --git a/ramalama/chat_providers/api_providers.py b/ramalama/chat_providers/api_providers.py index 2f99a46b2..757d193cd 100644 --- a/ramalama/chat_providers/api_providers.py +++ b/ramalama/chat_providers/api_providers.py @@ -13,7 +13,8 @@ def get_provider_api_key(scheme: str) -> str | None: """Return a configured API key for the given provider scheme, if any.""" if resolver := PROVIDER_API_KEY_RESOLVERS.get(scheme): - return resolver() + if key := resolver(): + return key return get_config().api_key @@ -26,7 +27,7 @@ def get_provider_api_key(scheme: str) -> str | None: def get_chat_provider(scheme: str) -> ChatProvider: if (resolver := DEFAULT_PROVIDERS.get(scheme, None)) is None: - raise ValueError(f"No support chat providers for {scheme}") + raise ValueError(f"No supported chat provider for {scheme}") return resolver() diff --git a/ramalama/chat_providers/base.py b/ramalama/chat_providers/base.py index 00fd4d122..fd872c313 100644 --- a/ramalama/chat_providers/base.py +++ b/ramalama/chat_providers/base.py @@ -185,7 +185,7 @@ def list_models(self) -> list[str]: if exc.code in (401, 403): message = ( f"Could not authenticate with {self.provider}." - "The provided API key was either missing or invalid.\n" + " The provided API key was either missing or invalid.\n" f"Set RAMALAMA_API_KEY or ramalama.provider..api_key." ) try: diff --git a/ramalama/cli.py b/ramalama/cli.py index 81c721ffb..48fbc4e97 100644 --- a/ramalama/cli.py +++ b/ramalama/cli.py @@ -713,7 +713,6 @@ def info_cli(args: DefaultArgsType) -> None: message = f"{name}={source} ({config_source})" print(message) return - info: dict[str, Any] = { "Accelerator": get_accel(), "Config": load_file_config(), @@ -950,6 +949,8 @@ def push_cli(args): if args.TARGET: shortnames = get_shortnames() + if source_model.type == "OCI": + raise ValueError(f"converting from an OCI based image {args.SOURCE} is not supported") target = shortnames.resolve(args.TARGET) target_model = New(target, args) @@ -1642,12 +1643,7 @@ def _rm_model(models, args): try: m = New(model, args) - if m.remove(args): - continue - # Failed to remove and might be OCI so attempt to remove OCI - if args.ignore: - _rm_oci_model(model, args) - continue + m.remove(args) except (KeyError, subprocess.CalledProcessError) as e: for prefix in MODEL_TYPES: if model.startswith(prefix + "://"): @@ -1723,7 +1719,6 @@ def inspect_cli(args): if not args.MODEL: parser = get_parser() parser.error("inspect requires MODEL") - args.pull = "never" model = New(args.MODEL, args) diff --git a/ramalama/common.py b/ramalama/common.py index d8d0fe31c..d53be29c7 100644 --- a/ramalama/common.py +++ b/ramalama/common.py @@ -12,7 +12,9 @@ import subprocess import sys from collections.abc import Callable, Sequence +from dataclasses import dataclass from functools import lru_cache +from pathlib import Path from typing import IO, TYPE_CHECKING, Any, Literal, Optional, Protocol, TypeAlias, TypedDict, cast, get_args import yaml @@ -294,10 +296,11 @@ def genname(): return "ramalama-" + "".join(random.choices(string.ascii_letters + string.digits, k=10)) -def engine_version(engine: SUPPORTED_ENGINES) -> str: +@lru_cache +def engine_version(engine: SUPPORTED_ENGINES | Path | str) -> SemVer: # Create manifest list for target with imageid cmd_args = [str(engine), "version", "--format", "{{ .Client.Version }}"] - return run_cmd(cmd_args, encoding="utf-8").stdout.strip() + return SemVer.parse(run_cmd(cmd_args, encoding="utf-8").stdout.strip()) class CDI_DEVICE(TypedDict): @@ -712,3 +715,36 @@ def __str__(self): def __repr__(self): return repr(self.entrypoint) + + +SEMVER_RE = re.compile( + r"^(?P0|[1-9]\d*)\." + r"(?P0|[1-9]\d*)\." + r"(?P0|[1-9]\d*)" + r"(?:-(?P" + r"(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*" + r"))?" + r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" +) + + +def parse_semver(s: str) -> SemVer: + m = SEMVER_RE.fullmatch(s) + if not m: + raise ValueError(f"Not a valid SemVer 2.0.0: {s!r}") + major = int(m.group("major")) + minor = int(m.group("minor")) + patch = int(m.group("patch")) + return SemVer(major, minor, patch) + + +@dataclass(frozen=True, order=True) +class SemVer: + major: int + minor: int + patch: int + + @classmethod + def parse(cls, s: str) -> "SemVer": + return parse_semver(s) diff --git a/ramalama/compose.py b/ramalama/compose.py index f0115ad3d..795ffe2f4 100644 --- a/ramalama/compose.py +++ b/ramalama/compose.py @@ -2,7 +2,7 @@ import os import shlex -from typing import Optional, Tuple +from typing import Optional from ramalama.common import RAG_DIR, get_accel_env_vars from ramalama.file import PlainFile @@ -13,9 +13,9 @@ class Compose: def __init__( self, model_name: str, - model_paths: Tuple[str, str], - chat_template_paths: Optional[Tuple[str, str]], - mmproj_paths: Optional[Tuple[str, str]], + model_paths: tuple[str, str], + chat_template_paths: Optional[tuple[str, str]], + mmproj_paths: Optional[tuple[str, str]], args, exec_args, ): diff --git a/ramalama/oci_tools.py b/ramalama/oci_tools.py index 82c7266a5..b965799ce 100644 --- a/ramalama/oci_tools.py +++ b/ramalama/oci_tools.py @@ -1,11 +1,13 @@ import json import subprocess +from dataclasses import dataclass from datetime import datetime +from itertools import chain from typing import TypedDict import ramalama.annotations as annotations from ramalama.arg_types import EngineArgType -from ramalama.common import engine_version, run_cmd +from ramalama.common import SemVer, engine_version, run_cmd from ramalama.logger import logger ocilabeltype = "org.containers.type" @@ -69,9 +71,15 @@ def list_artifacts(args: EngineArgType): except subprocess.CalledProcessError as e: logger.debug(e) return [] + if output == "": + return [] - artifacts = json.loads(f"[{output[:-1]}]") - models: list[ListModelResponse] = [] + try: + artifacts = json.loads(f"[{output[:-1]}]") + except json.JSONDecodeError: + return [] + + models = [] for artifact in artifacts: conman_args = [ args.engine, @@ -79,11 +87,17 @@ def list_artifacts(args: EngineArgType): "inspect", artifact["ID"], ] - output = run_cmd(conman_args).stdout.decode("utf-8").strip() + try: + output = run_cmd(conman_args, ignore_stderr=True).stdout.decode("utf-8").strip() + except Exception: + continue if output == "": continue - inspect = json.loads(output) + try: + inspect = json.loads(output) + except json.JSONDecodeError: + continue if "Manifest" not in inspect: continue if "artifactType" not in inspect["Manifest"]: @@ -103,8 +117,12 @@ def list_artifacts(args: EngineArgType): def engine_supports_manifest_attributes(engine) -> bool: if not engine or engine == "" or engine == "docker": return False - if engine == "podman" and engine_version(engine) < "5": - return False + if engine == "podman": + try: + if engine_version(engine) < SemVer(5, 0, 0): + return False + except Exception: + return False return True @@ -227,12 +245,67 @@ def list_images(args: EngineArgType) -> list[ListModelResponse]: def list_models(args: EngineArgType) -> list[ListModelResponse]: - conman = args.engine - if conman is None: + if args.engine is None: return [] - models = list_images(args) - models.extend(list_manifests(args)) - models.extend(list_artifacts(args)) + model_gen = chain(list_images(args), list_manifests(args), list_artifacts(args)) + seen: set[str] = set() + models: list[ListModelResponse] = [] + for m in model_gen: + if (name := m["name"]) in seen: + continue + seen.add(name) + models.append(m) return models + + +@dataclass(frozen=True) +class OciRef: + registry: str + repository: str + specifier: str # Either the digest or the tag + tag: str | None = None + digest: str | None = None + + def __str__(self) -> str: + if self.digest: + return f"{self.registry}/{self.repository}@{self.digest}" + return f"{self.registry}/{self.repository}:{self.tag or self.specifier}" + + @staticmethod + def from_ref_string(ref: str) -> "OciRef": + return split_oci_reference(ref) + + +def split_oci_reference(ref: str, default_registry: str = "docker.io") -> OciRef: + ref = ref.strip() + + name, digest = ref.split("@", 1) if "@" in ref else (ref, None) + + slash = name.rfind("/") + colon = name.rfind(":") + if colon > slash: + name, tag = name[:colon], name[colon + 1 :] + else: + tag = None + + parts = name.split("/", 1) + if len(parts) == 1: + registry = default_registry + repository = parts[0] + else: + first, rest = parts[0], parts[1] + if first == "localhost" or "." in first or ":" in first: + registry = first + repository = rest + else: + registry = default_registry + repository = name # keep full path + + specifier = digest or tag + if specifier is None: + tag = "latest" + specifier = tag + + return OciRef(registry=registry, repository=repository, tag=tag, digest=digest, specifier=specifier) diff --git a/ramalama/rag.py b/ramalama/rag.py index 037d0bafc..21fdc3ebb 100644 --- a/ramalama/rag.py +++ b/ramalama/rag.py @@ -15,7 +15,7 @@ from ramalama.engine import BuildEngine, Engine, is_healthy, stop_container, wait_for_healthy from ramalama.path_utils import get_container_mount_path from ramalama.transports.base import Transport -from ramalama.transports.oci import OCI +from ramalama.transports.oci.oci import OCI INPUT_DIR = "/docs" @@ -197,7 +197,6 @@ def serve(self, args: RagArgsType, cmd: list[str]): stop_container(args.model_args, args.model_args.name, remove=True) def run(self, args: RagArgsType, cmd: list[str]): - args.model_args.name = self.imodel.get_container_name(args.model_args) process = self.imodel.serve_nonblocking(args.model_args, self.model_cmd) rag_process = self.serve_nonblocking(args, cmd) diff --git a/ramalama/transports/base.py b/ramalama/transports/base.py index df0a4fd98..a45df5f5c 100644 --- a/ramalama/transports/base.py +++ b/ramalama/transports/base.py @@ -8,8 +8,7 @@ import sys import time from abc import ABC, abstractmethod -from functools import cached_property -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Optional, TypeGuard from ramalama import chat from ramalama.common import ContainerEntryPoint @@ -28,6 +27,7 @@ if TYPE_CHECKING: from ramalama.chat import ChatOperationalArgs + from ramalama.transports.oci.oci import OCI from datetime import datetime, timezone @@ -84,6 +84,16 @@ def __str__(self): return f"No ref file found for '{self.model}'. Please pull model." +def is_oci(transport: "Transport") -> TypeGuard["OCI"]: + """ + Type guard to determine whether a given transport is an OCI transport. + + This assumes the transport exposes a `model_type` attribute and that + OCI-based transports set `model_type` to the string `"oci"`. + """ + return getattr(transport, "model_type", None) == "oci" + + def trim_model_name(model): if model.startswith("huggingface://"): model = model.replace("huggingface://", "hf://", 1) @@ -167,10 +177,6 @@ def __init__(self, model: str, model_store_path: str): self.default_image = accel_image(get_config()) self.draft_model: Transport | None = None - @cached_property - def artifact(self) -> bool: - return self.is_artifact() - def extract_model_identifiers(self): model_name = self.model model_tag = "latest" @@ -271,16 +277,9 @@ def _get_entry_model_path(self, use_container: bool, should_generate: bool, dry_ if dry_run: return "/path/to/model" - if self.model_type == 'oci': + if is_oci(self): if use_container or should_generate: - if getattr(self, "artifact", False): - artifact_name_method = getattr(self, "artifact_name", None) - if artifact_name_method: - try: - return f"{MNT_DIR}/{artifact_name_method()}" - except subprocess.CalledProcessError: - pass - return f"{MNT_DIR}/model.file" + return self.entrypoint_path() else: return f"oci://{self.model}" @@ -434,12 +433,12 @@ def setup_mounts(self, args): return if self.model_type == 'oci': - if self.engine.use_podman: + if self.engine.use_podman or self.strategy.kind == "artifact": mount_cmd = self.mount_cmd() elif self.engine.use_docker: output_filename = self._get_entry_model_path(args.container, True, args.dryrun) volume = populate_volume_from_image(self, args, os.path.basename(output_filename)) - mount_cmd = f"--mount=type=volume,src={volume},dst={MNT_DIR},readonly" + mount_cmd = self.mount_cmd(volume, MNT_DIR) else: raise NotImplementedError(f"No compatible oci mount method for engine: {self.engine.args.engine}") self.engine.add([mount_cmd]) @@ -792,12 +791,19 @@ def serve(self, args, cmd: list[str]): try: self.execute_command(cmd, args) except Exception as e: - self._cleanup_server_process(args.server_process) + self._cleanup_server_process(getattr(args, 'server_process', None)) raise e def quadlet(self, model_paths, chat_template_paths, mmproj_paths, args, exec_args, output_dir, model_parts=None): quadlet = Quadlet( - self.model_name, model_paths, chat_template_paths, mmproj_paths, args, exec_args, self.artifact, model_parts + self.model_name, + model_paths, + chat_template_paths, + mmproj_paths, + args, + exec_args, + self.is_artifact, + model_parts, ) for generated_file in quadlet.generate(): generated_file.write(output_dir) @@ -805,16 +811,16 @@ def quadlet(self, model_paths, chat_template_paths, mmproj_paths, args, exec_arg def quadlet_kube( self, model_paths, chat_template_paths, mmproj_paths, args, exec_args, output_dir, model_parts=None ): - kube = Kube(self.model_name, model_paths, chat_template_paths, mmproj_paths, args, exec_args, self.artifact) + kube = Kube(self.model_name, model_paths, chat_template_paths, mmproj_paths, args, exec_args, self.is_artifact) kube.generate().write(output_dir) quadlet = Quadlet( - kube.name, model_paths, chat_template_paths, mmproj_paths, args, exec_args, self.artifact, model_parts + kube.name, model_paths, chat_template_paths, mmproj_paths, args, exec_args, self.is_artifact, model_parts ) quadlet.kube().write(output_dir) def kube(self, model_paths, chat_template_paths, mmproj_paths, args, exec_args, output_dir): - kube = Kube(self.model_name, model_paths, chat_template_paths, mmproj_paths, args, exec_args, self.artifact) + kube = Kube(self.model_name, model_paths, chat_template_paths, mmproj_paths, args, exec_args, self.is_artifact) kube.generate().write(output_dir) def compose(self, model_paths, chat_template_paths, mmproj_paths, args, exec_args, output_dir): @@ -865,6 +871,7 @@ def print_pull_message(self, model_name) -> None: perror(f"Downloading {model_name} ...") perror(f"Trying to pull {model_name} ...") + @property def is_artifact(self) -> bool: return False diff --git a/ramalama/transports/oci/__init__.py b/ramalama/transports/oci/__init__.py new file mode 100644 index 000000000..652aded1a --- /dev/null +++ b/ramalama/transports/oci/__init__.py @@ -0,0 +1,3 @@ +from .oci import OCI + +__all__ = ["OCI"] diff --git a/ramalama/transports/oci.py b/ramalama/transports/oci/oci.py similarity index 74% rename from ramalama/transports/oci.py rename to ramalama/transports/oci/oci.py index 6459bc812..dc51fa5b8 100644 --- a/ramalama/transports/oci.py +++ b/ramalama/transports/oci/oci.py @@ -4,17 +4,19 @@ import shutil import subprocess import tempfile +from functools import cached_property from textwrap import dedent -from typing import Tuple import ramalama.annotations as annotations -from ramalama.common import MNT_DIR, engine_version, exec_cmd, perror, run_cmd, set_accel_env_vars +from ramalama.common import MNT_DIR, exec_cmd, perror, run_cmd, set_accel_env_vars from ramalama.engine import BuildEngine, Engine, dry_run -from ramalama.oci_tools import engine_supports_manifest_attributes -from ramalama.transports.base import NoRefFileFound, Transport +from ramalama.oci_tools import OciRef, engine_supports_manifest_attributes +from ramalama.transports.base import Transport +from ramalama.transports.oci import spec as oci_spec +from ramalama.transports.oci.strategies import BaseOCIStrategy +from ramalama.transports.oci.strategy import OCIStrategyFactory prefix = "oci://" - ociimage_raw = "org.containers.type=ai.image.model.raw" ociimage_car = "org.containers.type=ai.image.model.car" @@ -33,6 +35,15 @@ def __init__(self, model: str, model_store_path: str, conman: str, ignore_stderr raise ValueError("RamaLama OCI Images requires a container engine") self.conman = conman self.ignore_stderr = ignore_stderr + self.ref: OciRef = OciRef.from_ref_string(self.model.removeprefix(prefix)) + + @cached_property + def strategy(self) -> BaseOCIStrategy: + return OCIStrategyFactory(self.conman, model_store=self.model_store).resolve(self.ref) + + @property + def is_artifact(self) -> bool: + return self.strategy.kind == 'artifact' def login(self, args): conman_args = [self.conman, "login"] @@ -55,6 +66,27 @@ def logout(self, args): conman_args.append(self.model) return exec_cmd(conman_args) + def _target_decompose(self, model): + model = model.removeprefix(prefix) + # Remove the prefix and extract target details + try: + registry, reference = model.split("/", 1) + except Exception: + raise KeyError( + "You must specify a registry for the model in the form " + f"'oci://registry.acme.org/ns/repo:tag', got instead: {self.model}" + ) + + reference_dir = reference.replace(":", "/") + return registry, reference, reference_dir + + def _has_local_snapshot(self) -> bool: + try: + _, cached_files, complete = self.model_store.get_cached_files(self.model_tag) + return complete and bool(cached_files) + except Exception: + return False + def _convert_to_gguf(self, outdir, source_model, args): with tempfile.TemporaryDirectory(prefix="RamaLama_convert_src_") as srcdir: ref_file = source_model.model_store.get_ref_file(source_model.model_tag) @@ -99,6 +131,7 @@ def build_image(self, cfile, contextdir, args): else: parent = "scratch" label = ociimage_raw + footer = dedent(f""" FROM {parent} LABEL {label} @@ -170,7 +203,7 @@ def tag(self, imageid, target, args): else: run_cmd(cmd_args) - def _rm_artifact(self, ignore): + def _rm_artifact(self, ignore) -> None: rm_cmd = [ self.conman, "artifact", @@ -186,17 +219,21 @@ def _rm_artifact(self, ignore): ) def _add_artifact(self, create, name, path, file_name) -> None: + filepath = oci_spec.normalize_layer_filepath(file_name) + metadata = oci_spec.FileMetadata.from_path(path, name=file_name).to_json() cmd = [ self.conman, "artifact", "add", "--annotation", - f"org.opencontainers.image.title={file_name}", + f"{oci_spec.LAYER_ANNOTATION_FILEPATH}={filepath}", + "--annotation", + f"{oci_spec.LAYER_ANNOTATION_FILE_METADATA}={metadata}", + "--annotation", + f"{oci_spec.LAYER_ANNOTATION_FILE_MEDIATYPE_UNTESTED}=true", ] if create: - if self.conman == "podman" and engine_version("podman") >= "5.7.0": - cmd.append("--replace") - cmd.extend(["--type", annotations.ArtifactTypeModelManifest]) + cmd.extend(["--replace", "--type", annotations.ArtifactTypeModelManifest]) else: cmd.extend(["--append"]) @@ -331,104 +368,33 @@ def push(self, source_model, args): raise e def pull(self, args): - if not args.engine: - raise NotImplementedError("OCI images require a container engine like Podman or Docker") - - conman_args = [args.engine, "pull"] + conman_args = [] if args.quiet: conman_args.extend(['--quiet']) else: - # Write message to stderr perror(f"Downloading {self.model} ...") if str(args.tlsverify).lower() == "false": conman_args.extend([f"--tls-verify={args.tlsverify}"]) if args.authfile: conman_args.extend([f"--authfile={args.authfile}"]) - conman_args.extend([self.model]) - run_cmd(conman_args, ignore_stderr=self.ignore_stderr) - def remove(self, args) -> bool: - if self.conman is None: - raise NotImplementedError("OCI Images require a container engine") + self.strategy.pull(self.ref, cmd_args=conman_args) - try: - conman_args = [self.conman, "manifest", "rm", self.model] - run_cmd(conman_args, ignore_stderr=True) - except subprocess.CalledProcessError: - try: - conman_args = [self.conman, "rmi", f"--force={args.ignore}", self.model] - run_cmd(conman_args, ignore_stderr=True) - except subprocess.CalledProcessError: - try: - self._rm_artifact(args.ignore) - except subprocess.CalledProcessError: - raise KeyError(f"Model '{self.model}' not found") - return True + def remove(self, args) -> bool: + cmd_args = [] + ignore = getattr(args, "ignore", False) + if ignore: + if self.strategy.kind == "artifact": + cmd_args.append("--ignore") + else: + cmd_args.append(f"--force={ignore}") + return self.strategy.remove(self.ref, cmd_args=cmd_args) def exists(self) -> bool: - if self.conman is None: - return False - - conman_args = [self.conman, "image", "inspect", self.model] - try: - run_cmd(conman_args, ignore_stderr=True) - return True - except Exception: - conman_args = [self.conman, "artifact", "inspect", self.model] - try: - run_cmd(conman_args, ignore_stderr=True) - return True - except Exception: - return False + return self.strategy.exists(self.ref) - def _inspect( - self, - show_all: bool = False, - show_all_metadata: bool = False, - get_field: str = "", - as_json: bool = False, - dryrun: bool = False, - ) -> Tuple[str, str]: - out = super().inspect(show_all, show_all_metadata, get_field, dryrun, as_json) - if as_json: - out_data = json.loads(out) - else: - out_data = out - conman_args = [self.conman, "image", "inspect", self.model] - oci_type = "Image" - try: - inspect_output = run_cmd(conman_args, ignore_stderr=True).stdout.decode('utf-8').strip() - # podman image inspect returns a list of objects - inspect_data = json.loads(inspect_output) - if as_json and inspect_data: - out_data.update(inspect_data[0]) - except Exception as e: - conman_args = [self.conman, "artifact", "inspect", self.model] - try: - inspect_output = run_cmd(conman_args, ignore_stderr=True).stdout.decode('utf-8').strip() - - # podman artifact inspect returns a single object - if as_json: - out_data.update(json.loads(inspect_output)) - oci_type = "Artifact" - except Exception: - raise e - - if as_json: - return json.dumps(out_data), oci_type - return out_data, oci_type - - def artifact_name(self) -> str: - conman_args = [ - self.conman, - "artifact", - "inspect", - "--format", - '{{index .Manifest.Annotations "org.opencontainers.image.title" }}', - self.model, - ] - - return run_cmd(conman_args, ignore_stderr=True).stdout.decode('utf-8').strip() + def entrypoint_path(self, mount_dir: str | None = None) -> str: + return self.strategy.entrypoint_path(self.ref, mount_dir=mount_dir) def inspect( self, @@ -438,21 +404,29 @@ def inspect( as_json: bool = False, dryrun: bool = False, ) -> None: - out, type = self._inspect(show_all, show_all_metadata, get_field, as_json, dryrun) - if as_json: + out = super().inspect( + show_all=show_all, show_all_metadata=show_all_metadata, get_field=get_field, dryrun=dryrun, as_json=as_json + ) + + if get_field or dryrun: print(out) - else: - print(f"{out} Type: {type}") + return - def is_artifact(self) -> bool: - try: - _, oci_type = self._inspect() - except (NoRefFileFound, subprocess.CalledProcessError): - return False - return oci_type == "Artifact" + if as_json: + out = json.loads(out) + + # docker/podman image inspect returns a list of objects + inspect = json.loads(self.strategy.inspect(self.ref)) + if self.strategy.kind == "image": + inspect = inspect[0] if inspect else {} - def mount_cmd(self): - if self.artifact: - return f"--mount=type=artifact,src={self.model},destination={MNT_DIR}" + print(json.dumps(out | inspect, sort_keys=True, indent=4)) else: - return f"--mount=type=image,src={self.model},destination={MNT_DIR},subpath=/models,rw=false" + print(f"{out} Type: {self.strategy.kind.capitalize()}") + + def mount_cmd(self, src: str | OciRef | None = None, dest: str | None = None): + if isinstance(src, OciRef): + return self.strategy.mount_arg(src, dest) + if src is not None: + return f"--mount=type=volume,src={src},dst={dest or MNT_DIR},readonly" + return self.strategy.mount_arg(self.ref, dest) diff --git a/ramalama/transports/oci/oci_artifact.py b/ramalama/transports/oci/oci_artifact.py new file mode 100644 index 000000000..b8cc72e28 --- /dev/null +++ b/ramalama/transports/oci/oci_artifact.py @@ -0,0 +1,249 @@ +import hashlib +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Iterable +from tempfile import NamedTemporaryFile +from typing import Any + +from ramalama.common import perror +from ramalama.logger import logger +from ramalama.model_store.snapshot_file import SnapshotFile, SnapshotFileType +from ramalama.model_store.store import ModelStore +from ramalama.oci_tools import split_oci_reference +from ramalama.transports.oci import spec as oci_spec + +OCI_ARTIFACT_MEDIA_TYPES = { + oci_spec.CNAI_ARTIFACT_TYPE, + oci_spec.CNAI_CONFIG_MEDIA_TYPE, +} + +MANIFEST_ACCEPT_HEADERS = [ + "application/vnd.oci.artifact.manifest.v1+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", +] + +BLOB_CHUNK_SIZE = 1024 * 1024 + + +def get_snapshot_file_type(name: str, media_type: str) -> SnapshotFileType: + if name.endswith(".gguf") or media_type.endswith(".gguf"): + return SnapshotFileType.GGUFModel + if name.endswith(".safetensors") or media_type.endswith("safetensors"): + return SnapshotFileType.SafetensorModel + if name.endswith(".mmproj"): + return SnapshotFileType.Mmproj + if name.endswith(".json"): + return SnapshotFileType.Other + return SnapshotFileType.Other + + +class RegistryBlobSnapshotFile(SnapshotFile): + def __init__( + self, + client: "OCIRegistryClient", + digest: str, + name: str, + media_type: str, + required: bool = True, + ): + file_type = get_snapshot_file_type(name, media_type) + super().__init__( + url="", + header={}, + hash=digest, + name=name, + type=file_type, + should_show_progress=False, + should_verify_checksum=False, + required=required, + ) + self.client = client + self.digest = digest + + def download(self, blob_file_path: str, snapshot_dir: str) -> str: + if not os.path.exists(blob_file_path): + self.client.download_blob(self.digest, blob_file_path) + else: + logger.debug(f"Using cached blob for descriptor {self.digest}") + return os.path.relpath(blob_file_path, start=snapshot_dir) + + +class OCIRegistryClient: + def __init__( + self, + registry: str, + repository: str, + reference: str, + ): + self.registry = registry + self.repository = repository + self.reference = reference + self.base_url = f"https://{self.registry}/v2/{self.repository}" + + self._bearer_token: str | None = None + + def get_manifest(self) -> tuple[dict[str, Any], str]: + headers = {"Accept": ",".join(MANIFEST_ACCEPT_HEADERS)} + response = self._open(f"{self.base_url}/manifests/{self.reference}", headers=headers) + manifest_bytes = response.read() + digest = response.headers.get("Docker-Content-Digest") + if not digest: + digest = f"sha256:{hashlib.sha256(manifest_bytes).hexdigest()}" + manifest = json.loads(manifest_bytes.decode("utf-8")) + logger.debug(f"Fetched manifest digest {digest} for {self.repository}@{self.reference}") + return manifest, digest + + def download_blob(self, digest: str, dest_path: str) -> None: + url = f"{self.base_url}/blobs/{digest}" + response = self._open(url) + + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + hash_algo, _, expected_hash = digest.partition(":") + if hash_algo != "sha256": + logger.debug(f"Unsupported digest algorithm {hash_algo}, skipping verification.") + + hasher = hashlib.sha256() + + temp_path = None + try: + with NamedTemporaryFile(delete=False, dir=os.path.dirname(dest_path) or ".") as out_file: + temp_path = out_file.name + while True: + chunk = response.read(BLOB_CHUNK_SIZE) + if not chunk: + break + out_file.write(chunk) + if hash_algo == "sha256": + hasher.update(chunk) + + if hash_algo == "sha256" and (actual_hash := hasher.hexdigest()) != expected_hash: + raise ValueError(f"Digest mismatch for {digest}: expected {expected_hash}, got {actual_hash}") + finally: + if temp_path is not None: + try: + os.remove(temp_path) + except FileNotFoundError: + pass + raise + + def _prepare_headers(self, headers: dict[str, str] | None = None) -> dict[str, str]: + final_headers = dict() if headers is None else headers.copy() + if self._bearer_token is not None: + final_headers.setdefault("Authorization", f"Bearer {self._bearer_token}") + + return final_headers + + def _open(self, url: str, headers: dict[str, str] | None = None): + req = urllib.request.Request(url, headers=self._prepare_headers(headers)) + try: + return urllib.request.urlopen(req, timeout=60) + except urllib.error.HTTPError as exc: + if exc.code == 401: + www_authenticate = exc.headers.get("WWW-Authenticate", "") + if "Bearer" in www_authenticate: + token = self._request_bearer_token(www_authenticate) + if token: + self._bearer_token = token + req = urllib.request.Request(url, headers=self._prepare_headers(headers)) + return urllib.request.urlopen(req, timeout=60) + raise + + def _request_bearer_token(self, challenge: str) -> str | None: + scheme, _, params = challenge.partition(" ") + if scheme.lower() != "bearer": + return None + + auth_params: dict[str, str] = {} + for item in params.split(","): + if "=" not in item: + continue + key, value = item.strip().split("=", 1) + auth_params[key.lower()] = value.strip('"') + + realm = auth_params.get("realm") + if not realm: + return None + + query = {} + if "service" in auth_params: + query["service"] = auth_params["service"] + if "scope" in auth_params: + query["scope"] = auth_params["scope"] + else: + query["scope"] = f"repository:{self.repository}:pull" + token_url = realm + if query: + token_url = f"{realm}?{urllib.parse.urlencode(query)}" + + req_headers = {"User-Agent": "ramalama/oci-artifact"} + + request = urllib.request.Request(token_url, headers=req_headers) + try: + response = urllib.request.urlopen(request) + data = json.loads(response.read().decode("utf-8")) + token = data.get("token") or data.get("access_token") + return token + except urllib.error.URLError as exc: + perror(f"Failed to obtain registry token from {token_url}: {exc}") + return None + + +def _build_snapshot_files(client: OCIRegistryClient, manifest: dict[str, Any]) -> Iterable[SnapshotFile]: + descriptors = [*manifest.get("layers", []), *manifest.get("blobs", [])] + for descriptor in descriptors: + digest = descriptor.get("digest") + if not digest: + continue + + annotations = descriptor.get("annotations") or {} + filepath = annotations.get(oci_spec.LAYER_ANNOTATION_FILEPATH) + + metadata_value = annotations.get(oci_spec.LAYER_ANNOTATION_FILE_METADATA) + if metadata_value is not None: + metadata = oci_spec.FileMetadata.from_json(metadata_value) + if filepath is None: + filepath = metadata.name + + if filepath is None: + raise ValueError(f"Layer {digest} missing {oci_spec.LAYER_ANNOTATION_FILEPATH}") + filepath = oci_spec.normalize_layer_filepath(filepath) + + mediatype_untested = annotations.get(oci_spec.LAYER_ANNOTATION_FILE_MEDIATYPE_UNTESTED) + if mediatype_untested is not None and mediatype_untested not in {"true", "false"}: + raise ValueError("layer annotation mediatype.untested must be 'true' or 'false'") + + media_type = descriptor.get("mediaType", "") + yield RegistryBlobSnapshotFile(client, digest, filepath, media_type) + + +def download_oci_artifact(*, reference: str, model_store: ModelStore, model_tag: str) -> bool: + oci_ref = split_oci_reference(reference) + + client = OCIRegistryClient(oci_ref.registry, oci_ref.repository, oci_ref.specifier) + + try: + manifest, manifest_digest = client.get_manifest() + except urllib.error.HTTPError as exc: + perror(f"Failed to fetch manifest for {oci_ref.registry}/{reference}: {exc}") + return False + + artifact_type = manifest.get("artifactType") or manifest.get("config", {}).get("mediaType", "") + if not oci_spec.is_cncf_artifact_manifest(manifest): + logger.debug(f"Manifest artifact type '{artifact_type}' not in supported set {OCI_ARTIFACT_MEDIA_TYPES}") + return False + + try: + snapshot_files = list(_build_snapshot_files(client, manifest)) + except ValueError as exc: + perror(str(exc)) + return False + if not snapshot_files: + perror("Artifact manifest contained no downloadable blobs.") + return False + + model_store.new_snapshot(model_tag, manifest_digest, snapshot_files) + return True diff --git a/ramalama/transports/oci/resolver.py b/ramalama/transports/oci/resolver.py new file mode 100644 index 000000000..d6dee56c6 --- /dev/null +++ b/ramalama/transports/oci/resolver.py @@ -0,0 +1,103 @@ +import os +from collections.abc import Callable +from typing import Literal + +from ramalama.common import run_cmd +from ramalama.model_store.store import ModelStore +from ramalama.oci_tools import OciRef +from ramalama.transports.oci import spec as oci_spec +from ramalama.transports.oci.oci_artifact import OCIRegistryClient + +ReferenceKind = Literal["artifact", "image", "unknown"] + + +def _format_oci_reference(oci_ref: OciRef) -> str: + repository = f"{oci_ref.registry}/{oci_ref.repository}" + if oci_ref.digest: + return f"{repository}@{oci_ref.digest}" + tag = oci_ref.tag or "latest" + return f"{repository}:{tag}" + + +def fetch_manifest(oci_ref: OciRef) -> dict | None: + client = OCIRegistryClient(oci_ref.registry, oci_ref.repository, oci_ref.specifier) + try: + manifest, _ = client.get_manifest() + return manifest + except Exception: + return None + + +def manifest_kind(oci_ref: OciRef) -> ReferenceKind: + manifest = fetch_manifest(oci_ref) + if not manifest: + return "unknown" + if oci_spec.is_cncf_artifact_manifest(manifest): + return "artifact" + return "image" + + +def engine_artifact_exists(engine: str, oci_ref: OciRef, runner: Callable | None = None) -> bool: + runner = runner or run_cmd + try: + runner([engine, "artifact", "inspect", _format_oci_reference(oci_ref)], ignore_stderr=True) + return True + except Exception: + return False + + +def engine_image_exists(engine: str, oci_ref: OciRef, runner: Callable | None = None) -> bool: + runner = runner or run_cmd + try: + runner([engine, "image", "inspect", _format_oci_reference(oci_ref)], ignore_stderr=True) + return True + except Exception: + return False + + +def resolve_engine_kind(engine: str, oci_ref: OciRef, runner: Callable | None = None) -> ReferenceKind: + if not engine: + return "unknown" + engine_name = os.path.basename(engine) + if engine_name == "podman": + if engine_artifact_exists(engine, oci_ref, runner=runner): + return "artifact" + elif engine_image_exists(engine, oci_ref, runner=runner): + return "image" + + if engine_name == "docker": + if engine_image_exists(engine, oci_ref, runner=runner): + return "image" + return "unknown" + + +def model_tag_from_reference(oci_ref: OciRef) -> str: + return oci_ref.specifier + + +def model_store_has_snapshot(model_store: ModelStore, oci_ref: OciRef) -> bool: + model_tag = model_tag_from_reference(oci_ref) + try: + _, cached_files, complete = model_store.get_cached_files(model_tag) + return complete and bool(cached_files) + except Exception: + return False + + +class OCITypeResolver: + def __init__(self, engine: str, model_store: ModelStore | None = None, runner: Callable | None = None): + self.engine = engine + self.model_store = model_store + self.runner = runner or run_cmd + + def resolve(self, reference: OciRef) -> ReferenceKind: + if self.model_store and model_store_has_snapshot(self.model_store, reference): + return "artifact" + + engine_kind = resolve_engine_kind(self.engine, reference, runner=self.runner) + if engine_kind != "unknown": + return engine_kind + + if "." in reference.registry or ":" in reference.registry or reference.registry == "localhost": + return manifest_kind(reference) + return "unknown" diff --git a/ramalama/transports/oci/spec.py b/ramalama/transports/oci/spec.py new file mode 100644 index 000000000..543224dfe --- /dev/null +++ b/ramalama/transports/oci/spec.py @@ -0,0 +1,278 @@ +import json +import os +import stat +import tarfile +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +# CNAI_ARTIFACT_TYPE is the media type for a model artifact manifest. +CNAI_ARTIFACT_TYPE = "application/vnd.cncf.model.manifest.v1+json" + +# CNAI_CONFIG_MEDIA_TYPE is the media type for a model config object. +CNAI_CONFIG_MEDIA_TYPE = "application/vnd.cncf.model.config.v1+json" + +# OCI_MANIFEST_MEDIA_TYPE is the standard OCI image manifest media type. +OCI_MANIFEST_MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json" + +# LAYER_ANNOTATION_FILEPATH specifies the file path of the layer (string). +LAYER_ANNOTATION_FILEPATH = "org.cncf.model.filepath" + +# LAYER_ANNOTATION_FILE_METADATA specifies file metadata JSON for the layer (string). +LAYER_ANNOTATION_FILE_METADATA = "org.cncf.model.file.metadata+json" + +# LAYER_ANNOTATION_FILE_MEDIATYPE_UNTESTED indicates media type classification is untested (string). +LAYER_ANNOTATION_FILE_MEDIATYPE_UNTESTED = "org.cncf.model.file.mediatype.untested" + +# ALLOWED_LAYER_MEDIA_TYPES contains CNAI layer media types from the model format spec. +ALLOWED_LAYER_MEDIA_TYPES = { + "application/vnd.cncf.model.weight.v1.raw", + "application/vnd.cncf.model.weight.v1.tar", + "application/vnd.cncf.model.weight.v1.tar+gzip", + "application/vnd.cncf.model.weight.v1.tar+zstd", + "application/vnd.cncf.model.weight.config.v1.raw", + "application/vnd.cncf.model.weight.config.v1.tar", + "application/vnd.cncf.model.weight.config.v1.tar+gzip", + "application/vnd.cncf.model.weight.config.v1.tar+zstd", + "application/vnd.cncf.model.doc.v1.raw", + "application/vnd.cncf.model.doc.v1.tar", + "application/vnd.cncf.model.doc.v1.tar+gzip", + "application/vnd.cncf.model.doc.v1.tar+zstd", + "application/vnd.cncf.model.code.v1.raw", + "application/vnd.cncf.model.code.v1.tar", + "application/vnd.cncf.model.code.v1.tar+gzip", + "application/vnd.cncf.model.code.v1.tar+zstd", + "application/vnd.cncf.model.dataset.v1.raw", + "application/vnd.cncf.model.dataset.v1.tar", + "application/vnd.cncf.model.dataset.v1.tar+gzip", + "application/vnd.cncf.model.dataset.v1.tar+zstd", +} + +_MEDIATYPE_UNTESTED_VALUES = {"true", "false"} + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def _require_str(value: Any, message: str) -> str: + if not isinstance(value, str) or not value: + raise ValueError(message) + return value + + +def _require_int(value: Any, message: str) -> int: + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(message) + return value + + +def _typeflag_for_mode(mode: int) -> int: + if stat.S_ISDIR(mode): + return ord(tarfile.DIRTYPE) + if stat.S_ISLNK(mode): + return ord(tarfile.SYMTYPE) + return ord(tarfile.REGTYPE) + + +def normalize_layer_filepath(value: str) -> str: + value = _require_str(value, "layer annotation filepath must be a non-empty string") + if os.path.isabs(value): + raise ValueError("layer annotation filepath must be relative") + normalized = os.path.normpath(value).lstrip(os.sep) + if normalized in {"", "."} or normalized.startswith(".."): + raise ValueError("layer annotation filepath must not escape the layer") + return normalized + + +@dataclass(frozen=True) +class FileMetadata: + name: str + mode: int + uid: int + gid: int + size: int + mtime: str + typeflag: int + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "FileMetadata": + if not isinstance(data, dict): + raise ValueError("file metadata must be a JSON object") + name = _require_str(data.get("name"), "file metadata name is required") + mode = _require_int(data.get("mode"), "file metadata mode is required") + uid = _require_int(data.get("uid"), "file metadata uid is required") + gid = _require_int(data.get("gid"), "file metadata gid is required") + size = _require_int(data.get("size"), "file metadata size is required") + mtime = _require_str(data.get("mtime"), "file metadata mtime is required") + typeflag = _require_int(data.get("typeflag"), "file metadata typeflag is required") + return cls( + name=name, + mode=mode, + uid=uid, + gid=gid, + size=size, + mtime=mtime, + typeflag=typeflag, + ) + + @classmethod + def from_json(cls, value: str) -> "FileMetadata": + try: + data = json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError("file metadata annotation must be valid JSON") from exc + return cls.from_dict(data) + + @classmethod + def from_path(cls, path: str, *, name: str | None = None) -> "FileMetadata": + stat_result = os.stat(path, follow_symlinks=False) + mtime = datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z") + return cls( + name=name or os.path.basename(path), + mode=stat.S_IMODE(stat_result.st_mode), + uid=stat_result.st_uid, + gid=stat_result.st_gid, + size=stat_result.st_size, + mtime=mtime, + typeflag=_typeflag_for_mode(stat_result.st_mode), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "mode": self.mode, + "uid": self.uid, + "gid": self.gid, + "size": self.size, + "mtime": self.mtime, + "typeflag": self.typeflag, + } + + def to_json(self) -> str: + return json.dumps(self.to_dict(), separators=(",", ":")) + + +def is_cncf_artifact_manifest(manifest: dict[str, Any]) -> bool: + artifact_type = manifest.get("artifactType") + config_media = (manifest.get("config") or {}).get("mediaType", "") + if artifact_type == CNAI_ARTIFACT_TYPE or config_media == CNAI_CONFIG_MEDIA_TYPE: + return True + layers = manifest.get("layers") or manifest.get("blobs") or [] + return any(layer.get("mediaType") in ALLOWED_LAYER_MEDIA_TYPES for layer in layers) + + +@dataclass +class Descriptor: + media_type: str + digest: str + size: int + annotations: dict[str, str] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: dict[str, Any], *, allowed_media_types: set[str] | None = None) -> "Descriptor": + media_type = _require_str(data.get("mediaType"), "descriptor mediaType is required") + digest = _require_str(data.get("digest"), "descriptor digest is required") + size = data.get("size") + + if size is None: + raise ValueError("descriptor size is required") + + if allowed_media_types is not None: + _require( + media_type in allowed_media_types, + f"descriptor mediaType '{media_type}' not in allowed set", + ) + + annotations = data.get("annotations") or {} + if not isinstance(annotations, dict): + raise ValueError("descriptor annotations must be a map") + for key, value in annotations.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise ValueError("descriptor annotations must be a string map") + return cls(media_type=media_type, digest=digest, size=int(size), annotations=annotations) + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = { + "mediaType": self.media_type, + "digest": self.digest, + "size": self.size, + } + if self.annotations: + data["annotations"] = self.annotations + return data + + def filepath(self) -> str | None: + value = self.annotations.get(LAYER_ANNOTATION_FILEPATH) + if value is None: + return None + return normalize_layer_filepath(value) + + def file_metadata(self) -> FileMetadata | None: + value = self.annotations.get(LAYER_ANNOTATION_FILE_METADATA) + if value is None: + return None + return FileMetadata.from_json(value) + + def media_type_untested(self) -> bool | None: + value = self.annotations.get(LAYER_ANNOTATION_FILE_MEDIATYPE_UNTESTED) + if value is None: + return None + if value not in _MEDIATYPE_UNTESTED_VALUES: + raise ValueError("layer annotation mediatype.untested must be 'true' or 'false'") + return value == "true" + + +@dataclass +class Manifest: + schema_version: int + media_type: str + artifact_type: str + config: Descriptor + layers: list[Descriptor] + annotations: dict[str, str] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Manifest": + schema_version = data.get("schemaVersion", 2) + media_type = data.get("mediaType") or OCI_MANIFEST_MEDIA_TYPE + artifact_type = _require_str(data.get("artifactType"), "artifactType is required") + _require(artifact_type == CNAI_ARTIFACT_TYPE, f"artifactType must be '{CNAI_ARTIFACT_TYPE}'") + _require(media_type == OCI_MANIFEST_MEDIA_TYPE, f"mediaType must be '{OCI_MANIFEST_MEDIA_TYPE}'") + + config_dict = data.get("config") or {} + config = Descriptor.from_dict(config_dict) + _require( + config.media_type == CNAI_CONFIG_MEDIA_TYPE, + f"config mediaType must be '{CNAI_CONFIG_MEDIA_TYPE}'", + ) + + layers_data = data.get("layers") or [] + _require( + isinstance(layers_data, list) and len(layers_data) > 0, + "layers must be a non-empty list", + ) + layers = [Descriptor.from_dict(layer, allowed_media_types=ALLOWED_LAYER_MEDIA_TYPES) for layer in layers_data] + + annotations = data.get("annotations") or {} + return cls( + schema_version=schema_version, + media_type=media_type, + artifact_type=artifact_type, + config=config, + layers=layers, + annotations=annotations, + ) + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = { + "schemaVersion": self.schema_version, + "mediaType": self.media_type, + "artifactType": self.artifact_type, + "config": self.config.to_dict(), + "layers": [layer.to_dict() for layer in self.layers], + } + if self.annotations: + data["annotations"] = self.annotations + return data diff --git a/ramalama/transports/oci/strategies.py b/ramalama/transports/oci/strategies.py new file mode 100644 index 000000000..bff2c8867 --- /dev/null +++ b/ramalama/transports/oci/strategies.py @@ -0,0 +1,258 @@ +import json +import posixpath +from abc import ABC, abstractmethod +from typing import Generic, Literal, TypeVar + +from ramalama.common import MNT_DIR, run_cmd +from ramalama.model_store.store import ModelStore +from ramalama.oci_tools import OciRef +from ramalama.path_utils import get_container_mount_path +from ramalama.transports.oci import spec as oci_spec +from ramalama.transports.oci.oci_artifact import OCIRegistryClient, download_oci_artifact + +StrategyKind = Literal["artifact", "image"] +K = TypeVar("K", bound=StrategyKind) + + +class BaseOCIStrategy(Generic[K], ABC): + """Interface for artifact handling strategies.""" + + kind: K + model_store: ModelStore | None + + def __init__(self, *, model_store: ModelStore | None = None): + self.model_store = model_store + + @abstractmethod + def pull(self, ref: OciRef, *args, **kwargs) -> None: + raise NotImplementedError + + @abstractmethod + def exists(self, ref: OciRef, *args, **kwargs) -> bool: + raise NotImplementedError + + @abstractmethod + def mount_arg(self, ref: OciRef, *args, **kwargs) -> str | None: + """Return a mount argument for container run, or None if not applicable.""" + raise NotImplementedError + + @abstractmethod + def remove(self, ref: OciRef, *args, **kwargs) -> bool: + raise NotImplementedError + + @abstractmethod + def filenames(self, ref: OciRef) -> list[str]: + """Return the list of candidate model filenames.""" + raise NotImplementedError + + @abstractmethod + def inspect(self, ref: OciRef) -> str: + """Return raw inspect output for the reference.""" + raise NotImplementedError + + def entrypoint_path(self, ref: OciRef, mount_dir: str | None = None) -> str: + mount_dir = mount_dir or MNT_DIR + filenames = self.filenames(ref) + if not filenames: + raise ValueError(f"No model files found for {ref}") + return posixpath.join(mount_dir, filenames[0]) + + +class BaseArtifactStrategy(BaseOCIStrategy[Literal['artifact']]): + kind: Literal['artifact'] = "artifact" + + def __init__(self, engine: str, *, model_store: ModelStore): + self.engine = engine + self.model_store = model_store + + +class BaseImageStrategy(BaseOCIStrategy[Literal['image']]): + kind: Literal['image'] = 'image' + + def __init__(self, engine: str, *, model_store: ModelStore): + self.engine = engine + self.model_store = model_store + + def pull(self, ref: OciRef, cmd_args: list[str] | None = None) -> None: + if cmd_args is None: + cmd_args = [] + run_cmd([self.engine, "pull", *cmd_args, str(ref)]) + + def exists(self, ref: OciRef) -> bool: + try: + run_cmd([self.engine, "image", "inspect", str(ref)], ignore_stderr=True) + return True + except Exception: + return False + + def remove(self, ref: OciRef, cmd_args: list[str] | None = None) -> bool: + if cmd_args is None: + cmd_args = [] + + try: + run_cmd([self.engine, "manifest", "rm", str(ref)], ignore_stderr=True) + return True + except Exception: + pass + try: + run_cmd([self.engine, "image", "rm", *cmd_args, str(ref)], ignore_stderr=True) + return True + except Exception: + return False + + def filenames(self, ref: OciRef) -> list[str]: + return ["model.file"] + + def inspect(self, ref: OciRef) -> str: + result = run_cmd([self.engine, "image", "inspect", str(ref)], ignore_stderr=True) + return result.stdout.decode("utf-8").strip() + + +class HttpArtifactStrategy(BaseArtifactStrategy): + """HTTP download + bind mount strategy (used for Docker or fallback).""" + + def __init__(self, engine: str = "podman", *, model_store: ModelStore): + super().__init__(engine=engine, model_store=model_store) + + def pull(self, ref: OciRef, cmd_args: list[str] | None = None) -> None: + if cmd_args is None: + cmd_args = [] + if not self.model_store: + raise ValueError("HTTP artifact strategy requires a model store") + + model_tag = ref.specifier + download_oci_artifact( + reference=str(ref), + model_store=self.model_store, + model_tag=model_tag, + ) + + def exists(self, ref: OciRef) -> bool: + if not self.model_store: + return False + try: + _, cached_files, complete = self.model_store.get_cached_files(ref.specifier) + return complete and bool(cached_files) + except Exception: + return False + + def remove(self, ref: OciRef, cmd_args: list[str] | None = None) -> bool: + if cmd_args is None: + cmd_args = [] + + if not self.model_store: + return False + try: + return self.model_store.remove_snapshot(ref.specifier) + except Exception: + return False + + def mount_arg(self, ref: OciRef, dest: str | None = None) -> str | None: + if not self.model_store: + return None + snapshot_dir = self.model_store.get_snapshot_directory_from_tag(ref.specifier) + container_path = get_container_mount_path(snapshot_dir) + + # TODO: SElinux + relabel = getattr(self, "relabel", "") + return f"--mount=type=bind,src={container_path},destination={dest or MNT_DIR},ro{relabel}" + + def filenames(self, ref: OciRef) -> list[str]: + if not self.model_store: + raise ValueError("HTTP artifact strategy requires a model store") + ref_file = self.model_store.get_ref_file(ref.specifier) + if ref_file is None or not ref_file.model_files: + raise ValueError(f"No model files found for artifact {str(ref)}") + return sorted(file.name for file in ref_file.model_files) + + def inspect(self, ref: OciRef) -> str: + client = OCIRegistryClient(ref.registry, ref.repository, ref.specifier) + manifest, _ = client.get_manifest() + return json.dumps(manifest) + + +class PodmanArtifactStrategy(BaseArtifactStrategy): + def __init__(self, engine: str = "podman", *, model_store: ModelStore): + super().__init__(engine=engine, model_store=model_store) + + def pull(self, ref: OciRef, cmd_args: list[str] | None = None) -> None: + if cmd_args is None: + cmd_args = [] + run_cmd([self.engine, "artifact", "pull", *cmd_args, str(ref)]) + + def exists(self, ref: OciRef) -> bool: + try: + run_cmd([self.engine, "artifact", "inspect", str(ref)], ignore_stderr=True) + return True + except Exception: + return False + + def mount_arg(self, ref: OciRef, dest: str | None = None) -> str: + return f"--mount=type=artifact,src={str(ref)},destination={dest or MNT_DIR}" + + def remove(self, ref: OciRef, cmd_args: list[str] | None = None) -> bool: + if cmd_args is None: + cmd_args = [] + + try: + run_cmd([self.engine, "artifact", "rm", *cmd_args, str(ref)], ignore_stderr=True) + return True + except Exception: + return False + + def filenames(self, ref: OciRef) -> list[str]: + result = run_cmd( + [self.engine, "artifact", "inspect", "--format", "{{json .Manifest}}", str(ref)], + ignore_stderr=True, + ) + + payload = result.stdout.decode("utf-8").strip() + manifest = json.loads(payload) if payload else {} + names = [] + for layer in manifest.get("layers") or manifest.get("blobs") or []: + annotations = layer.get("annotations") or {} + filepath = annotations.get(oci_spec.LAYER_ANNOTATION_FILEPATH) + metadata_value = annotations.get(oci_spec.LAYER_ANNOTATION_FILE_METADATA) + if metadata_value is not None and filepath is None: + metadata = oci_spec.FileMetadata.from_json(metadata_value) + filepath = metadata.name + if filepath is None: + digest = layer.get("digest", "unknown") + raise ValueError(f"Layer {digest} missing {oci_spec.LAYER_ANNOTATION_FILEPATH}") + names.append(oci_spec.normalize_layer_filepath(filepath)) + + if not names: + raise ValueError(f"No layer filename annotations found for {str(ref)}") + + return sorted(names) + + def entrypoint_path(self, ref: OciRef, mount_dir: str | None = None) -> str: + mount_dir = mount_dir or MNT_DIR + filenames = self.filenames(ref) + if not filenames: + raise ValueError(f"No model files found for {str(ref)}") + if len(filenames) == 1: + if posixpath.dirname(filenames[0]): + return posixpath.join(mount_dir, filenames[0]) + return mount_dir + return posixpath.join(mount_dir, filenames[0]) + + def inspect(self, ref: OciRef) -> str: + result = run_cmd([self.engine, "artifact", "inspect", str(ref)], ignore_stderr=True) + return result.stdout.decode("utf-8").strip() + + +class PodmanImageStrategy(BaseImageStrategy): + def __init__(self, engine: str = "podman", *, model_store: ModelStore): + super().__init__(engine=engine, model_store=model_store) + + def mount_arg(self, ref: OciRef, dest: str | None = None) -> str: + return f"--mount=type=image,src={str(ref)},destination={dest or MNT_DIR},subpath=/models,rw=false" + + +class DockerImageStrategy(BaseImageStrategy): + def __init__(self, engine: str = "docker", *, model_store: ModelStore): + super().__init__(engine=engine, model_store=model_store) + + def mount_arg(self, ref: OciRef, dest: str | None = None) -> str | None: + return f"--mount=type=volume,src={str(ref)},dst={dest or MNT_DIR},readonly" diff --git a/ramalama/transports/oci/strategy.py b/ramalama/transports/oci/strategy.py new file mode 100644 index 000000000..da72288e1 --- /dev/null +++ b/ramalama/transports/oci/strategy.py @@ -0,0 +1,85 @@ +import os +from functools import lru_cache +from pathlib import Path +from typing import Literal, TypedDict, cast + +from ramalama.common import SemVer, engine_version +from ramalama.config import SUPPORTED_ENGINES, get_config +from ramalama.model_store.store import ModelStore +from ramalama.oci_tools import OciRef +from ramalama.transports.oci import resolver as oci_resolver +from ramalama.transports.oci.strategies import ( + BaseArtifactStrategy, + BaseImageStrategy, + DockerImageStrategy, + HttpArtifactStrategy, + PodmanArtifactStrategy, + PodmanImageStrategy, +) + +PODMAN_MIN_ARTIFACT_VERSION = SemVer(5, 7, 0) + + +def get_engine_image_strategy(engine: str, engine_name: SUPPORTED_ENGINES) -> type[BaseImageStrategy]: + if engine_name == "docker": + return DockerImageStrategy + elif engine_name == "podman": + return PodmanImageStrategy + else: + raise ValueError(f"No engine image strategies for `{engine_name}` engine.") + + +def get_engine_artifact_strategy(engine: str, engine_name: SUPPORTED_ENGINES) -> type[BaseArtifactStrategy]: + if engine_name == "podman": + version = engine_version(engine) + if version >= PODMAN_MIN_ARTIFACT_VERSION: + return PodmanArtifactStrategy + + return HttpArtifactStrategy + + +class StrategiesType(TypedDict): + image: BaseImageStrategy + artifact: BaseArtifactStrategy + + +@lru_cache +def get_strategy( + engine: str, engine_name: SUPPORTED_ENGINES, model_store: ModelStore, kind: Literal['image', 'artifact'] +) -> BaseArtifactStrategy | BaseImageStrategy: + cls_generator = get_engine_image_strategy if kind == 'image' else get_engine_artifact_strategy + cls = cls_generator(engine, engine_name) + return cls(engine=engine, model_store=model_store) + + +class OCIStrategyFactory: + """Resolve reference kind and return the appropriate strategy implementation.""" + + def __init__( + self, + engine: SUPPORTED_ENGINES | Path | str | None, + model_store: ModelStore, + ): + if (engine := engine or get_config().engine) is None: + raise Exception("OCIStrategyFactory require a valid engine") + + self.engine = str(engine) + self.engine_name: SUPPORTED_ENGINES = cast(SUPPORTED_ENGINES, os.path.basename(self.engine)) + self.model_store = model_store + self._type_resolver = oci_resolver.OCITypeResolver(self.engine, model_store=self.model_store) + + def strategies(self, kind: Literal['image', 'artifact']) -> BaseArtifactStrategy | BaseImageStrategy: + return get_strategy(self.engine, self.engine_name, self.model_store, kind) + + def resolve_kind(self, model: OciRef) -> Literal["image", "artifact"] | None: + kind = self._type_resolver.resolve(model) + if kind == "unknown": + return None + return kind + + def resolve(self, model: OciRef) -> BaseArtifactStrategy | BaseImageStrategy: + kind = self.resolve_kind(model) + if kind is None: + raise ValueError(f"{model} does not exist.") + + return self.strategies(kind) diff --git a/ramalama/transports/rlcr.py b/ramalama/transports/rlcr.py index f26b54757..6b3c573af 100644 --- a/ramalama/transports/rlcr.py +++ b/ramalama/transports/rlcr.py @@ -2,7 +2,7 @@ import subprocess from ramalama.common import MNT_DIR, run_cmd -from ramalama.transports.oci import OCI +from ramalama.transports.oci.oci import OCI def find_model_file_in_image(conman: str, model: str) -> str | None: @@ -43,11 +43,19 @@ def find_model_file_in_image(conman: str, model: str) -> str | None: class RamalamaContainerRegistry(OCI): def __init__(self, model: str, *args, **kwargs): super().__init__(f"rlcr.io/ramalama/{model}", *args, **kwargs) - self._model_type = 'oci' - - def _get_entry_model_path(self, *args, **kwargs) -> str: - model_filename = find_model_file_in_image(self.conman, self.model) - if model_filename: - model_basename = os.path.basename(model_filename) - return os.path.join(MNT_DIR, model_basename) - return os.path.join(MNT_DIR, "model.file") + self._model_type = "oci" + + def _get_entry_model_path(self, use_container: bool, should_generate: bool, dry_run: bool) -> str: + if dry_run: + return super()._get_entry_model_path(use_container, should_generate, dry_run) + + if self.strategy.kind == 'artifact': + return super()._get_entry_model_path(use_container, should_generate, dry_run) + + if use_container or should_generate: + model_filename = find_model_file_in_image(self.conman, self.model) + if model_filename: + model_basename = os.path.basename(model_filename) + return os.path.join(MNT_DIR, model_basename) + + return super()._get_entry_model_path(use_container, should_generate, dry_run) diff --git a/ramalama/transports/transport_factory.py b/ramalama/transports/transport_factory.py index d56dc649d..9b99c4b2e 100644 --- a/ramalama/transports/transport_factory.py +++ b/ramalama/transports/transport_factory.py @@ -12,7 +12,7 @@ from ramalama.transports.base import MODEL_TYPES, Transport from ramalama.transports.huggingface import Huggingface from ramalama.transports.modelscope import ModelScope -from ramalama.transports.oci import OCI +from ramalama.transports.oci.oci import OCI from ramalama.transports.ollama import Ollama from ramalama.transports.rlcr import RamalamaContainerRegistry from ramalama.transports.url import URL diff --git a/test/conftest.py b/test/conftest.py index 344153c26..d836cd88e 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -61,15 +61,22 @@ def test_model(): def get_podman_version(): """Get podman version as a tuple of integers (major, minor, patch).""" + if shutil.which("podman") is None: + return (0, 0, 0) + try: result = subprocess.run( - ["podman", "version", "--format", "{{.Client.Version}}"], capture_output=True, text=True, check=True + ["podman", "version", "--format", "{{.Client.Version}}"], + capture_output=True, + text=True, + check=True, + timeout=5, ) version_str = result.stdout.strip() # Handle versions like "5.7.0-dev" by taking only the numeric part version_parts = version_str.split('-')[0].split('.') return tuple(int(x) for x in version_parts[:3]) - except (subprocess.CalledProcessError, FileNotFoundError, ValueError): + except (subprocess.CalledProcessError, FileNotFoundError, ValueError, subprocess.TimeoutExpired): return (0, 0, 0) diff --git a/test/e2e/test_artifact.py b/test/e2e/test_artifact.py index ab87286ca..40ad892c8 100644 --- a/test/e2e/test_artifact.py +++ b/test/e2e/test_artifact.py @@ -8,34 +8,19 @@ """ import json -import platform import re from pathlib import Path from subprocess import STDOUT, CalledProcessError import pytest -from test.conftest import skip_if_docker, skip_if_no_container, skip_if_podman_too_old, skip_if_windows +from test.conftest import skip_if_docker, skip_if_no_container from test.e2e.utils import RamalamaExecWorkspace, check_output -def path_to_uri(path): - """Convert a Path object to a file:// URI, handling Windows paths correctly.""" - if platform.system() == "Windows": - # On Windows, convert backslashes to forward slashes and ensure proper file:// format - path_str = str(path).replace("\\", "/") - # Windows paths need an extra slash: file:///C:/path - if len(path_str) > 1 and path_str[1] == ':': - return f"file:///{path_str}" - return f"file://{path_str}" - else: - return f"file://{path}" - - @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old def test_list_command(): """Test that ramalama list command works""" with RamalamaExecWorkspace() as ctx: @@ -48,7 +33,6 @@ def test_list_command(): @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old def test_list_json_output(): """Test that ramalama list --json returns valid JSON""" with RamalamaExecWorkspace() as ctx: @@ -62,7 +46,6 @@ def test_list_json_output(): @pytest.mark.e2e @skip_if_no_container -@skip_if_podman_too_old def test_convert_error_invalid_type(): """Test that invalid convert type is rejected""" with RamalamaExecWorkspace() as ctx: @@ -71,7 +54,7 @@ def test_convert_error_invalid_type(): with pytest.raises(CalledProcessError) as exc_info: ctx.check_output( - ["ramalama", "convert", "--type", "invalid_type", path_to_uri(test_file), "test:latest"], stderr=STDOUT + ["ramalama", "convert", "--type", "invalid_type", f"file://{test_file}", "test:latest"], stderr=STDOUT ) assert exc_info.value.returncode == 2 @@ -81,7 +64,6 @@ def test_convert_error_invalid_type(): @pytest.mark.e2e @skip_if_no_container -@skip_if_podman_too_old def test_convert_error_missing_source(): """Test that convert with missing source is rejected""" with RamalamaExecWorkspace() as ctx: @@ -99,7 +81,6 @@ def test_convert_error_missing_source(): @pytest.mark.e2e @skip_if_no_container -@skip_if_podman_too_old def test_convert_nocontainer_error(): """Test that convert with --nocontainer is rejected""" with RamalamaExecWorkspace() as ctx: @@ -108,7 +89,7 @@ def test_convert_nocontainer_error(): with pytest.raises(CalledProcessError) as exc_info: ctx.check_output( - ["ramalama", "--nocontainer", "convert", "--type", "raw", path_to_uri(test_file), "test:latest"], + ["ramalama", "--nocontainer", "convert", "--type", "raw", f"file://{test_file}", "test:latest"], stderr=STDOUT, ) @@ -122,7 +103,6 @@ def test_convert_nocontainer_error(): @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old def test_rm_nonexistent(): """Test removing nonexistent model (should handle gracefully)""" with RamalamaExecWorkspace() as ctx: @@ -138,7 +118,6 @@ def test_rm_nonexistent(): @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old def test_info_command_output(): """Test that info command returns valid JSON with expected fields""" result = check_output(["ramalama", "info"]) @@ -156,7 +135,6 @@ def test_info_command_output(): @pytest.mark.e2e @skip_if_no_container -@skip_if_podman_too_old def test_convert_help_shows_types(): """Test that convert --help shows the available types""" result = check_output(["ramalama", "convert", "--help"]) @@ -171,7 +149,6 @@ def test_convert_help_shows_types(): @pytest.mark.e2e @skip_if_no_container -@skip_if_podman_too_old def test_push_help_shows_types(): """Test that push --help shows the available types""" result = check_output(["ramalama", "push", "--help"]) @@ -186,7 +163,6 @@ def test_push_help_shows_types(): @pytest.mark.e2e @skip_if_no_container -@skip_if_podman_too_old def test_convert_types_in_help(): """Test that both convert and push commands show type options""" convert_help = check_output(["ramalama", "convert", "--help"]) @@ -202,7 +178,6 @@ def test_convert_types_in_help(): @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old def test_version_command(): """Test that version command works""" result = check_output(["ramalama", "version"]) @@ -212,7 +187,6 @@ def test_version_command(): @pytest.mark.e2e @skip_if_no_container -@skip_if_podman_too_old def test_config_with_convert_type(): """Test that config file can specify convert_type""" config = """ @@ -232,7 +206,6 @@ def test_config_with_convert_type(): @pytest.mark.e2e @skip_if_no_container -@skip_if_podman_too_old def test_help_command(): """Test that help command works and shows subcommands""" result = check_output(["ramalama", "help"]) @@ -247,7 +220,6 @@ def test_help_command(): @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old def test_convert_command_exists(): """Test that convert command exists and shows help""" result = check_output(["ramalama", "convert", "--help"]) @@ -261,7 +233,6 @@ def test_convert_command_exists(): @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old def test_push_command_exists(): """Test that push command exists and shows help""" result = check_output(["ramalama", "push", "--help"]) @@ -277,8 +248,6 @@ def test_push_command_exists(): @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old -@skip_if_windows def test_artifact_lifecycle_basic(): """Test complete artifact lifecycle: create, list, remove""" with RamalamaExecWorkspace() as ctx: @@ -289,7 +258,7 @@ def test_artifact_lifecycle_basic(): artifact_name = "test-artifact-lifecycle:latest" # Step 1: Convert to artifact (using raw type which should work) - ctx.check_call(["ramalama", "convert", "--type", "raw", path_to_uri(test_file), artifact_name]) + ctx.check_call(["ramalama", "convert", "--type", "raw", f"file://{test_file}", artifact_name]) # Step 2: Verify it appears in list result = ctx.check_output(["ramalama", "list"]) @@ -309,7 +278,7 @@ def test_artifact_lifecycle_basic(): assert found, "Artifact not found in JSON list output" # Step 4: Remove the artifact - ctx.check_call(["ramalama", "rm", artifact_name]) + ctx.check_call(["ramalama", "rm", f"oci://localhost/{artifact_name}"]) # Step 5: Verify it's gone result_after = ctx.check_output(["ramalama", "list"]) @@ -319,8 +288,6 @@ def test_artifact_lifecycle_basic(): @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old -@skip_if_windows def test_artifact_multiple_types(): """Test creating artifacts with different types (raw and car)""" with RamalamaExecWorkspace() as ctx: @@ -331,10 +298,10 @@ def test_artifact_multiple_types(): test_file2.write_text("Model 2 content") # Create raw type artifact - ctx.check_call(["ramalama", "convert", "--type", "raw", path_to_uri(test_file1), "test-raw-artifact-unique:v1"]) + ctx.check_call(["ramalama", "convert", "--type", "raw", f"file://{test_file1}", "test-raw-artifact-unique:v1"]) # Create car type artifact - ctx.check_call(["ramalama", "convert", "--type", "car", path_to_uri(test_file2), "test-car-artifact-unique:v1"]) + ctx.check_call(["ramalama", "convert", "--type", "car", f"file://{test_file2}", "test-car-artifact-unique:v1"]) # Verify both appear in list using JSON (more reliable) json_result = ctx.check_output(["ramalama", "list", "--json"]) @@ -345,8 +312,8 @@ def test_artifact_multiple_types(): assert found_car, "Car artifact not found in list" # Clean up - ctx.check_call(["ramalama", "rm", "test-raw-artifact-unique:v1"]) - ctx.check_call(["ramalama", "rm", "test-car-artifact-unique:v1"]) + ctx.check_call(["ramalama", "rm", "oci://localhost/test-raw-artifact-unique:v1"]) + ctx.check_call(["ramalama", "rm", "oci://localhost/test-car-artifact-unique:v1"]) # Verify both are gone using JSON json_result_after = ctx.check_output(["ramalama", "list", "--json"]) @@ -359,8 +326,6 @@ def test_artifact_multiple_types(): @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old -@skip_if_windows def test_artifact_list_json_with_size(): """Test that artifact in JSON list has correct size information""" with RamalamaExecWorkspace() as ctx: @@ -372,7 +337,7 @@ def test_artifact_list_json_with_size(): artifact_name = "test-sized-artifact-unique:v1" # Convert to artifact - ctx.check_call(["ramalama", "convert", "--type", "raw", path_to_uri(test_file), artifact_name]) + ctx.check_call(["ramalama", "convert", "--type", "raw", f"file://{test_file}", artifact_name]) # Get JSON output json_result = ctx.check_output(["ramalama", "list", "--json"]) @@ -393,14 +358,12 @@ def test_artifact_list_json_with_size(): assert "modified" in artifact # Clean up - ctx.check_call(["ramalama", "rm", artifact_name]) + ctx.check_call(["ramalama", "rm", f"oci://localhost/{artifact_name}"]) @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old -@skip_if_windows def test_artifact_rm_multiple(): """Test removing multiple artifacts one at a time""" with RamalamaExecWorkspace() as ctx: @@ -414,7 +377,7 @@ def test_artifact_rm_multiple(): artifacts.append(artifact_name) # Convert to artifact - ctx.check_call(["ramalama", "convert", "--type", "raw", path_to_uri(test_file), artifact_name]) + ctx.check_call(["ramalama", "convert", "--type", "raw", f"file://{test_file}", artifact_name]) # Verify all appear in list using JSON json_result = ctx.check_output(["ramalama", "list", "--json"]) @@ -425,7 +388,7 @@ def test_artifact_rm_multiple(): # Remove artifacts one at a time for artifact_name in artifacts: - ctx.check_call(["ramalama", "rm", artifact_name]) + ctx.check_call(["ramalama", "rm", f"oci://localhost/{artifact_name}"]) # Verify all are gone using JSON json_result_after = ctx.check_output(["ramalama", "list", "--json"]) @@ -440,8 +403,6 @@ def test_artifact_rm_multiple(): @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old -@skip_if_windows def test_artifact_with_different_tags(): """Test creating artifacts with different tags""" with RamalamaExecWorkspace() as ctx: @@ -452,7 +413,14 @@ def test_artifact_with_different_tags(): tags = ["v1.0", "v2.0", "latest"] for tag in tags: ctx.check_call( - ["ramalama", "convert", "--type", "raw", path_to_uri(test_file), f"test-tagged-artifact:{tag}"] + [ + "ramalama", + "convert", + "--type", + "raw", + f"file://{test_file}", + f"test-tagged-artifact:{tag}", + ] ) # Verify all tags appear in list @@ -462,7 +430,7 @@ def test_artifact_with_different_tags(): # Clean up all tags for tag in tags: - ctx.check_call(["ramalama", "rm", f"test-tagged-artifact:{tag}"]) + ctx.check_call(["ramalama", "rm", f"oci://localhost/test-tagged-artifact:{tag}"]) # Verify all are gone result_after = ctx.check_output(["ramalama", "list"]) @@ -472,8 +440,6 @@ def test_artifact_with_different_tags(): @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old -@skip_if_windows def test_artifact_list_empty_after_cleanup(): """Test that list is clean after removing all artifacts""" with RamalamaExecWorkspace() as ctx: @@ -483,14 +449,14 @@ def test_artifact_list_empty_after_cleanup(): artifact_name = "test-temp-artifact:latest" # Create artifact - ctx.check_call(["ramalama", "convert", "--type", "raw", path_to_uri(test_file), artifact_name]) + ctx.check_call(["ramalama", "convert", "--type", "raw", f"file://{test_file}", artifact_name]) # Verify it exists result_before = ctx.check_output(["ramalama", "list"]) assert "test-temp-artifact" in result_before # Remove it - ctx.check_call(["ramalama", "rm", artifact_name]) + ctx.check_call(["ramalama", "rm", f"oci://localhost/{artifact_name}"]) # Verify list doesn't contain it result_after = ctx.check_output(["ramalama", "list"]) @@ -506,8 +472,6 @@ def test_artifact_list_empty_after_cleanup(): @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old -@skip_if_windows def test_artifact_with_config_default_type(): """Test that config convert_type is used when type not specified""" config = """ @@ -523,21 +487,19 @@ def test_artifact_with_config_default_type(): artifact_name = "test-config-default:latest" # Convert without specifying --type (should use config default) - ctx.check_call(["ramalama", "convert", path_to_uri(test_file), artifact_name]) + ctx.check_call(["ramalama", "convert", f"file://{test_file}", artifact_name]) # Verify it was created result = ctx.check_output(["ramalama", "list"]) assert "test-config-default" in result # Clean up - ctx.check_call(["ramalama", "rm", artifact_name]) + ctx.check_call(["ramalama", "rm", f"oci://localhost/{artifact_name}"]) @pytest.mark.e2e @skip_if_no_container @skip_if_docker -@skip_if_podman_too_old -@skip_if_windows def test_artifact_overwrite_same_name(): """Test that converting to same name overwrites/updates""" with RamalamaExecWorkspace() as ctx: @@ -547,9 +509,10 @@ def test_artifact_overwrite_same_name(): test_file2.write_text("Version 2 content - this is longer") artifact_name = "test-overwrite-artifact:latest" + artifact_ref = f"oci://localhost/{artifact_name}" # Create first version - ctx.check_call(["ramalama", "convert", "--type", "raw", path_to_uri(test_file1), artifact_name]) + ctx.check_call(["ramalama", "convert", "--type", "raw", f"file://{test_file1}", artifact_ref]) # Get size of first version json_result1 = ctx.check_output(["ramalama", "list", "--json"]) @@ -562,7 +525,7 @@ def test_artifact_overwrite_same_name(): assert size1 is not None # Create second version with same name - ctx.check_call(["ramalama", "convert", "--type", "raw", path_to_uri(test_file2), artifact_name]) + ctx.check_call(["ramalama", "convert", "--type", "raw", f"file://{test_file2}", artifact_ref]) # Verify only one artifact with this name exists result = ctx.check_output(["ramalama", "list"]) @@ -584,4 +547,4 @@ def test_artifact_overwrite_same_name(): assert size2 >= size1, "Second version should be at least as large" # Clean up - ctx.check_call(["ramalama", "rm", artifact_name]) + ctx.check_call(["ramalama", "rm", artifact_ref]) diff --git a/test/e2e/test_help.py b/test/e2e/test_help.py index 068d1e4d5..c32b5f4a3 100644 --- a/test/e2e/test_help.py +++ b/test/e2e/test_help.py @@ -223,7 +223,7 @@ def test_default_runtime(): @pytest.mark.e2e def test_default_runtime_variable_precedence(): env_runtime = "mlx" - config_runtime = "lamma.cpp" + config_runtime = "llama.cpp" param_runtime = "vllm" config = f""" diff --git a/test/e2e/test_serve.py b/test/e2e/test_serve.py index bc58a0701..26fcd2edb 100644 --- a/test/e2e/test_serve.py +++ b/test/e2e/test_serve.py @@ -786,7 +786,10 @@ def test_serve_with_non_existing_images(): stderr=STDOUT, ) assert exc_info.value.returncode == 22 - assert re.search(r"Error: quay.io/ramalama/rag does not exist.*", exc_info.value.output.decode("utf-8")) + assert re.search( + r"Error: quay.io/ramalama/rag(?::latest)? does not exist.*", + exc_info.value.output.decode("utf-8"), + ) @pytest.mark.e2e diff --git a/test/system/056-artifact.bats b/test/system/056-artifact.bats index d02f52afc..e396b0e57 100644 --- a/test/system/056-artifact.bats +++ b/test/system/056-artifact.bats @@ -8,7 +8,6 @@ load setup_suite @test "ramalama convert artifact - basic functionality" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" # Requires the -rag images which are not available on these arches yet skip_if_ppc64le skip_if_s390x @@ -26,7 +25,8 @@ load setup_suite run_podman artifact ls is "$output" ".*artifact-test.*latest" "artifact appears in podman artifact list" - run_ramalama rm ${artifact} file://${testmodel} + run_ramalama rm file://${testmodel} + run_ramalama rm ${artifact} run_ramalama ls assert "$output" !~ ".*artifact-test" "artifact was removed" } @@ -34,7 +34,6 @@ load setup_suite @test "ramalama convert artifact - from ollama model" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x @@ -54,7 +53,6 @@ load setup_suite @test "ramalama convert artifact - with OCI target" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x @@ -85,7 +83,6 @@ load setup_suite @test "ramalama convert artifact - error handling" { skip_if_nocontainer - skip_if_podman_too_old "5.7.0" # Test invalid type run_ramalama 2 convert --type invalid file://$RAMALAMA_TMPDIR/test oci://test @@ -103,7 +100,6 @@ load setup_suite @test "ramalama push artifact - basic functionality" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x local registry=localhost:${PODMAN_LOGIN_REGISTRY_PORT} @@ -115,19 +111,19 @@ load setup_suite --password ${PODMAN_LOGIN_PASS} \ oci://$registry - run_ramalama ? rm oci://$registry/artifact-test-push:latest + run_ramalama rm --ignore oci://$registry/artifact-test-push:latest echo "test model" > $RAMALAMA_TMPDIR/testmodel run_ramalama convert --type artifact file://$RAMALAMA_TMPDIR/testmodel oci://$registry/artifact-test-push:latest run_ramalama list is "$output" ".*$registry/artifact-test-push.*latest" "artifact was pushed and listed" - run_ramalama push --tls-verify=false --type artifact oci://$registry/artifact-test-push:latest + run_ramalama push --type artifact oci://$registry/artifact-test-push:latest # Verify it's an artifact run_podman artifact ls is "$output" ".*$registry/artifact-test-push" "pushed artifact appears in podman artifact list" - run_ramalama rm oci://$registry/artifact-test-push:latest file://$RAMALAMA_TMPDIR/testmodel + run_ramalama rm oci://$registry/artifact-test-push:latest run_ramalama ls assert "$output" !~ ".*$registry/artifact-test-push" "pushed artifact was removed" @@ -136,7 +132,7 @@ load setup_suite run_ramalama convert --type raw file://$RAMALAMA_TMPDIR/testmodel oci://$registry/test-image:latest run_ramalama push --type artifact oci://$registry/test-image:latest - run_ramalama rm oci://$registry/test-image:latest file://$RAMALAMA_TMPDIR/testmodel + run_ramalama rm oci://$registry/test-image:latest assert "$output" !~ ".*test-image" "local image was removed" stop_registry } @@ -144,19 +140,39 @@ load setup_suite @test "ramalama list - includes artifacts" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x artifact="artifact-test:latest" - run_podman artifact rm --ignore ${artifact} + run_podman ? artifact rm ${artifact} # Create a regular image echo "test model" > $RAMALAMA_TMPDIR/testmodel - run_ramalama convert --type raw file://$RAMALAMA_TMPDIR/testmodel image-test + run_ramalama convert --type raw file://$RAMALAMA_TMPDIR/testmodel test-image # Create an artifact run_ramalama convert --type artifact file://$RAMALAMA_TMPDIR/testmodel ${artifact} + run_ramalama list + is "$output" ".*test-image.*latest" "regular image appears in list" + is "$output" ".*artifact-test.*latest" "artifact appears in list" + + run_ramalama rm test-image:latest ${artifact} + run_ramalama list + assert "$output" !~ ".*test-image" "regular image was removed" + run_podman artifact ls + assert "$output" !~ ".*artifact-test" "artifact was removed" +} + +@test "ramalama list - json output includes artifacts" { + skip_if_darwin + skip_if_nocontainer + skip_if_docker + skip_if_ppc64le + skip_if_s390x + + echo "test model" > $RAMALAMA_TMPDIR/testmodel + run_ramalama convert --type artifact file://$RAMALAMA_TMPDIR/testmodel artifact-test:latest + run_ramalama list --json # Check that the artifact appears in JSON output name=$(echo "$output" | jq -r '.[].name') @@ -168,22 +184,14 @@ load setup_suite assert "$modified" != "" "artifact has modified field" assert "$size" != "" "artifact has size field" - run_ramalama list - is "$output" ".*image-test.*latest" "regular image appears in list" - is "$output" ".*artifact-test.*latest" "artifact appears in list" - - run_ramalama rm file://$RAMALAMA_TMPDIR/testmodel oci://localhost/image-test:latest ${artifact} - run_ramalama list - assert "$output" !~ ".*image-test" "regular image was removed" + run_ramalama rm artifact-test:latest run_podman artifact ls - assert "$output" !~ ".*artifact-test" "artifact was removed" + assert "$output" !~ ".*artifact-test.*latest" "artifact was removed" } - @test "ramalama convert - default type from config" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x @@ -207,13 +215,12 @@ EOF run_podman artifact ls is "$output" ".*test-config-artifact.*latest" "artifact appears in podman artifact list" - run_ramalama rm test-config-artifact:latest file://$RAMALAMA_TMPDIR/testmodel + run_ramalama rm test-config-artifact:latest } @test "ramalama convert - type precedence (CLI over config)" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x @@ -236,18 +243,17 @@ EOF run_podman artifact ls assert "$output" !~ ".*test-cli-override" "image does not appear in podman artifact list" - run_ramalama rm test-cli-override file://$RAMALAMA_TMPDIR/testmodel + run_ramalama rm test-cli-override assert "$output" !~ ".*test-cli-override" "image was removed" } @test "ramalama convert - all supported types" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x - run_ramalama ? rm test-car:latest test-raw:latest + run_ramalama rm --ignore test-car:latest test-raw:latest echo "test model" > $RAMALAMA_TMPDIR/testmodel # Test car type @@ -266,7 +272,7 @@ EOF assert "$output" !~ ".*test-raw" "raw does not appear in artifact list" # Clean up - run_ramalama rm test-car:latest test-raw:latest file://$RAMALAMA_TMPDIR/testmodel + run_ramalama rm test-car:latest test-raw:latest assert "$output" !~ ".*test-car" "car was removed" assert "$output" !~ ".*test-raw" "raw was removed" } @@ -274,7 +280,6 @@ EOF @test "ramalama push - all supported types" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x local registry=localhost:${PODMAN_LOGIN_REGISTRY_PORT} @@ -288,7 +293,7 @@ EOF echo "test model" > $RAMALAMA_TMPDIR/testmodel - run_ramalama ? rm artifact-test:latest + run_ramalama rm --ignore artifact-test:latest # Test artifact push run_ramalama convert --type artifact file://$RAMALAMA_TMPDIR/testmodel oci://$registry/artifact-test-push:latest run_ramalama list @@ -319,10 +324,33 @@ EOF stop_registry } +# bats test_tags=distro-integration +@test "ramalama artifact - large file handling" { + skip_if_nocontainer + skip_if_docker + skip_if_ppc64le + skip_if_s390x + + # Create a larger test file (1MB) + dd if=/dev/zero of=$RAMALAMA_TMPDIR/large_model bs=1M count=1 2>/dev/null + echo "test data" >> $RAMALAMA_TMPDIR/large_model + + run_ramalama convert --type artifact file://$RAMALAMA_TMPDIR/large_model large-artifact:latest + run_ramalama list + is "$output" ".*large-artifact.*latest" "large artifact was created" + + # Verify size is reasonable + size=$(run_ramalama list --json | jq -r '.[0].size') + assert [ "$size" -gt 1000000 ] "artifact size is at least 1MB" + + run_ramalama rm large-artifact + run_ramalama ls + assert "$output" !~ ".*large-artifact" "large artifact was removed" +} + @test "ramalama artifact - multiple files in artifact" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x @@ -336,11 +364,11 @@ EOF run_ramalama convert --type artifact file://$RAMALAMA_TMPDIR/multi_model.tar.gz multi-artifact run_ramalama list - is "$output" ".*multi-artifact.*latest" "multi-file artifact was created" + is "$output" ".*multi-artifact:latest" "multi-file artifact was created" # Verify it's an artifact run_podman artifact ls - is "$output" ".*multi-artifact.*latest" "multi-file artifact appears in podman artifact list" + is "$output" ".*multi-artifact:latest" "multi-file artifact appears in podman artifact list" run_ramalama rm multi-artifact assert "$output" !~ ".*multi-artifact" "multi-file artifact was removed" @@ -349,13 +377,9 @@ EOF @test "ramalama artifact - concurrent operations" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x - skip "FIXME: This is broken in Podman 5.7 and fixed in podman 6.0. - https://github.com/containers/podman/pull/27574" - echo "test model 1" > $RAMALAMA_TMPDIR/testmodel1 echo "test model 2" > $RAMALAMA_TMPDIR/testmodel2 @@ -370,10 +394,10 @@ EOF wait $pid2 run_ramalama list - is "$output" ".*concurrent-artifact1.*latest" "first concurrent artifact was created" - is "$output" ".*concurrent-artifact2.*latest" "second concurrent artifact was created" + is "$output" ".*concurrent-artifact1:latest" "first concurrent artifact was created" + is "$output" ".*concurrent-artifact2:latest" "second concurrent artifact was created" - run_ramalama rm concurrent-artifact1 concurrent-artifact2 file://$RAMALAMA_TMPDIR/testmodel1 file://$RAMALAMA_TMPDIR/testmodel2 + run_ramalama rm concurrent-artifact1 concurrent-artifact2 assert "$output" !~ ".*concurrent-artifact1" "first concurrent artifact was removed" assert "$output" !~ ".*concurrent-artifact2" "second concurrent artifact was removed" } @@ -381,11 +405,10 @@ EOF @test "ramalama artifact - error handling for invalid source" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" # Test with non-existent file - run_ramalama 2 convert --type artifact file:///nonexistent/path/model.gguf test-artifact - is "$output" ".*Error: No such file: '/nonexistent/path/model.gguf.*" "directory as source is handled gracefully" + run_ramalama 22 convert --type artifact file:///nonexistent/path/model.gguf test-artifact + is "$output" ".*Error.*" "non-existent file is handled gracefully" # Test with directory instead of file mkdir -p $RAMALAMA_TMPDIR/testdir @@ -393,11 +416,32 @@ EOF is "$output" ".*Error.*" "directory as source is handled gracefully" } +@test "ramalama artifact - size reporting accuracy" { + skip_if_nocontainer + skip_if_docker + skip_if_ppc64le + skip_if_s390x + + # Create a file with known size + echo "test data for size verification" > $RAMALAMA_TMPDIR/size_test_model + expected_size=$(wc -c < $RAMALAMA_TMPDIR/size_test_model) + + run_ramalama convert --type artifact file://$RAMALAMA_TMPDIR/size_test_model size-test-artifact + run_ramalama list --json + reported_size=$(echo "$output" | jq -r '.[0].size') + + # Allow for some overhead in artifact storage + assert [ "$reported_size" -ge "$expected_size" ] "reported size is at least the file size" + assert [ "$reported_size" -lt "$((expected_size * 2))" ] "reported size is not excessively large" + + run_ramalama rm size-test-artifact + assert "$output" !~ ".*size-test-artifact" "size test artifact was removed" +} + # bats test_tags=distro-integration @test "ramalama config - convert_type setting" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x @@ -421,13 +465,12 @@ EOF run_podman artifact ls is "$output" ".*config-test-artifact.*latest" "artifact appears in podman artifact list" - run_ramalama rm ${artifact} file://$RAMALAMA_TMPDIR/testmodel + run_ramalama rm ${artifact} assert "$output" !~ ".*config-test-artifact" "artifact was removed" } @test "ramalama config - convert_type validation" { skip_if_nocontainer - skip_if_podman_too_old "5.7.0" # Test invalid convert_type in config local config_file=$RAMALAMA_TMPDIR/ramalama.conf @@ -446,7 +489,6 @@ EOF @test "ramalama config - convert_type precedence" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x @@ -462,20 +504,19 @@ EOF # Test CLI override of config RAMALAMA_CONFIG=$config_file run_ramalama convert --type raw file://$RAMALAMA_TMPDIR/testmodel cli-override-test run_ramalama list - is "$output" ".*cli-override-test.*latest" "CLI type override worked" + is "$output" ".*cli-override-test:latest" "CLI type override worked" # Verify it's NOT an artifact (should be raw) run_podman artifact ls assert "$output" !~ ".*cli-override-test" "CLI override created raw image, not artifact" - run_ramalama rm cli-override-test file://$RAMALAMA_TMPDIR/testmodel + run_ramalama rm cli-override-test assert "$output" !~ ".*cli-override-test" "image was removed" } @test "ramalama config - environment variable override" { skip_if_nocontainer skip_if_docker - skip_if_podman_too_old "5.7.0" skip_if_ppc64le skip_if_s390x @@ -491,13 +532,13 @@ EOF # Test environment variable override RAMALAMA_CONFIG=$config_file RAMALAMA_CONVERT_TYPE=raw run_ramalama convert file://$RAMALAMA_TMPDIR/testmodel env-override-test run_ramalama list - is "$output" ".*env-override-test.*latest" "environment variable override worked" + is "$output" ".*env-override-test:latest" "environment variable override worked" # Verify it's NOT an artifact (should be raw) run_podman artifact ls assert "$output" !~ ".*env-override-test" "environment override created raw image, not artifact" - run_ramalama rm env-override-test file://$RAMALAMA_TMPDIR/testmodel + run_ramalama rm env-override-test assert "$output" !~ ".*env-override-test" "image was removed" } diff --git a/test/unit/providers/test_openai_provider.py b/test/unit/providers/test_openai_provider.py index 3ebb68a46..d57be6cca 100644 --- a/test/unit/providers/test_openai_provider.py +++ b/test/unit/providers/test_openai_provider.py @@ -25,7 +25,7 @@ def make_options(**overrides): return ChatRequestOptions(**data) -class OpenAICompletionsProviderTests: +class TestOpenAICompletionsProvider: def setup_method(self): self.provider = OpenAICompletionsChatProvider("http://example.com") @@ -78,7 +78,7 @@ def test_serializes_tool_calls_and_responses(self): assert messages[1]["tool_call_id"] == "call-1" -class OpenAIResponsesProviderTests: +class TestOpenAIResponsesProvider: def setup_method(self): self.provider = OpenAIResponsesChatProvider("http://example.com") diff --git a/test/unit/test_artifact_strategies_impl.py b/test/unit/test_artifact_strategies_impl.py new file mode 100644 index 000000000..35f78b9eb --- /dev/null +++ b/test/unit/test_artifact_strategies_impl.py @@ -0,0 +1,91 @@ +from unittest.mock import Mock + +from ramalama.oci_tools import OciRef +from ramalama.transports.oci import strategies + + +class Recorder: + def __init__(self, should_fail=False): + self.calls = [] + self.should_fail = should_fail + + def __call__(self, args, **kwargs): + self.calls.append((args, kwargs)) + if self.should_fail: + raise RuntimeError("fail") + return None + + +def test_podman_artifact_mount_and_pull(monkeypatch): + rec = Recorder() + monkeypatch.setattr(strategies, "run_cmd", rec) + strat = strategies.PodmanArtifactStrategy(engine="podman", model_store=Mock()) + ref = OciRef.from_ref_string("artifact:latest") + strat.pull(ref) + assert rec.calls[0][0] == ["podman", "artifact", "pull", str(ref)] + assert strat.mount_arg(ref).startswith("--mount=type=artifact") + + +def test_podman_artifact_exists(monkeypatch): + rec = Recorder() + monkeypatch.setattr(strategies, "run_cmd", rec) + strat = strategies.PodmanArtifactStrategy(engine="podman", model_store=Mock()) + ref = OciRef.from_ref_string("artifact:latest") + assert strat.exists(ref) is True + assert rec.calls[0][0] == ["podman", "artifact", "inspect", str(ref)] + + +def test_podman_image_path(monkeypatch): + rec = Recorder() + monkeypatch.setattr(strategies, "run_cmd", rec) + strat = strategies.PodmanImageStrategy(engine="podman", model_store=Mock()) + ref = OciRef.from_ref_string("image:latest") + strat.pull(ref) + assert rec.calls[0][0] == ["podman", "pull", str(ref)] + assert strat.mount_arg(ref).startswith("--mount=type=image") + + +def test_docker_image_path(monkeypatch): + rec = Recorder() + monkeypatch.setattr(strategies, "run_cmd", rec) + strat = strategies.DockerImageStrategy(engine="docker", model_store=Mock()) + ref = OciRef.from_ref_string("image:latest") + strat.pull(ref) + assert rec.calls[0][0] == ["docker", "pull", str(ref)] + assert strat.exists(ref) is True + assert rec.calls[1][0] == ["docker", "image", "inspect", str(ref)] + assert strat.mount_arg(ref).startswith("--mount=type=volume") + + +def test_http_bind_path_fetch_and_exists(monkeypatch): + called = [] + + class StoreStub: + def __init__(self, cached=True): + self.cached = cached + self.last_tag = None + + def get_cached_files(self, model_tag): + self.last_tag = model_tag + cached_files = ["blob"] if self.cached else [] + return None, cached_files, self.cached + + def get_snapshot_directory_from_tag(self, model_tag): + return f"/snapshots/{model_tag}" + + def downloader(**kwargs): + called.append(kwargs) + return True + + monkeypatch.setattr(strategies, "download_oci_artifact", downloader) + store = StoreStub() + strat = strategies.HttpArtifactStrategy(engine="docker", model_store=store) + ref = OciRef.from_ref_string("example.com/ns/model:tag") + strat.pull(ref) + assert called[0]["reference"] == str(ref) + assert called[0]["model_tag"] == "tag" + assert strat.exists(ref) is True + assert store.last_tag == "tag" + mount_arg = strat.mount_arg(ref) + assert "type=bind" in mount_arg + assert "destination=/mnt/models" in mount_arg diff --git a/test/unit/test_artifact_strategy.py b/test/unit/test_artifact_strategy.py new file mode 100644 index 000000000..3a7c706aa --- /dev/null +++ b/test/unit/test_artifact_strategy.py @@ -0,0 +1,20 @@ +from ramalama.common import SemVer +from ramalama.transports.oci import strategies +from ramalama.transports.oci import strategy as strat + + +def test_podman_artifact_supported(monkeypatch): + monkeypatch.setattr(strat, "engine_version", lambda e: SemVer(5, 7, 1)) + strategy_cls = strat.get_engine_artifact_strategy("podman", "podman") + assert strategy_cls is strategies.PodmanArtifactStrategy + + +def test_podman_below_min_version(monkeypatch): + monkeypatch.setattr(strat, "engine_version", lambda e: SemVer(5, 6, 9)) + strategy_cls = strat.get_engine_artifact_strategy("podman", "podman") + assert strategy_cls is strategies.HttpArtifactStrategy + + +def test_docker_path(): + assert strat.get_engine_image_strategy("docker", "docker") is strategies.DockerImageStrategy + assert strat.get_engine_artifact_strategy("docker", "docker") is strategies.HttpArtifactStrategy diff --git a/test/unit/test_oci.py b/test/unit/test_oci.py index 50d81d273..f725d98f9 100644 --- a/test/unit/test_oci.py +++ b/test/unit/test_oci.py @@ -6,7 +6,7 @@ from ramalama.model_store.reffile import RefJSONFile, StoreFile, StoreFileType from ramalama.model_store.store import ModelStore from ramalama.transports.huggingface import Huggingface -from ramalama.transports.oci import OCI +from ramalama.transports.oci.oci import OCI from ramalama.transports.ollama import Ollama from ramalama.transports.url import URL diff --git a/test/unit/test_oci_spec.py b/test/unit/test_oci_spec.py new file mode 100644 index 000000000..e21e5902e --- /dev/null +++ b/test/unit/test_oci_spec.py @@ -0,0 +1,81 @@ +import json + +import pytest + +from ramalama.transports.oci import spec as oci_spec + + +def _valid_manifest(): + layer_metadata = { + "name": "model.gguf", + "mode": 0o644, + "uid": 0, + "gid": 0, + "size": 30327160, + "mtime": "2024-01-01T00:00:00Z", + "typeflag": ord("0"), + } + return { + "schemaVersion": 2, + "mediaType": oci_spec.OCI_MANIFEST_MEDIA_TYPE, + "artifactType": oci_spec.CNAI_ARTIFACT_TYPE, + "config": { + "mediaType": oci_spec.CNAI_CONFIG_MEDIA_TYPE, + "digest": "sha256:d5815835051dd97d800a03f641ed8162877920e734d3d705b698912602b8c763", + "size": 301, + }, + "layers": [ + { + "mediaType": "application/vnd.cncf.model.weight.v1.tar", + "digest": "sha256:3f907c1a03bf20f20355fe449e18ff3f9de2e49570ffb536f1a32f20c7179808", + "size": 30327160, + "annotations": { + oci_spec.LAYER_ANNOTATION_FILEPATH: "model.gguf", + oci_spec.LAYER_ANNOTATION_FILE_METADATA: json.dumps(layer_metadata), + oci_spec.LAYER_ANNOTATION_FILE_MEDIATYPE_UNTESTED: "true", + }, + } + ], + } + + +def test_round_trip_manifest(): + manifest_dict = _valid_manifest() + manifest = oci_spec.Manifest.from_dict(manifest_dict) + assert manifest.artifact_type == oci_spec.CNAI_ARTIFACT_TYPE + assert manifest.config.media_type == oci_spec.CNAI_CONFIG_MEDIA_TYPE + assert manifest.layers[0].filepath() == "model.gguf" + assert manifest.layers[0].file_metadata().name == "model.gguf" + assert manifest.layers[0].media_type_untested() is True + + round_trip = manifest.to_dict() + assert round_trip == manifest_dict + assert json.loads(json.dumps(round_trip)) == json.loads(json.dumps(manifest_dict)) + + +def test_reject_wrong_artifact_type(): + manifest_dict = _valid_manifest() + manifest_dict["artifactType"] = "application/vnd.ramalama.model.gguf" + with pytest.raises(ValueError): + oci_spec.Manifest.from_dict(manifest_dict) + + +def test_reject_missing_config(): + manifest_dict = _valid_manifest() + manifest_dict["config"] = {} + with pytest.raises(ValueError): + oci_spec.Manifest.from_dict(manifest_dict) + + +def test_reject_invalid_layer_media_type(): + manifest_dict = _valid_manifest() + manifest_dict["layers"][0]["mediaType"] = "application/unknown" + with pytest.raises(ValueError): + oci_spec.Manifest.from_dict(manifest_dict) + + +def test_reject_wrong_config_media_type(): + manifest_dict = _valid_manifest() + manifest_dict["config"]["mediaType"] = "application/vnd.oci.image.config.v1+json" + with pytest.raises(ValueError): + oci_spec.Manifest.from_dict(manifest_dict) diff --git a/test/unit/test_oci_tools.py b/test/unit/test_oci_tools.py new file mode 100644 index 000000000..1b737bc7e --- /dev/null +++ b/test/unit/test_oci_tools.py @@ -0,0 +1,113 @@ +import json +import subprocess +from datetime import datetime, timedelta +from types import SimpleNamespace + +from ramalama import oci_tools +from ramalama.annotations import AnnotationModel +from ramalama.arg_types import EngineArgs +from ramalama.common import SemVer + + +def _result(text: str): + return SimpleNamespace(stdout=text.encode("utf-8")) + + +def test_list_models_dedupes_labelled_and_manifest_entries(monkeypatch): + label_output = ( + '{"name":"oci://localhost/demo:latest","modified":"2026-01-01 00:00:00 +0000","size":123,"ID":"sha256:a"},' + ) + manifest_output = ( + '{"name":"oci://localhost/demo:latest","modified":"2026-01-01 00:00:00 +0000","size":123,"ID":"sha256:b"},' + ) + + def fake_run_cmd(args, **kwargs): + if args[:4] == ["podman", "images", "--filter", "label=org.containers.type"]: + return _result(label_output) + if args[:4] == ["podman", "images", "--filter", "manifest=true"]: + return _result(manifest_output) + if args[:3] == ["podman", "artifact", "ls"]: + return _result("") + raise AssertionError(f"Unexpected command: {args}") + + monkeypatch.setattr(oci_tools, "run_cmd", fake_run_cmd) + monkeypatch.setattr(oci_tools, "engine_supports_manifest_attributes", lambda engine: False) + + models = oci_tools.list_models(EngineArgs(engine="podman")) + + assert len(models) == 1 + assert models[0]["name"] == "oci://localhost/demo:latest" + assert models[0]["size"] == 123 + + +def test_list_models_timezones_in_utc(monkeypatch): + label_output = ( + '{"name":"oci://localhost/demo:latest","modified":"2026-01-01 00:00:00 +0000","size":123,"ID":"sha256:a"},' + ) + + def fake_run_cmd(args, **kwargs): + if args[:4] == ["podman", "images", "--filter", "label=org.containers.type"]: + return _result(label_output) + if args[:4] == ["podman", "images", "--filter", "manifest=true"]: + return _result("") + if args[:3] == ["podman", "artifact", "ls"]: + return _result("") + raise AssertionError(f"Unexpected command: {args}") + + monkeypatch.setattr(oci_tools, "run_cmd", fake_run_cmd) + monkeypatch.setattr(oci_tools, "engine_supports_manifest_attributes", lambda engine: False) + + models = oci_tools.list_models(EngineArgs(engine="podman")) + assert all(isinstance(m['modified'], datetime) for m in models) + assert all(m['modified'].utcoffset() == timedelta(0) for m in models) + + +def test_list_manifests_filters_by_annotation(monkeypatch): + manifests_output = """\ +{"name":"oci://localhost/annotation-filtered:latest","modified":"2026-01-01 00:00:00 +0000","size":456,"ID":"sha256:c"}, +""" + inspect_payload = { + "manifests": [ + { + "digest": "sha256:child", + "annotations": {AnnotationModel: "true"}, + } + ] + } + + def fake_run_cmd(args, **kwargs): + if args[:4] == ["podman", "images", "--filter", "manifest=true"]: + return _result(manifests_output) + if args[:3] == ["podman", "manifest", "inspect"]: + return _result(json.dumps(inspect_payload)) + raise AssertionError(f"Unexpected command: {args}") + + monkeypatch.setattr(oci_tools, "run_cmd", fake_run_cmd) + monkeypatch.setattr(oci_tools, "engine_supports_manifest_attributes", lambda engine: True) + + models = oci_tools.list_manifests(EngineArgs(engine="podman")) + + assert len(models) == 1 + assert models[0]["name"] == "oci://localhost/annotation-filtered:latest" + assert models[0]["size"] == 456 + + +def test_list_artifacts_handles_unsupported_format_flag(monkeypatch): + def fake_run_cmd(args, **kwargs): + if args[:3] == ["podman", "artifact", "ls"]: + raise subprocess.CalledProcessError(125, args) + raise AssertionError(f"Unexpected command: {args}") + + monkeypatch.setattr(oci_tools, "run_cmd", fake_run_cmd) + + models = oci_tools.list_artifacts(EngineArgs(engine="podman")) + + assert models == [] + + +def test_engine_supports_manifest_attributes_uses_semver(monkeypatch): + monkeypatch.setattr(oci_tools, "engine_version", lambda engine: SemVer(10, 0, 0)) + assert oci_tools.engine_supports_manifest_attributes("podman") is True + + monkeypatch.setattr(oci_tools, "engine_version", lambda engine: SemVer(4, 9, 9)) + assert oci_tools.engine_supports_manifest_attributes("podman") is False diff --git a/test/unit/test_rlcr.py b/test/unit/test_rlcr.py index 4d74299e3..4f7ceddb5 100644 --- a/test/unit/test_rlcr.py +++ b/test/unit/test_rlcr.py @@ -1,3 +1,5 @@ +import json +import os import subprocess import tempfile import warnings @@ -6,6 +8,11 @@ import pytest from ramalama.arg_types import StoreArgs +from ramalama.oci_tools import OciRef +from ramalama.transports.oci import resolver as oci_resolver +from ramalama.transports.oci import spec as oci_spec +from ramalama.transports.oci import strategies as oci_strategies +from ramalama.transports.oci.oci_artifact import download_oci_artifact from ramalama.transports.rlcr import RamalamaContainerRegistry, find_model_file_in_image @@ -24,6 +31,14 @@ def args(): with tempfile.TemporaryDirectory() as tmpdir: args_obj = StoreArgs(store=tmpdir, engine="podman", container=True) args_obj.dryrun = False # Add dryrun attribute dynamically + args_obj.quiet = False + args_obj.tlsverify = True + args_obj.authfile = None + args_obj.username = None + args_obj.password = None + args_obj.passwordstdin = False + args_obj.REGISTRY = None + args_obj.verify = True yield args_obj @@ -52,7 +67,7 @@ def test_rlcr_model_initialization(self, rlcr_model): def test_rlcr_inherits_from_oci(self, rlcr_model): """Test that RLCR properly inherits from OCI""" - from ramalama.transports.oci import OCI + from ramalama.transports.oci.oci import OCI assert isinstance(rlcr_model, OCI) @@ -129,3 +144,94 @@ def test_model_path_construction_variations(self, input_model, expected_path): model=input_model, model_store_path="/tmp", conman="podman", ignore_stderr=False ) assert rlcr.model == expected_path + + +class TestRLCRArtifactFallback: + def test_pull_falls_back_to_artifact(self, rlcr_model, args): + args.quiet = False + rlcr_model.strategy = oci_strategies.HttpArtifactStrategy( + engine=rlcr_model.conman, model_store=rlcr_model.model_store + ) + + with patch('ramalama.transports.oci.strategies.download_oci_artifact', return_value=True) as mock_download: + rlcr_model.pull(args) + mock_download.assert_called_once() + + def test_pull_re_raises_when_artifact_download_fails(self, rlcr_model, args): + rlcr_model.strategy = oci_strategies.HttpArtifactStrategy( + engine=rlcr_model.conman, model_store=rlcr_model.model_store + ) + + with patch( + 'ramalama.transports.oci.strategies.download_oci_artifact', + side_effect=subprocess.CalledProcessError(1, "download"), + ): + with pytest.raises(subprocess.CalledProcessError): + rlcr_model.pull(args) + + +class TestOCIArtifactDownload: + def test_download_oci_artifact_creates_snapshot(self, rlcr_model, args): + args.verify = False # skip model verification for synthetic data + store = rlcr_model.model_store + digest = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + + class FakeClient: + def __init__(self, registry, repository, reference): + self.registry = registry + self.repository = repository + self.reference = reference + + def get_manifest(self): + manifest = { + "artifactType": oci_spec.CNAI_ARTIFACT_TYPE, + "blobs": [ + { + "mediaType": "application/octet-stream", + "digest": digest, + "size": 4, + "annotations": { + oci_spec.LAYER_ANNOTATION_FILEPATH: "gemma-3-270m-it-Q6_K.gguf", + oci_spec.LAYER_ANNOTATION_FILE_MEDIATYPE_UNTESTED: "true", + oci_spec.LAYER_ANNOTATION_FILE_METADATA: json.dumps( + { + "name": "gemma-3-270m-it-Q6_K.gguf", + "mode": 0o644, + "uid": 0, + "gid": 0, + "size": 4, + "mtime": "2024-01-01T00:00:00Z", + "typeflag": ord("0"), + } + ), + }, + } + ], + } + return manifest, "sha256:feedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface" + + def download_blob(self, blob_digest, dest_path): + assert blob_digest == digest + data = b"test" + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + with open(dest_path, "wb") as fh: + fh.write(data) + + with patch('ramalama.transports.oci.oci_artifact.OCIRegistryClient', FakeClient): + result = download_oci_artifact( + reference="rlcr.io/ramalama/gemma3-270m:gguf", + model_store=store, + model_tag=rlcr_model.model_tag, + ) + + assert result is True + ref = store.get_ref_file(rlcr_model.model_tag) + assert ref is not None + model_files = ref.model_files + assert model_files + assert model_files[0].name == "gemma-3-270m-it-Q6_K.gguf" + + new_model = RamalamaContainerRegistry( + model="gemma3-270m", model_store_path=args.store, conman="podman", ignore_stderr=False + ) + assert oci_resolver.model_store_has_snapshot(new_model.model_store, OciRef.from_ref_string(new_model.model)) diff --git a/test/unit/test_transport_base.py b/test/unit/test_transport_base.py index 1a4d8e93e..c7adbe380 100644 --- a/test/unit/test_transport_base.py +++ b/test/unit/test_transport_base.py @@ -8,7 +8,7 @@ from ramalama.common import MNT_DIR from ramalama.config import get_config from ramalama.transports.base import Transport, compute_ports, compute_serving_port -from ramalama.transports.oci import OCI +from ramalama.transports.oci.oci import OCI from ramalama.transports.transport_factory import TransportFactory @@ -18,11 +18,6 @@ class ARGS: container = True -config = get_config() - -DEFAULT_PORT_RANGE = config.default_port_range -DEFAULT_PORT = int(config.port) - hf_granite_blob = "https://huggingface.co/ibm-granite/granite-3b-code-base-2k-GGUF/blob" ms_granite_blob = "https://modelscope.cn/models/ibm-granite/granite-3b-code-base-2k-GGUF/file/view" _CONFIG = get_config() @@ -30,6 +25,20 @@ class ARGS: DEFAULT_PORT_RANGE = _CONFIG.default_port_range +@pytest.fixture +def force_oci_image(monkeypatch): + from ramalama.transports.oci.strategy import OCIStrategyFactory + + monkeypatch.setattr(OCIStrategyFactory, "resolve", lambda self, model: self.strategies("image")) + + +@pytest.fixture +def force_oci_artifact(monkeypatch): + from ramalama.transports.oci.strategy import OCIStrategyFactory + + monkeypatch.setattr(OCIStrategyFactory, "resolve", lambda self, model: self.strategies("artifact")) + + @pytest.mark.parametrize( "model_input,expected_name,expected_tag,expected_orga", [ @@ -86,7 +95,13 @@ class ARGS: ), ], ) -def test_extract_model_identifiers(model_input: str, expected_name: str, expected_tag: str, expected_orga: str): +def test_extract_model_identifiers( + model_input: str, + expected_name: str, + expected_tag: str, + expected_orga: str, + force_oci_image, +): args = ARGS() args.engine = "podman" name, tag, orga = TransportFactory(model_input, args).create().extract_model_identifiers() @@ -270,63 +285,82 @@ def test_mlx_run_uses_server_client_model( assert args.url == "http://127.0.0.1:8080/v1" -class TestOCIModelSetupMounts: - """Test the OCI model setup_mounts functionality that was refactored""" +class TestOCIModelSetupMountsPodman: + """Test OCI model setup_mounts for Podman""" @pytest.fixture - def mock_engine(self): - """Create a mock engine for testing""" + def mock_podman_engine(self): + """Create a mock Podman engine for testing""" engine = Mock() engine.use_podman = True + engine.use_docker = False engine.add = Mock() return engine @pytest.fixture - def oci_model(self): - """Create an OCI model for testing""" + def oci_model_podman(self, force_oci_image): + """Create a Podman OCI model for testing""" model = OCI("test-registry.io/test-model:latest", "/tmp/store", "podman") return model - def test_setup_mounts_dryrun(self, oci_model, mock_engine): + def test_setup_mounts_dryrun(self, oci_model_podman, mock_podman_engine): """Test that setup_mounts returns early on dryrun""" args = Namespace(dryrun=True) - oci_model.engine = mock_engine + oci_model_podman.engine = mock_podman_engine - result = oci_model.setup_mounts(args) + result = oci_model_podman.setup_mounts(args) assert result is None - mock_engine.add.assert_not_called() + mock_podman_engine.add.assert_not_called() - def test_setup_mounts_oci_podman(self, oci_model, mock_engine): + def test_setup_mounts_oci_podman(self, oci_model_podman, mock_podman_engine): """Test OCI model mounting with Podman (image mount)""" args = Namespace(dryrun=False) - mock_engine.use_podman = True - oci_model.engine = mock_engine + oci_model_podman.engine = mock_podman_engine + + oci_model_podman.setup_mounts(args) - oci_model.setup_mounts(args) + expected_mount = ( + f"--mount=type=image,src={oci_model_podman.model},destination={MNT_DIR},subpath=/models,rw=false" + ) + mock_podman_engine.add.assert_called_once_with([expected_mount]) + + +class TestOCIModelSetupMountsDocker: + """Test OCI model setup_mounts for Docker""" + + @pytest.fixture + def mock_docker_engine(self): + """Create a mock Docker engine for testing""" + engine = Mock() + engine.use_podman = False + engine.use_docker = True + engine.add = Mock() + return engine - expected_mount = f"--mount=type=image,src={oci_model.model},destination={MNT_DIR},subpath=/models,rw=false" - mock_engine.add.assert_called_once_with([expected_mount]) + @pytest.fixture + def oci_model_docker(self, force_oci_image): + """Create a Docker OCI model for testing""" + model = OCI("test-registry.io/test-model:latest", "/tmp/store", "docker") + return model @patch('ramalama.transports.base.populate_volume_from_image') - def test_setup_mounts_oci_docker(self, mock_populate_volume, oci_model, mock_engine): + def test_setup_mounts_oci_docker(self, mock_populate_volume, oci_model_docker, mock_docker_engine): """Test OCI model mounting with Docker (volume mount using populate_volume_from_image)""" args = Namespace(dryrun=False, container=True, generate=False, engine="docker") - mock_engine.use_podman = False - mock_engine.use_docker = True - oci_model.engine = mock_engine + oci_model_docker.engine = mock_docker_engine mock_volume_name = "ramalama-models-abc123" mock_populate_volume.return_value = mock_volume_name - oci_model.setup_mounts(args) + oci_model_docker.setup_mounts(args) # Verify populate_volume_from_image was called mock_populate_volume.assert_called_once() call_args = mock_populate_volume.call_args - assert call_args[0][0] == oci_model # model argument + assert call_args[0][0] == oci_model_docker # model argument assert call_args[0][1] is args # Verify mount command was added expected_mount = f"--mount=type=volume,src={mock_volume_name},dst={MNT_DIR},readonly" - mock_engine.add.assert_called_once_with([expected_mount]) + mock_docker_engine.add.assert_called_once_with([expected_mount]) diff --git a/test/unit/test_transport_factory.py b/test/unit/test_transport_factory.py index 84373261b..fd3848cb0 100644 --- a/test/unit/test_transport_factory.py +++ b/test/unit/test_transport_factory.py @@ -8,7 +8,7 @@ from ramalama.transports.api import APITransport from ramalama.transports.huggingface import Huggingface from ramalama.transports.modelscope import ModelScope -from ramalama.transports.oci import OCI +from ramalama.transports.oci.oci import OCI from ramalama.transports.ollama import Ollama from ramalama.transports.rlcr import RamalamaContainerRegistry from ramalama.transports.transport_factory import TransportFactory