1
0
mirror of https://github.com/containers/ramalama.git synced 2026-02-05 06:46:39 +01:00

Merge pull request #2046 from rhatdan/artifact

Add support for converting to OCI artifacts
This commit is contained in:
Daniel J Walsh
2025-12-15 10:56:40 -05:00
committed by GitHub
30 changed files with 1673 additions and 214 deletions

View File

@@ -7,7 +7,7 @@ SHAREDIR ?= ${PREFIX}/share
PYTHON ?= $(shell command -v python3 python|head -n1)
DESTDIR ?= /
PATH := $(PATH):$(HOME)/.local/bin
MYPIP ?= pip
MYPIP ?= uv pip
IMAGE ?= ramalama
PROJECT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
EXCLUDE_DIRS := .venv venv .tox build

View File

@@ -39,14 +39,15 @@ Image to use when converting to GGUF format (when then `--gguf` option has been
executable and available in the `PATH`. The script is available from the `llama.cpp` GitHub repo. Defaults to the current
`quay.io/ramalama/ramalama-rag` image.
#### **--type**=*raw* | *car*
#### **--type**="artifact" | *raw* | *car*
type of OCI Model Image to convert.
Convert the MODEL to the specified OCI Object
| Type | Description |
| ---- | ------------------------------------------------------------- |
| car | Includes base image with the model stored in a /models subdir |
| raw | Only the model and a link file model.file to it stored at / |
| Type | Description |
| -------- | ------------------------------------------------------------- |
| artifact | Store AI Models as artifacts |
| car | Traditional OCI image including base image with the model stored in a /models subdir |
| raw | Traditional OCI image including only the model and a link file `model.file` pointed at it stored at / |
## EXAMPLE

View File

@@ -32,6 +32,14 @@
#
#carimage = "registry.access.redhat.com/ubi10-micro:latest"
# Convert the MODEL to the specified OCI Object
# Options: artifact, car, raw
#
# artifact: Store AI Models as artifacts
# car: Traditional OCI image including base image with the model stored in a /models subdir
# raw: Traditional OCI image including only the model and a link file `model.file` pointed at it stored at /
#convert_type = "raw"
# Run RamaLama in the default container.
#
#container = true

View File

@@ -84,6 +84,18 @@ Min chunk size to attempt reusing from the cache via KV shifting
Run RamaLama in the default container.
RAMALAMA_IN_CONTAINER environment variable overrides this field.
**convert_type**="raw"
Convert the MODEL to the specified OCI Object
Options: artifact, car, raw
| Type | Description |
| -------- | ------------------------------------------------------------- |
| artifact | Store AI Models as artifacts |
| car | Traditional OCI image including base image with the model stored in a /models subdir |
| raw | Traditional OCI image including only the model and a link file `model.file` pointed at it stored at / |
**ctx_size**=0
Size of the prompt context (0 = loaded from model)

View File

@@ -734,11 +734,12 @@ def convert_parser(subparsers):
)
parser.add_argument(
"--type",
default="raw",
choices=["car", "raw"],
default=CONFIG.convert_type,
choices=["artifact", "car", "raw"],
help="""\
type of OCI Model Image to push.
Model "artifact" stores the AI Model as an OCI Artifact.
Model "car" includes base image with the model stored in a /models subdir.
Model "raw" contains the model and a link file model.file to it stored at /.""",
)
@@ -775,11 +776,12 @@ def push_parser(subparsers):
add_network_argument(parser)
parser.add_argument(
"--type",
default="raw",
choices=["car", "raw"],
default=CONFIG.convert_type,
choices=["artifact", "car", "raw"],
help="""\
type of OCI Model Image to push.
Model "artifact" stores the AI Model as an OCI Artifact.
Model "car" includes base image with the model stored in a /models subdir.
Model "raw" contains the model and a link file model.file to it stored at /.""",
)
@@ -794,10 +796,12 @@ Model "raw" contains the model and a link file model.file to it stored at /.""",
parser.set_defaults(func=push_cli)
def _get_source_model(args):
def _get_source_model(args, transport=None):
src = shortnames.resolve(args.SOURCE)
smodel = New(src, args)
smodel = New(src, args, transport=transport)
if smodel.type == "OCI":
if not args.TARGET:
return smodel
raise ValueError(f"converting from an OCI based image {src} is not supported")
if not smodel.exists() and not args.dryrun:
smodel.pull(args)
@@ -805,8 +809,12 @@ def _get_source_model(args):
def push_cli(args):
source_model = _get_source_model(args)
target = args.SOURCE
transport = None
if not args.TARGET:
transport = "oci"
source_model = _get_source_model(args, transport=transport)
if args.TARGET:
target = shortnames.resolve(args.TARGET)
target_model = New(target, args)
@@ -1189,9 +1197,14 @@ def serve_cli(args):
model.ensure_model_exists(args)
except KeyError as e:
try:
if "://" in args.MODEL:
raise e
args.quiet = True
model = TransportFactory(args.MODEL, args, ignore_stderr=True).create_oci()
model.ensure_model_exists(args)
# Since this is a OCI model, prepend oci://
args.MODEL = f"oci://{args.MODEL}"
except Exception:
raise e
@@ -1432,27 +1445,42 @@ def rm_parser(subparsers):
parser.set_defaults(func=rm_cli)
def _rm_oci_model(model, args) -> bool:
# attempt to remove as a container image
try:
m = TransportFactory(model, args, ignore_stderr=True).create_oci()
return m.remove(args)
except Exception:
return False
def _rm_model(models, args):
exceptions = []
for model in models:
model = shortnames.resolve(model)
try:
m = New(model, args)
m.remove(args)
except KeyError as e:
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
except (KeyError, subprocess.CalledProcessError) as e:
for prefix in MODEL_TYPES:
if model.startswith(prefix + "://"):
if not args.ignore:
raise e
try:
# attempt to remove as a container image
m = TransportFactory(model, args, ignore_stderr=True).create_oci()
m.remove(args)
return
except Exception:
pass
if not args.ignore:
raise e
# attempt to remove as a container image
if _rm_oci_model(model, args) or args.ignore:
continue
exceptions.append(e)
if len(exceptions) > 0:
for exception in exceptions[1:]:
perror("Error: " + str(exception).strip("'\""))
raise exceptions[0]
def rm_cli(args):
@@ -1512,7 +1540,7 @@ def inspect_parser(subparsers):
def inspect_cli(args):
args.pull = "never"
model = New(args.MODEL, args)
model.inspect(args.all, args.get == "all", args.get, args.json, args.dryrun)
print(model.inspect(args.all, args.get == "all", args.get, args.json, args.dryrun))
def main() -> None:
@@ -1544,9 +1572,11 @@ def main() -> None:
args.func(args)
except urllib.error.HTTPError as e:
eprint(f"pulling {e.geturl()} failed: {e}", errno.EINVAL)
except FileNotFoundError as e:
eprint(e, errno.ENOENT)
except HelpException:
parser.print_help()
except (ConnectionError, IndexError, KeyError, ValueError, NoRefFileFound) as e:
except (IsADirectoryError, ConnectionError, IndexError, KeyError, ValueError, NoRefFileFound) as e:
eprint(e, errno.EINVAL)
except NotImplementedError as e:
eprint(e, errno.ENOSYS)

View File

@@ -283,7 +283,7 @@ def verify_checksum(filename: str) -> bool:
def genname():
return "ramalama_" + "".join(random.choices(string.ascii_letters + string.digits, k=10))
return "ramalama-" + "".join(random.choices(string.ascii_letters + string.digits, k=10))
def engine_version(engine: SUPPORTED_ENGINES) -> str:

View File

@@ -221,6 +221,7 @@ class BaseConfig:
carimage: str = "registry.access.redhat.com/ubi10-micro:latest"
container: bool = None # type: ignore
ctx_size: int = 0
convert_type: Literal["artifact", "car", "raw"] = "raw"
default_image: str = DEFAULT_IMAGE
default_rag_image: str = DEFAULT_RAG_IMAGE
dryrun: bool = False

View File

@@ -2,7 +2,7 @@ import os
import platform
from typing import Optional, Tuple
from ramalama.common import MNT_DIR, RAG_DIR, genname, get_accel_env_vars
from ramalama.common import MNT_DIR, RAG_DIR, get_accel_env_vars
from ramalama.file import PlainFile
from ramalama.path_utils import normalize_host_path_for_container
from ramalama.version import version
@@ -17,6 +17,7 @@ class Kube:
mmproj_paths: Optional[Tuple[str, str]],
args,
exec_args,
artifact: bool,
):
self.src_model_path, self.dest_model_path = model_paths
self.src_chat_template_path, self.dest_chat_template_path = (
@@ -29,11 +30,12 @@ class Kube:
if getattr(args, "name", None):
self.name = args.name
else:
self.name = genname()
self.name = "ramalama"
self.args = args
self.exec_args = exec_args
self.image = args.image
self.artifact = artifact
def _gen_volumes(self):
mounts = """\
@@ -41,15 +43,17 @@ class Kube:
volumes = """
volumes:"""
if os.path.exists(self.src_model_path):
m, v = self._gen_path_volume()
mounts += m
volumes += v
else:
subPath = ""
if not self.artifact:
subPath = """
subPath: /models"""
mounts += f"""
- mountPath: {MNT_DIR}
subPath: /models
- mountPath: {MNT_DIR}{subPath}
name: model"""
volumes += self._gen_oci_volume()
@@ -104,7 +108,7 @@ class Kube:
def _gen_oci_volume(self):
return f"""
- image:
reference: {self.ai_image}
reference: {self.src_model_path}
pullPolicy: IfNotPresent
name: model"""
@@ -176,7 +180,7 @@ class Kube:
for k, v in env_vars.items():
env_spec += f"""
- name: {k}
value: {v}"""
value: \"{v}\""""
return env_spec
@@ -191,7 +195,7 @@ class Kube:
# it into Kubernetes.
#
# Created with ramalama-{_version}
apiVersion: v1
apiVersion: apps/v1
kind: Deployment
metadata:
name: {self.name}

View File

@@ -1,14 +1,79 @@
import json
import subprocess
from datetime import datetime
import ramalama.annotations as annotations
from ramalama.arg_types import EngineArgType
from ramalama.common import engine_version, run_cmd
from ramalama.logger import logger
ocilabeltype = "org.containers.type"
def engine_supports_manifest_attributes(engine):
def convert_from_human_readable_size(input) -> float:
sizes = [("KB", 1024), ("MB", 1024**2), ("GB", 1024**3), ("TB", 1024**4), ("B", 1)]
input = input.lower()
for unit, size in sizes:
if input.endswith(unit) or input.endswith(unit.lower()):
return float(input[: -len(unit)]) * size
return float(input)
def list_artifacts(args: EngineArgType):
if args.engine == "docker":
return []
conman_args = [
args.engine,
"artifact",
"ls",
"--format",
(
'{"name":"oci://{{ .Repository }}:{{ .Tag }}",\
"created":"{{ .CreatedAt }}", \
"size":"{{ .Size }}", \
"ID":"{{ .Digest }}"},'
),
]
try:
if (output := run_cmd(conman_args, ignore_stderr=True).stdout.decode("utf-8").strip()) == "":
return []
except subprocess.CalledProcessError as e:
logger.debug(e)
return []
artifacts = json.loads(f"[{output[:-1]}]")
models = []
for artifact in artifacts:
conman_args = [
args.engine,
"artifact",
"inspect",
artifact["ID"],
]
output = run_cmd(conman_args).stdout.decode("utf-8").strip()
if output == "":
continue
inspect = json.loads(output)
if "Manifest" not in inspect:
continue
if "artifactType" not in inspect["Manifest"]:
continue
if inspect["Manifest"]['artifactType'] != annotations.ArtifactTypeModelManifest:
continue
models += [
{
"name": artifact["name"],
"modified": artifact["created"],
"size": convert_from_human_readable_size(artifact["size"]),
}
]
return models
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":
@@ -91,26 +156,27 @@ def list_models(args: EngineArgType):
"--format",
formatLine,
]
models = []
output = run_cmd(conman_args, env={"TZ": "UTC"}).stdout.decode("utf-8").strip()
if output == "":
return []
if output != "":
models += json.loads(f"[{output[:-1]}]")
# exclude dangling images having no tag (i.e. <none>:<none>)
models = [model for model in models if model["name"] != "oci://<none>:<none>"]
models = json.loads(f"[{output[:-1]}]")
# exclude dangling images having no tag (i.e. <none>:<none>)
models = [model for model in models if model["name"] != "oci://<none>:<none>"]
# Grab the size from the inspect command
if conman == "docker":
# grab the size from the inspect command
for model in models:
conman_args = [conman, "image", "inspect", model["id"], "--format", "{{.Size}}"]
output = run_cmd(conman_args).stdout.decode("utf-8").strip()
# convert the number value from the string output
model["size"] = int(output)
# drop the id from the model
del model["id"]
# Grab the size from the inspect command
if conman == "docker":
# grab the size from the inspect command
for model in models:
conman_args = [conman, "image", "inspect", model["id"], "--format", "{{.Size}}"]
output = run_cmd(conman_args).stdout.decode("utf-8").strip()
# convert the number value from the string output
model["size"] = int(output)
# drop the id from the model
del model["id"]
models += list_manifests(args)
models += list_artifacts(args)
for model in models:
# Convert to ISO 8601 format
parsed_date = datetime.fromisoformat(

View File

@@ -15,6 +15,7 @@ class Quadlet:
mmproj_path: Optional[Tuple[str, str]],
args,
exec_args,
artifact: bool,
):
self.src_model_path, self.dest_model_path = model_paths
self.src_chat_template_path, self.dest_chat_template_path = (
@@ -33,6 +34,7 @@ class Quadlet:
self.name = model_name
self.args = args
self.artifact = artifact
self.exec_args = exec_args
self.image = args.image
self.rag = ""
@@ -147,11 +149,18 @@ class Quadlet:
files.append(self._gen_image(self.name, self.ai_image))
quadlet_file.add(
"Container",
"Mount",
f"type=image,source={self.ai_image},destination={MNT_DIR},subpath=/models,readwrite=false",
)
if self.artifact:
quadlet_file.add(
"Container",
"Mount",
f"type=artifact,source={self.ai_image},destination={MNT_DIR}",
)
else:
quadlet_file.add(
"Container",
"Mount",
f"type=image,source={self.ai_image},destination={MNT_DIR},subpath=/models,readwrite=false",
)
return files
def _gen_port(self, quadlet_file: UnitFile):

View File

@@ -6,6 +6,7 @@ import subprocess
import sys
import time
from abc import ABC, abstractmethod
from functools import cached_property
from typing import Any, Dict, Optional
import ramalama.chat as chat
@@ -100,7 +101,7 @@ class TransportBase(ABC):
raise self.__not_implemented_error("push")
@abstractmethod
def remove(self, args):
def remove(self, args) -> bool:
raise self.__not_implemented_error("rm")
@abstractmethod
@@ -153,6 +154,10 @@ class Transport(TransportBase):
self.default_image = accel_image(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"
@@ -202,6 +207,13 @@ class Transport(TransportBase):
if self.model_type == 'oci':
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"
else:
return f"oci://{self.model}"
@@ -291,10 +303,13 @@ class Transport(TransportBase):
return f"{MNT_DIR}/{chat_template_file.name}"
return self.model_store.get_blob_file_path(chat_template_file.hash)
def remove(self, args):
def remove(self, args) -> bool:
_, tag, _ = self.extract_model_identifiers()
if not self.model_store.remove_snapshot(tag) and not args.ignore:
if self.model_store.remove_snapshot(tag):
return True
if not args.ignore:
raise KeyError(f"Model '{self.model}' not found")
return False
def get_container_name(self, args):
if getattr(args, "name", None):
@@ -347,9 +362,10 @@ class Transport(TransportBase):
def setup_mounts(self, args):
if args.dryrun:
return
if self.model_type == 'oci':
if self.engine.use_podman:
mount_cmd = f"--mount=type=image,src={self.model},destination={MNT_DIR},subpath=/models,rw=false"
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))
@@ -594,7 +610,7 @@ class Transport(TransportBase):
)
elif args.generate.gen_type == "kube":
self.kube(
(model_src_path, model_dest_path),
(model_src_path.removeprefix("oci://"), model_dest_path),
(chat_template_src_path, chat_template_dest_path),
(mmproj_src_path, mmproj_dest_path),
args,
@@ -649,19 +665,21 @@ class Transport(TransportBase):
raise e
def quadlet(self, model_paths, chat_template_paths, mmproj_paths, args, exec_args, output_dir):
quadlet = Quadlet(self.model_name, model_paths, chat_template_paths, mmproj_paths, args, exec_args)
quadlet = Quadlet(
self.model_name, model_paths, chat_template_paths, mmproj_paths, args, exec_args, self.artifact
)
for generated_file in quadlet.generate():
generated_file.write(output_dir)
def quadlet_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)
kube = Kube(self.model_name, model_paths, chat_template_paths, mmproj_paths, args, exec_args, self.artifact)
kube.generate().write(output_dir)
quadlet = Quadlet(kube.name, model_paths, chat_template_paths, mmproj_paths, args, exec_args)
quadlet = Quadlet(kube.name, model_paths, chat_template_paths, mmproj_paths, args, exec_args, self.artifact)
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)
kube = Kube(self.model_name, model_paths, chat_template_paths, mmproj_paths, args, exec_args, self.artifact)
kube.generate().write(output_dir)
def compose(self, model_paths, chat_template_paths, mmproj_paths, args, exec_args, output_dir):
@@ -681,41 +699,39 @@ class Transport(TransportBase):
get_field: str = "",
as_json: bool = False,
dryrun: bool = False,
) -> None:
) -> Any:
model_name = self.filename
model_registry = self.type.lower()
model_path = self._get_inspect_model_path(dryrun)
if GGUFInfoParser.is_model_gguf(model_path):
if not show_all_metadata and get_field == "":
gguf_info: GGUFModelInfo = GGUFInfoParser.parse(model_name, model_registry, model_path)
print(gguf_info.serialize(json=as_json, all=show_all))
return
return gguf_info.serialize(json=as_json, all=show_all)
metadata = GGUFInfoParser.parse_metadata(model_path)
if show_all_metadata:
print(metadata.serialize(json=as_json))
return
return metadata.serialize(json=as_json)
elif get_field != "": # If a specific field is requested, print only that field
field_value = metadata.get(get_field)
if field_value is None:
raise KeyError(f"Field '{get_field}' not found in GGUF model metadata")
print(field_value)
return
return field_value
if SafetensorInfoParser.is_model_safetensor(model_name):
safetensor_info: SafetensorModelInfo = SafetensorInfoParser.parse(model_name, model_registry, model_path)
print(safetensor_info.serialize(json=as_json, all=show_all))
return
return safetensor_info.serialize(json=as_json, all=show_all)
print(ModelInfoBase(model_name, model_registry, model_path).serialize(json=as_json))
return ModelInfoBase(model_name, model_registry, model_path).serialize(json=as_json)
def print_pull_message(self, model_name):
def print_pull_message(self, model_name) -> None:
model_name = trim_model_name(model_name)
# Write messages to stderr
perror(f"Downloading {model_name} ...")
perror(f"Trying to pull {model_name} ...")
def is_artifact(self) -> bool:
return False
def compute_ports(exclude: list[str] | None = None) -> list[int]:
excluded = exclude and set(map(int, exclude)) or set()

View File

@@ -1,15 +1,17 @@
import copy
import json
import os
import shutil
import subprocess
import tempfile
from textwrap import dedent
from typing import Tuple
import ramalama.annotations as annotations
from ramalama.common import exec_cmd, perror, run_cmd, set_accel_env_vars
from ramalama.common import MNT_DIR, engine_version, 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 Transport
from ramalama.transports.base import NoRefFileFound, Transport
prefix = "oci://"
@@ -23,9 +25,11 @@ class OCI(Transport):
def __init__(self, model: str, model_store_path: str, conman: str, ignore_stderr: bool = False):
super().__init__(model, model_store_path)
if ":" not in self.model:
self.model = f"{self.model}:latest"
if not conman:
raise ValueError("RamaLama OCI Images requires a container engine")
self.conman = conman
self.ignore_stderr = ignore_stderr
@@ -119,8 +123,13 @@ class OCI(Transport):
def _generate_containerfile(self, source_model, args):
# Generate the containerfile content
# Keep this in sync with docs/ramalama-oci.5.md !
is_car = args.type == "car"
is_raw = args.type == "raw"
if args.type == "artifact":
raise TypeError("artifact handling should not generate containerfiles.")
if not is_car and not is_raw:
raise ValueError(f"argument --type: invalid choice: '{args.type}' (choose from artifact, car, raw)")
content = [f"FROM {args.carimage} AS build"]
model_name = source_model.model_name
ref_file = source_model.model_store.get_ref_file(source_model.model_tag)
@@ -164,6 +173,52 @@ class OCI(Transport):
else:
run_cmd(cmd_args)
def _rm_artifact(self, ignore):
rm_cmd = [
self.conman,
"artifact",
"rm",
]
if ignore:
rm_cmd.append("--ignore")
rm_cmd.append(self.model)
run_cmd(
rm_cmd,
ignore_all=True,
)
def _add_artifact(self, create, name, path, file_name) -> None:
cmd = [
self.conman,
"artifact",
"add",
"--annotation",
f"org.opencontainers.image.title={file_name}",
]
if create:
if self.conman == "podman" and engine_version("podman") >= "5.7.0":
cmd.append("--replace")
cmd.extend(["--type", annotations.ArtifactTypeModelManifest])
else:
cmd.extend(["--append"])
cmd.extend([self.model, path])
run_cmd(
cmd,
ignore_stderr=True,
)
def _create_artifact(self, source_model, target, args) -> None:
model_name = source_model.model_name
ref_file = source_model.model_store.get_ref_file(source_model.model_tag)
name = ref_file.model_files[0].name if ref_file.model_files else model_name
create = True
for file in ref_file.files:
blob_file_path = source_model.model_store.get_blob_file_path(file.hash)
self._add_artifact(create, name, blob_file_path, file.name)
create = False
def _create_manifest_without_attributes(self, target, imageid, args):
# Create manifest list for target with imageid
cmd_args = [
@@ -228,6 +283,11 @@ class OCI(Transport):
run_cmd(rm_cmd, ignore_stderr=True, stdout=None)
except subprocess.CalledProcessError:
pass
if args.type == "artifact":
perror(f"Creating Artifact {self.model} ...")
self._create_artifact(source_model, self.model, args)
return
perror(f"Building {self.model} ...")
imageid = self.build(source_model, args)
if args.dryrun:
@@ -249,9 +309,13 @@ Tagging build instead
def push(self, source_model, args):
target = self.model
source = source_model.model
perror(f"Pushing {self.model} ...")
conman_args = [self.conman, "push"]
type = "image"
if args.type == "artifact":
type = args.type
conman_args.insert(1, "artifact")
perror(f"Pushing {type} {self.model} ...")
if args.authfile:
conman_args.extend([f"--authfile={args.authfile}"])
if str(args.tlsverify).lower() == "false":
@@ -260,10 +324,16 @@ Tagging build instead
if source != target:
self._convert(source_model, args)
try:
run_cmd(conman_args)
run_cmd(conman_args, ignore_stderr=self.ignore_stderr)
except subprocess.CalledProcessError as e:
perror(f"Failed to push OCI {target} : {e}")
raise e
try:
if args.type != "artifact":
perror(f"Pushing artifact {self.model} ...")
conman_args.insert(1, "artifact")
run_cmd(conman_args)
except subprocess.CalledProcessError:
perror(f"Failed to push OCI {target} : {e}")
raise e
def pull(self, args):
if not args.engine:
@@ -282,16 +352,23 @@ Tagging build instead
conman_args.extend([self.model])
run_cmd(conman_args, ignore_stderr=self.ignore_stderr)
def remove(self, args, ignore_stderr=False):
def remove(self, args) -> bool:
if self.conman is None:
raise NotImplementedError("OCI Images require a container engine")
try:
conman_args = [self.conman, "manifest", "rm", self.model]
run_cmd(conman_args, ignore_stderr=self.ignore_stderr)
run_cmd(conman_args, ignore_stderr=True)
except subprocess.CalledProcessError:
conman_args = [self.conman, "rmi", f"--force={args.ignore}", self.model]
run_cmd(conman_args, ignore_stderr=self.ignore_stderr)
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 exists(self) -> bool:
if self.conman is None:
@@ -299,7 +376,88 @@ Tagging build instead
conman_args = [self.conman, "image", "inspect", self.model]
try:
run_cmd(conman_args, ignore_stderr=self.ignore_stderr)
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
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 inspect(
self,
show_all: bool = False,
show_all_metadata: bool = False,
get_field: str = "",
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:
print(out)
else:
print(f"{out} Type: {type}")
def is_artifact(self) -> bool:
try:
_, oci_type = self._inspect()
except (NoRefFileFound, subprocess.CalledProcessError):
return False
return oci_type == "Artifact"
def mount_cmd(self):
if self.artifact:
return f"--mount=type=artifact,src={self.model},destination={MNT_DIR}"
else:
return f"--mount=type=image,src={self.model},destination={MNT_DIR},subpath=/models,rw=false"

View File

@@ -57,6 +57,29 @@ def test_model():
return "smollm:135m" if sys.byteorder == "little" else "stories-be:260k"
def get_podman_version():
"""Get podman version as a tuple of integers (major, minor, patch)."""
try:
import subprocess
result = subprocess.run(
["podman", "version", "--format", "{{.Client.Version}}"], capture_output=True, text=True, check=True
)
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):
return (0, 0, 0)
def is_podman_version_at_least(major, minor, patch=0):
"""Check if podman version is at least the specified version."""
current = get_podman_version()
required = (major, minor, patch)
return current >= required
skip_if_no_container = pytest.mark.skipif("not config.option.container", reason="no container mode is enabled")
skip_if_container = pytest.mark.skipif("config.option.container", reason="container mode is enabled")
skip_if_docker = pytest.mark.skipif(
@@ -81,3 +104,7 @@ xfail_if_windows = pytest.mark.xfail(
platform.system() == "Windows",
reason="Known failure on Windows",
)
skip_if_podman_too_old = pytest.mark.skipif(
not is_podman_version_at_least(5, 7, 0), reason="requires podman >= 5.7.0 for artifact support"
)

586
test/e2e/test_artifact.py Normal file
View File

@@ -0,0 +1,586 @@
"""
E2E tests for artifact functionality (OCI artifacts support)
These tests focus on the artifact-related functionality added in the PR,
including convert command with different types, config file support, and
error handling. Tests are designed to be fast and not require actual
artifact creation when possible.
"""
import json
import platform
import re
from pathlib import Path
from subprocess import STDOUT, CalledProcessError
from test.conftest import skip_if_docker, skip_if_no_container, skip_if_podman_too_old, skip_if_windows
from test.e2e.utils import RamalamaExecWorkspace, check_output
import pytest
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:
# Just test that list works
result = ctx.check_output(["ramalama", "list"])
# Should return without error (may be empty)
assert result is not None
@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:
# Get JSON output
result = ctx.check_output(["ramalama", "list", "--json"])
items = json.loads(result)
# Should be a list (may be empty)
assert isinstance(items, list)
@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:
test_file = Path(ctx.workspace_dir) / "testmodel.gguf"
test_file.write_text("test content")
with pytest.raises(CalledProcessError) as exc_info:
ctx.check_output(
["ramalama", "convert", "--type", "invalid_type", path_to_uri(test_file), "test:latest"], stderr=STDOUT
)
assert exc_info.value.returncode == 2
error_output = exc_info.value.output.decode("utf-8")
assert "invalid choice" in error_output or "error" in error_output.lower()
@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:
with pytest.raises(CalledProcessError) as exc_info:
ctx.check_output(
["ramalama", "convert", "--type", "raw", "file:///nonexistent/path/model.gguf", "test:latest"],
stderr=STDOUT,
)
# Exit code can be 2 (arg error), 5 (not found), or 22 (runtime error)
assert exc_info.value.returncode in [2, 5, 22]
error_output = exc_info.value.output.decode("utf-8")
assert "error" in error_output.lower() or "Error" in error_output
@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:
test_file = Path(ctx.workspace_dir) / "testmodel.gguf"
test_file.write_text("test content")
with pytest.raises(CalledProcessError) as exc_info:
ctx.check_output(
["ramalama", "--nocontainer", "convert", "--type", "raw", path_to_uri(test_file), "test:latest"],
stderr=STDOUT,
)
# Exit code 2 for argument parsing errors or 22 for runtime errors
assert exc_info.value.returncode in [2, 22]
error_output = exc_info.value.output.decode("utf-8")
# Should error due to either invalid choice or nocontainer conflict
assert "error" in error_output.lower()
@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:
# Try to remove something that doesn't exist
# This should either fail gracefully or succeed
try:
ctx.check_output(["ramalama", "rm", "nonexistent-model:latest"], stderr=STDOUT)
except CalledProcessError:
# It's ok if it fails, just shouldn't crash
pass
@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"])
info = json.loads(result)
# Check that basic sections exist
assert "Accelerator" in info
assert "Config" in info
assert "Engine" in info
# Check Engine section has Name field
assert "Name" in info["Engine"]
assert info["Engine"]["Name"] in ["podman", "docker"]
@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"])
# Should show --type option
assert "--type" in result
# Should mention at least car and raw types
assert "car" in result.lower()
assert "raw" in result.lower()
@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"])
# Should show --type option
assert "--type" in result
# Should mention at least car and raw types
assert "car" in result.lower()
assert "raw" in result.lower()
@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"])
push_help = check_output(["ramalama", "push", "--help"])
# Both should mention OCI-related types
for help_text in [convert_help, push_help]:
assert "--type" in help_text
# Check for type descriptions
assert "model" in help_text.lower() or "image" in help_text.lower()
@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"])
# Should contain version info
assert re.search(r"\d+\.\d+\.\d+", result)
@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 = """
[ramalama]
store="{workspace_dir}/.local/share/ramalama"
convert_type = "artifact"
"""
with RamalamaExecWorkspace(config=config) as ctx:
# Just verify the config is loaded without error
result = ctx.check_output(["ramalama", "info"])
info = json.loads(result)
# Config should be loaded (check if artifact appears in the info output string)
assert "artifact" in str(info).lower() or "Config" in info
@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"])
# Should show key subcommands
assert "convert" in result.lower()
assert "push" in result.lower()
assert "pull" in result.lower()
assert "list" in result.lower()
@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"])
# Should show convert-specific help
assert "convert" in result.lower()
assert "source" in result.lower()
assert "target" in result.lower()
@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"])
# Should show push-specific help
assert "push" in result.lower()
assert "source" in result.lower()
# Comprehensive artifact lifecycle tests
@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:
# Create a small test model file
test_file = Path(ctx.workspace_dir) / "small_model.gguf"
test_file.write_text("Small test model content for artifact")
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])
# Step 2: Verify it appears in list
result = ctx.check_output(["ramalama", "list"])
assert "test-artifact-lifecycle" in result
assert "latest" in result
# Step 3: Verify it appears in list --json
json_result = ctx.check_output(["ramalama", "list", "--json"])
models = json.loads(json_result)
found = False
for model in models:
if "test-artifact-lifecycle" in model.get("name", ""):
found = True
assert "size" in model
assert model["size"] > 0
break
assert found, "Artifact not found in JSON list output"
# Step 4: Remove the artifact
ctx.check_call(["ramalama", "rm", artifact_name])
# Step 5: Verify it's gone
result_after = ctx.check_output(["ramalama", "list"])
assert "test-artifact-lifecycle" not in result_after
@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:
# Create test model files
test_file1 = Path(ctx.workspace_dir) / "unique_model1.gguf"
test_file1.write_text("Model 1 content")
test_file2 = Path(ctx.workspace_dir) / "unique_model2.gguf"
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"])
# Create car type artifact
ctx.check_call(["ramalama", "convert", "--type", "car", path_to_uri(test_file2), "test-car-artifact-unique:v1"])
# Verify both appear in list using JSON (more reliable)
json_result = ctx.check_output(["ramalama", "list", "--json"])
models = json.loads(json_result)
found_raw = any("test-raw-artifact-unique" in m.get("name", "") for m in models)
found_car = any("test-car-artifact-unique" in m.get("name", "") for m in models)
assert found_raw, "Raw artifact not found in list"
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"])
# Verify both are gone using JSON
json_result_after = ctx.check_output(["ramalama", "list", "--json"])
models_after = json.loads(json_result_after)
for model in models_after:
assert "test-raw-artifact-unique" not in model.get("name", "")
assert "test-car-artifact-unique" not in model.get("name", "")
@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:
# Create a test file with known content
test_file = Path(ctx.workspace_dir) / "sized_model_unique.gguf"
test_content = "A" * 1000 # 1000 bytes
test_file.write_text(test_content)
artifact_name = "test-sized-artifact-unique:v1"
# Convert to artifact
ctx.check_call(["ramalama", "convert", "--type", "raw", path_to_uri(test_file), artifact_name])
# Get JSON output
json_result = ctx.check_output(["ramalama", "list", "--json"])
models = json.loads(json_result)
# Find our artifact and check size
artifact = None
for model in models:
if "test-sized-artifact-unique" in model.get("name", ""):
artifact = model
break
assert artifact is not None, "Artifact not found in JSON output"
assert "size" in artifact
# OCI images have overhead, so size might be larger than original file
assert artifact["size"] > 0, "Size should be greater than 0"
assert "name" in artifact
assert "modified" in artifact
# Clean up
ctx.check_call(["ramalama", "rm", 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:
# Create multiple test files with unique names
artifacts = []
for i in range(3):
test_file = Path(ctx.workspace_dir) / f"uniquemulti{i}.gguf"
test_file.write_text(f"Model {i} content for rm test")
artifact_name = f"test-multi-rm-unique-{i}:v1"
artifacts.append(artifact_name)
# Convert to artifact
ctx.check_call(["ramalama", "convert", "--type", "raw", path_to_uri(test_file), artifact_name])
# Verify all appear in list using JSON
json_result = ctx.check_output(["ramalama", "list", "--json"])
models = json.loads(json_result)
for i in range(3):
found = any(f"test-multi-rm-unique-{i}" in m.get("name", "") for m in models)
assert found, f"Artifact test-multi-rm-unique-{i} not found"
# Remove artifacts one at a time
for artifact_name in artifacts:
ctx.check_call(["ramalama", "rm", artifact_name])
# Verify all are gone using JSON
json_result_after = ctx.check_output(["ramalama", "list", "--json"])
models_after = json.loads(json_result_after)
for model in models_after:
for i in range(3):
assert f"test-multi-rm-unique-{i}" not in model.get(
"name", ""
), f"Artifact test-multi-rm-unique-{i} still present after removal"
@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:
test_file = Path(ctx.workspace_dir) / "tagged_model.gguf"
test_file.write_text("Tagged model content")
# Create artifacts 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}"]
)
# Verify all tags appear in list
result = ctx.check_output(["ramalama", "list"])
for tag in tags:
assert tag in result
# Clean up all tags
for tag in tags:
ctx.check_call(["ramalama", "rm", f"test-tagged-artifact:{tag}"])
# Verify all are gone
result_after = ctx.check_output(["ramalama", "list"])
assert "test-tagged-artifact" not in result_after
@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:
test_file = Path(ctx.workspace_dir) / "temp_model.gguf"
test_file.write_text("Temporary content")
artifact_name = "test-temp-artifact:latest"
# Create artifact
ctx.check_call(["ramalama", "convert", "--type", "raw", path_to_uri(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])
# Verify list doesn't contain it
result_after = ctx.check_output(["ramalama", "list"])
assert "test-temp-artifact" not in result_after
# JSON output should also not contain it
json_result = ctx.check_output(["ramalama", "list", "--json"])
models = json.loads(json_result)
for model in models:
assert "test-temp-artifact" not in model.get("name", "")
@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 = """
[ramalama]
store="{workspace_dir}/.local/share/ramalama"
convert_type = "raw"
"""
with RamalamaExecWorkspace(config=config) as ctx:
test_file = Path(ctx.workspace_dir) / "config_model.gguf"
test_file.write_text("Model using config default")
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])
# 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])
@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:
test_file1 = Path(ctx.workspace_dir) / "model_v1.gguf"
test_file1.write_text("Version 1 content")
test_file2 = Path(ctx.workspace_dir) / "model_v2.gguf"
test_file2.write_text("Version 2 content - this is longer")
artifact_name = "test-overwrite-artifact:latest"
# Create first version
ctx.check_call(["ramalama", "convert", "--type", "raw", path_to_uri(test_file1), artifact_name])
# Get size of first version
json_result1 = ctx.check_output(["ramalama", "list", "--json"])
models1 = json.loads(json_result1)
size1 = None
for model in models1:
if "test-overwrite-artifact" in model.get("name", ""):
size1 = model["size"]
break
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])
# Verify only one artifact with this name exists
result = ctx.check_output(["ramalama", "list"])
# Count occurrences - should be 1 (or 2 if showing both tag and name)
count = result.count("test-overwrite-artifact")
assert count >= 1
# Get size of second version
json_result2 = ctx.check_output(["ramalama", "list", "--json"])
models2 = json.loads(json_result2)
size2 = None
for model in models2:
if "test-overwrite-artifact" in model.get("name", ""):
size2 = model["size"]
break
assert size2 is not None
# Size should be different (second file is larger)
assert size2 >= size1, "Second version should be at least as large"
# Clean up
ctx.check_call(["ramalama", "rm", artifact_name])

View File

@@ -313,7 +313,7 @@ def test_run_keepalive(shared_ctx_with_models, test_model):
"tiny",
],
22,
r".*quay.io/ramalama/testrag: image not known",
r".*Error: quay.io/ramalama/testrag does not exist",
id="non-existing-image-with-rag",
),
],

View File

@@ -68,7 +68,7 @@ def test_basic_dry_run():
id="check model name", marks=skip_if_no_container
),
pytest.param(
[], r".*--name ramalama_.", None, None, True,
[], r".*--name ramalama-.", None, None, True,
id="check default --name flag", marks=skip_if_no_container
),
pytest.param(
@@ -624,7 +624,7 @@ def test_serve_kube_generation(test_model, generate, env_vars):
if "kube" in generate:
assert re.search(r".*env:", content)
assert re.search(r".*name: HIP_VISIBLE_DEVICES", content)
assert re.search(r".*value: 99", content)
assert re.search(r".*value: \"99\"", content)
elif "compose" in generate:
assert re.search(r".*environment:", content)
assert re.search(r".*- HIP_VISIBLE_DEVICES=99", content)
@@ -751,7 +751,7 @@ def test_serve_with_non_existing_images():
stderr=STDOUT,
)
assert exc_info.value.returncode == 22
assert re.search(r"quay.io/ramalama/rag: image not known.*", exc_info.value.output.decode("utf-8"))
assert re.search(r"Error: quay.io/ramalama/rag does not exist.*", exc_info.value.output.decode("utf-8"))
@pytest.mark.e2e

View File

@@ -143,7 +143,7 @@ EOF
is "$output" "\(Error: bogus: image not known\|docker: Error response from daemon: No such image: bogus:latest\)"
is "$output" ".*Error: Failed to serve model TinyLlama-1.1B-Chat-v1.0-GGUF, for ramalama run command"
run_ramalama 22 run --image bogus1 --rag quay.io/ramalama/rag --pull=never tiny
is "$output" "\(Error: quay.io/ramalama/rag: image not known\|Error response from daemon: No such image: quay.io/ramalama/rag:latest\)"
is "$output" "\(Error: quay.io/ramalama/rag does not exist\|Error response from daemon: No such image: quay.io/ramalama/rag:latest\)"
is "$output" ".*Error: quay.io/ramalama/rag does not exist"
}

View File

@@ -12,7 +12,7 @@ verify_begin=".*run --rm"
if is_container; then
run_ramalama -q --dryrun serve ${model}
is "$output" "${verify_begin}.*" "dryrun correct"
is "$output" ".*--name ramalama_.*" "dryrun correct"
is "$output" ".*--name ramalama-.*" "dryrun correct"
is "$output" ".*${model}" "verify model name"
is "$output" ".*--cache-reuse 256" "cache"
assert "$output" !~ ".*--no-webui"
@@ -111,11 +111,11 @@ verify_begin=".*run --rm"
run_ramalama -q --dryrun serve --detach ${model}
is "$output" ".*-d .*" "dryrun correct"
is "$output" ".*--name ramalama_.*" "serve in detach mode"
is "$output" ".*--name ramalama-.*" "serve in detach mode"
run_ramalama -q --dryrun serve -d ${model}
is "$output" ".*-d .*" "dryrun correct"
is "$output" ".*--name ramalama_.*" "dryrun correct"
is "$output" ".*--name ramalama-.*" "dryrun correct"
run_ramalama stop --all
}
@@ -408,7 +408,7 @@ verify_begin=".*run --rm"
run cat $name.yaml
is "$output" ".*env:" "Should contain env property"
is "$output" ".*name: HIP_VISIBLE_DEVICES" "Should contain env name"
is "$output" ".*value: 99" "Should contain env value"
is "$output" ".*value: \"99\"" "Should contain env value"
run_ramalama serve --name=${name} --port 1234 --generate=quadlet/kube $model
is "$output" ".*Generating Kubernetes YAML file: ${name}.yaml" "generate .yaml file"
@@ -424,7 +424,7 @@ verify_begin=".*run --rm"
run cat $name.yaml
is "$output" ".*env:" "Should contain env property"
is "$output" ".*name: HIP_VISIBLE_DEVICES" "Should contain env name"
is "$output" ".*value: 99" "Should contain env value"
is "$output" ".*value: \"99\"" "Should contain env value"
run cat $name.kube
is "$output" ".*Yaml=$name.yaml" "Should container container port"
@@ -542,7 +542,7 @@ verify_begin=".*run --rm"
is "$output" "Error: bogus: image not known"
run_ramalama 22 serve --image bogus1 --rag quay.io/ramalama/rag --pull=never tiny
is "$output" "Error: quay.io/ramalama/rag: image not known.*"
is "$output" "Error: quay.io/ramalama/rag does not exist"
}
@test "ramalama serve with rag" {

View File

@@ -0,0 +1,504 @@
#!/usr/bin/env bats
load helpers
load helpers.registry
load setup_suite
# bats test_tags=distro-integration
@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
testmodel=$RAMALAMA_TMPDIR/testmodel
artifact=artifact-test:latest
run_ramalama rm --ignore ${artifact}
echo "hello" > ${testmodel}
run_ramalama convert --type artifact file://${testmodel} ${artifact}
run_ramalama list
is "$output" ".*artifact-test.*latest" "artifact was created and listed"
# Verify it's actually an artifact by checking podman artifact ls
run_podman artifact ls
is "$output" ".*artifact-test.*latest" "artifact appears in podman artifact list"
run_ramalama rm ${artifact} file://${testmodel}
run_ramalama ls
assert "$output" !~ ".*artifact-test" "artifact was removed"
}
@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
run_ramalama pull tiny
run_ramalama convert --type artifact tiny artifact-tiny:latest
run_ramalama list
is "$output" ".*artifact-tiny.*latest" "artifact was created from ollama model"
# Verify it's an artifact
run_podman artifact ls
is "$output" ".*artifact-tiny.*latest" "artifact appears in podman artifact list"
run_ramalama rm artifact-tiny:latest
assert "$output" !~ ".*artifact-tiny" "artifact was removed"
}
@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
local registry=localhost:${PODMAN_LOGIN_REGISTRY_PORT}
local authfile=$RAMALAMA_TMPDIR/authfile.json
start_registry
run_ramalama login --authfile=$authfile \
--tls-verify=false \
--username ${PODMAN_LOGIN_USER} \
--password ${PODMAN_LOGIN_PASS} \
oci://$registry
echo "test model" > $RAMALAMA_TMPDIR/testmodel
run_ramalama convert --type artifact file://$RAMALAMA_TMPDIR/testmodel oci://$registry/artifact-test:1.0
run_ramalama list
is "$output" ".*$registry/artifact-test.*1.0" "OCI artifact was created"
# Verify it's an artifact
run_podman artifact ls
is "$output" ".*$registry/artifact-test.*1.0" "OCI artifact appears in podman artifact list"
run_ramalama rm file://$RAMALAMA_TMPDIR/testmodel
run_ramalama rm oci://$registry/artifact-test:1.0
run_podman artifact ls
assert "$output" !~ ".*$registry/artifact-test" "OCI artifact was removed"
stop_registry
}
@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
is "$output" ".*error: argument --type: invalid choice: 'invalid'" "invalid type is rejected"
# Test missing arguments
run_ramalama 2 convert --type artifact
is "$output" ".*ramalama convert: error: the following arguments are required: SOURCE, TARGET" "missing arguments are rejected"
# Test with nocontainer
run_ramalama 22 --nocontainer convert --type artifact file://$RAMALAMA_TMPDIR/test oci://test
is "$output" "Error: convert command cannot be run with the --nocontainer option." "nocontainer is rejected for convert"
}
@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}
local authfile=$RAMALAMA_TMPDIR/authfile.json
start_registry
run_ramalama login --authfile=$authfile \
--tls-verify=false \
--username ${PODMAN_LOGIN_USER} \
--password ${PODMAN_LOGIN_PASS} \
oci://$registry
run_ramalama ? rm 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 --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://${testmodel}
run_ramalama ls
assert "$output" !~ ".*$registry/artifact-test-push" "pushed artifact was removed"
echo "test model" > $RAMALAMA_TMPDIR/testmodel
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
assert "$output" !~ ".*test-image" "local image was removed"
stop_registry
}
@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}
# Create a regular image
echo "test model" > $RAMALAMA_TMPDIR/testmodel
run_ramalama convert --type raw file://$RAMALAMA_TMPDIR/testmodel image-test
# Create an artifact
run_ramalama convert --type artifact file://$RAMALAMA_TMPDIR/testmodel ${artifact}
run_ramalama list --json
# Check that the artifact appears in JSON output
name=$(echo "$output" | jq -r '.[].name')
is "$name" ".*artifact-test.*latest" "artifact name in JSON output"
# Check that it has required fields
modified=$(echo "$output" | jq -r '.[0].modified')
size=$(echo "$output" | jq -r '.[0].size')
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_podman artifact ls
assert "$output" !~ ".*artifact-test" "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
run_ramalama rm --ignore test-config-artifact:latest
# Create a temporary config with artifact as default
local config_file=$RAMALAMA_TMPDIR/ramalama.conf
cat > $config_file << EOF
[ramalama]
# Convert the MODEL to the specified OCI Object
convert_type = "artifact"
EOF
echo "test model" > $RAMALAMA_TMPDIR/testmodel
# Test with config file
RAMALAMA_CONFIG=$config_file run_ramalama convert file://$RAMALAMA_TMPDIR/testmodel test-config-artifact:latest
run_ramalama list
is "$output" ".*test-config-artifact.*latest" "artifact was created with config default type"
# Verify it's an artifact
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
}
@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
# Create a temporary config with artifact as default
local config_file=$RAMALAMA_TMPDIR/ramalama.conf
cat > $config_file << EOF
[ramalama]
# Convert the MODEL to the specified OCI Object
convert_type = "artifact"
EOF
echo "test model" > $RAMALAMA_TMPDIR/testmodel
# Test with CLI override
RAMALAMA_CONFIG=$config_file run_ramalama convert --type raw file://$RAMALAMA_TMPDIR/testmodel test-cli-override
run_ramalama list
is "$output" ".*test-cli-override.*latest" "raw image was created despite config default"
# Verify it's NOT an artifact (should be a regular image)
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
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
echo "test model" > $RAMALAMA_TMPDIR/testmodel
# Test car type
run_ramalama convert --type car file://$RAMALAMA_TMPDIR/testmodel test-car:latest
run_ramalama list
is "$output" ".*test-car.*latest" "car type works"
# Test raw type
run_ramalama convert --type raw file://$RAMALAMA_TMPDIR/testmodel test-raw:latest
run_ramalama list
is "$output" ".*test-raw.*latest" "raw type works"
# Verify artifacts vs images
run_podman artifact ls
assert "$output" !~ ".*test-car" "car does not appear in artifact list"
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
assert "$output" !~ ".*test-car" "car was removed"
assert "$output" !~ ".*test-raw" "raw was removed"
}
@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}
local authfile=$RAMALAMA_TMPDIR/authfile.json
start_registry
run_ramalama login --authfile=$authfile \
--tls-verify=false \
--username ${PODMAN_LOGIN_USER} \
--password ${PODMAN_LOGIN_PASS} \
oci://$registry
echo "test model" > $RAMALAMA_TMPDIR/testmodel
run_ramalama ? rm artifact-test:latest
# Test artifact push
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" "convert artifact works"
run_ramalama push --authfile=$authfile --tls-verify=false oci://$registry/artifact-test-push:latest
# Test car push
run_ramalama convert --type car file://$RAMALAMA_TMPDIR/testmodel oci://$registry/test-car-push:1.0
run_ramalama list
is "$output" ".*$registry/test-car-push.*1.0" "convert works"
run_ramalama push --authfile=$authfile --tls-verify=false oci://$registry/test-car-push:1.0
run_ramalama list
is "$output" ".*$registry/test-car-push.*1.0" "car push works"
# Test raw push
run_ramalama convert --type raw file://$RAMALAMA_TMPDIR/testmodel oci://$registry/test-raw-push:1.1
run_ramalama push --authfile=$authfile --tls-verify=false oci://$registry/test-raw-push:1.1
run_ramalama list
is "$output" ".*$registry/test-raw-push.*1.1" "raw push works"
# Clean up
run_ramalama rm file://$RAMALAMA_TMPDIR/testmodel oci://$registry/artifact-test-push:latest oci://$registry/test-car-push:1.0 oci://$registry/test-raw-push:1.1
run_ramalama list
assert "$output" !~ ".*$registry/artifact-test-push" "pushed artifact was removed"
assert "$output" !~ ".*$registry/test-car-push" "pushed car was removed"
assert "$output" !~ ".*$registry/test-raw-push" "pushed raw was removed"
stop_registry
}
@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
# Create multiple test files
echo "model data 1" > $RAMALAMA_TMPDIR/model1.gguf
echo "model data 2" > $RAMALAMA_TMPDIR/model2.gguf
echo "config data" > $RAMALAMA_TMPDIR/config.json
# Create a tar archive to simulate a multi-file model
tar -czf $RAMALAMA_TMPDIR/multi_model.tar.gz -C $RAMALAMA_TMPDIR model1.gguf model2.gguf config.json
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"
# Verify it's an artifact
run_podman artifact ls
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"
}
@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
# Create two artifacts concurrently
run_ramalama convert --type artifact file://$RAMALAMA_TMPDIR/testmodel1 concurrent-artifact1 &
pid1=$!
run_ramalama convert --type artifact file://$RAMALAMA_TMPDIR/testmodel2 concurrent-artifact2 &
pid2=$!
# Wait for both to complete
wait $pid1
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"
run_ramalama rm concurrent-artifact1 concurrent-artifact2 file://$RAMALAMA_TMPDIR/testmodel1 file://$RAMALAMA_TMPDIR/testmodel2
assert "$output" !~ ".*concurrent-artifact1" "first concurrent artifact was removed"
assert "$output" !~ ".*concurrent-artifact2" "second concurrent artifact was removed"
}
@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"
# Test with directory instead of file
mkdir -p $RAMALAMA_TMPDIR/testdir
run_ramalama 22 convert --type artifact file://$RAMALAMA_TMPDIR/testdir test-artifact
is "$output" ".*Error.*" "directory as source is handled gracefully"
}
# 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
# Test default configuration
local config_file=$RAMALAMA_TMPDIR/ramalama.conf
cat > $config_file << EOF
[ramalama]
# Test configuration file
convert_type = "artifact"
EOF
echo "test model" > $RAMALAMA_TMPDIR/testmodel
artifact=config-test-artifact:latest
# Test with config file
RAMALAMA_CONFIG=$config_file run_ramalama convert file://$RAMALAMA_TMPDIR/testmodel ${artifact}
run_ramalama list
is "$output" ".*config-test-artifact.*latest" "artifact was created with config default"
# Verify it's an artifact
run_podman artifact ls
is "$output" ".*config-test-artifact.*latest" "artifact appears in podman artifact list"
run_ramalama rm ${artifact} file://$RAMALAMA_TMPDIR/testmodel
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
cat > $config_file << EOF
[ramalama]
convert_type = "invalid_type"
EOF
echo "test model" > $RAMALAMA_TMPDIR/testmodel
# This should fail with invalid config
RAMALAMA_CONFIG=${config_file} run_ramalama 22 convert file://$RAMALAMA_TMPDIR/testmodel test-invalid
is "$output" ".*Error.*" "invalid convert_type in config is rejected"
}
@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
# Create config with artifact as default
local config_file=$RAMALAMA_TMPDIR/ramalama.conf
cat > $config_file << EOF
[ramalama]
convert_type = "artifact"
EOF
echo "test model" > $RAMALAMA_TMPDIR/testmodel
# 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"
# 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
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
# Create config with artifact as default
local config_file=$RAMALAMA_TMPDIR/ramalama.conf
cat > $config_file << EOF
[ramalama]
convert_type = "artifact"
EOF
echo "test model" > $RAMALAMA_TMPDIR/testmodel
# 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"
# 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
assert "$output" !~ ".*env-override-test" "image was removed"
}
# vim: filetype=sh

View File

@@ -1,6 +1,6 @@
# -*- bash -*-
# RamaLama command to run;
# RamaLama command to run;
RAMALAMA=${RAMALAMA:-ramalama}
export RAMALAMA_CONFIG=${RAMALAMA_CONFIG:-./test/system/ramalama.conf}
@@ -65,7 +65,7 @@ function ramalama_basic_setup() {
# runtime is not likely to change
if [[ -z "$RAMALAMA_RUNTIME" ]]; then
RAMALAMA_RUNTIME=$(ramalama_runtime)
RAMALAMA_RUNTIME=$(ramalama_runtime)
fi
# In the unlikely event that a test runs is() before a run_ramalama()
@@ -120,11 +120,11 @@ function run_ramalama() {
local expected_rc=0
local allowed_levels="dit"
case "$1" in
0\+[we]*) allowed_levels+=$(expr "$1" : "^0+\([we]\+\)"); shift;;
[0-9]) expected_rc=$1; shift;;
[1-9][0-9]) expected_rc=$1; shift;;
[12][0-9][0-9]) expected_rc=$1; shift;;
'?') expected_rc= ; shift;; # ignore exit code
0\+[we]*) allowed_levels+=$(expr "$1" : "^0+\([we]\+\)"); shift;;
[0-9]) expected_rc=$1; shift;;
[1-9][0-9]) expected_rc=$1; shift;;
[12][0-9][0-9]) expected_rc=$1; shift;;
'?') expected_rc= ; shift;; # ignore exit code
esac
# Remember command args, for possible use in later diagnostic messages
@@ -135,10 +135,10 @@ function run_ramalama() {
# https://bats-core.readthedocs.io/en/stable/warnings/BW01.html
local silence127=
if [[ "$expected_rc" = "127" ]]; then
# We could use "-127", but that would cause BATS to fail if the
# command exits any other status -- and default BATS failure messages
# are much less helpful than the run_ramalama ones. "!" is more flexible.
silence127="!"
# We could use "-127", but that would cause BATS to fail if the
# command exits any other status -- and default BATS failure messages
# are much less helpful than the run_ramalama ones. "!" is more flexible.
silence127="!"
fi
# stdout is only emitted upon error; this printf is to help in debugging
@@ -148,43 +148,43 @@ function run_ramalama() {
run $silence127 timeout --foreground -v --kill=10 $RAMALAMA_TIMEOUT $RAMALAMA $_RAMALAMA_TEST_OPTS "$@" 3>/dev/null
# without "quotes", multiple lines are glommed together into one
if [ -n "$output" ]; then
echo "$(timestamp) $output"
echo "$(timestamp) $output"
# FIXME FIXME FIXME: instrumenting to track down #15488. Please
# remove once that's fixed. We include the args because, remember,
# bats only shows output on error; it's possible that the first
# instance of the metacopy warning happens in a test that doesn't
# check output, hence doesn't fail.
if [[ "$output" =~ Ignoring.global.metacopy.option ]]; then
echo "# YO! metacopy warning triggered by: ramalama $*" >&3
fi
# FIXME FIXME FIXME: instrumenting to track down #15488. Please
# remove once that's fixed. We include the args because, remember,
# bats only shows output on error; it's possible that the first
# instance of the metacopy warning happens in a test that doesn't
# check output, hence doesn't fail.
if [[ "$output" =~ Ignoring.global.metacopy.option ]]; then
echo "# YO! metacopy warning triggered by: ramalama $*" >&3
fi
fi
if [ "$status" -ne 0 ]; then
echo -n "$(timestamp) [ rc=$status ";
if [ -n "$expected_rc" ]; then
if [ "$status" -eq "$expected_rc" ]; then
echo -n "(expected) ";
else
echo -n "(** EXPECTED $expected_rc **) ";
fi
fi
echo "]"
echo -n "$(timestamp) [ rc=$status ";
if [ -n "$expected_rc" ]; then
if [ "$status" -eq "$expected_rc" ]; then
echo -n "(expected) ";
else
echo -n "(** EXPECTED $expected_rc **) ";
fi
fi
echo "]"
fi
if [ "$status" -eq 124 ]; then
if expr "$output" : ".*timeout: sending" >/dev/null; then
# It's possible for a subtest to _want_ a timeout
if [[ "$expected_rc" != "124" ]]; then
echo "*** TIMED OUT ***"
false
fi
fi
if expr "$output" : ".*timeout: sending" >/dev/null; then
# It's possible for a subtest to _want_ a timeout
if [[ "$expected_rc" != "124" ]]; then
echo "*** TIMED OUT ***"
false
fi
fi
fi
if [ -n "$expected_rc" ]; then
if [ "$status" -ne "$expected_rc" ]; then
die "exit code is $status; expected $expected_rc"
fi
if [ "$status" -ne "$expected_rc" ]; then
die "exit code is $status; expected $expected_rc"
fi
fi
}
@@ -192,8 +192,8 @@ function run_ramalama_testing() {
printf "\n%s %s %s %s\n" "$(timestamp)" "$_LOG_PROMPT" "$RAMALAMA_TESTING" "$*"
run $RAMALAMA_TESTING "$@"
if [[ $status -ne 0 ]]; then
echo "$output"
die "Unexpected error from testing helper, which should always always succeed"
echo "$output"
die "Unexpected error from testing helper, which should always always succeed"
fi
}
@@ -228,19 +228,19 @@ function not_docker() {
function skip_if_nocontainer() {
if [[ "${_RAMALAMA_TEST_OPTS}" == "--nocontainer" ]]; then
skip "Not supported with --nocontainer"
skip "Not supported with --nocontainer"
fi
}
function skip_if_notlocal() {
if [[ "${_RAMALAMA_TEST}" != "local" ]]; then
skip "Not supported unless --local"
skip "Not supported unless --local"
fi
}
function skip_if_docker() {
if [[ "${_RAMALAMA_TEST_OPTS}" == "--engine=docker" ]]; then
skip "Not supported with --engine=docker"
skip "Not supported with --engine=docker"
fi
}
@@ -254,38 +254,38 @@ function is_tty() {
function skip_if_darwin() {
if [[ "$(uname)" == "Darwin" ]]; then
skip "Not supported on darwin"
skip "Not supported on darwin"
fi
}
function is_apple_silicon() {
# Check if we're on macOS and have Apple Silicon (arm64)
if is_darwin; then
arch=$(uname -m)
[[ "$arch" == "arm64" ]]
arch=$(uname -m)
[[ "$arch" == "arm64" ]]
else
return 1
return 1
fi
}
function skip_if_no_hf_cli(){
if ! command -v hf 2>&1 >/dev/null
then
skip "Not supported without hf client"
skip "Not supported without hf client"
fi
}
function skip_if_no_ollama() {
if ! command -v ollama 2>&1 >/dev/null
then
skip "Not supported without ollama"
skip "Not supported without ollama"
fi
}
function skip_if_no_llama_bench() {
if ! command -v llama-bench 2>&1 >/dev/null
then
skip "Not supported without llama-bench"
skip "Not supported without llama-bench"
fi
}
@@ -295,7 +295,7 @@ function is_ppc64le() {
function skip_if_ppc64le() {
if is_ppc64le; then
skip "Not yet supported on ppc64le"
skip "Not yet supported on ppc64le"
fi
}
@@ -305,7 +305,35 @@ function is_s390x() {
function skip_if_s390x() {
if is_s390x; then
skip "Not yet supported on s390x"
skip "Not yet supported on s390x"
fi
}
function get_podman_version() {
# Extract podman version as a comparable number (e.g., "5.7.0" -> 50700)
local version_output
version_output=$(podman version --format '{{.Client.Version}}' 2>/dev/null || echo "0.0.0")
local version_major version_minor version_patch
version_major=$(echo "$version_output" | cut -d. -f1)
version_minor=$(echo "$version_output" | cut -d. -f2)
version_patch=$(echo "$version_output" | cut -d. -f3 | cut -d- -f1) # Handle versions like "5.7.0-dev"
# Convert to integer for comparison (e.g., 5.7.0 -> 50700)
echo $((version_major * 10000 + version_minor * 100 + version_patch))
}
function skip_if_podman_too_old() {
local required_version="$1" # e.g., "5.7.0"
local required_major required_minor required_patch
required_major=$(echo "$required_version" | cut -d. -f1)
required_minor=$(echo "$required_version" | cut -d. -f2)
required_patch=$(echo "$required_version" | cut -d. -f3)
local required_num=$((required_major * 10000 + required_minor * 100 + required_patch))
local current_num=$(get_podman_version)
if [ "$current_num" -lt "$required_num" ]; then
skip "Requires podman >= $required_version (found $(podman version --format '{{.Client.Version}}' 2>/dev/null || echo 'unknown'))"
fi
}
@@ -315,9 +343,9 @@ function is_bigendian() {
function test_model() {
if is_bigendian; then
echo ${2:-stories-be:260k}
echo ${2:-stories-be:260k}
else
echo ${1:-smollm:135m}
echo ${1:-smollm:135m}
fi
}

View File

@@ -2,30 +2,30 @@
# it into Kubernetes.
#
# Created with ramalama-test-version
apiVersion: v1
apiVersion: apps/v1
kind: Deployment
metadata:
name: generated-name
name: ramalama
labels:
app: generated-name
app: ramalama
spec:
replicas: 1
selector:
matchLabels:
app: generated-name
app: ramalama
template:
metadata:
labels:
app: generated-name
app: ramalama
spec:
containers:
- name: generated-name
- name: ramalama
image: testimage
command: ["llama-server"]
args: ['--model', '/mnt/models/model.file']
env:
- name: TEST_ENV
value: test_value
value: "test_value"
volumeMounts:
- mountPath: /mnt/models/model.file

View File

@@ -2,30 +2,30 @@
# it into Kubernetes.
#
# Created with ramalama-test-version
apiVersion: v1
apiVersion: apps/v1
kind: Deployment
metadata:
name: generated-name
name: ramalama
labels:
app: generated-name
app: ramalama
spec:
replicas: 1
selector:
matchLabels:
app: generated-name
app: ramalama
template:
metadata:
labels:
app: generated-name
app: ramalama
spec:
containers:
- name: generated-name
- name: ramalama
image: testimage
command: ["llama-server"]
args: ['--model', '/mnt/models/model.file']
env:
- name: TEST_ENV
value: test_value
value: "test_value"
volumeMounts:
- mountPath: /mnt/models/model.file

View File

@@ -2,7 +2,7 @@
# it into Kubernetes.
#
# Created with ramalama-test-version
apiVersion: v1
apiVersion: apps/v1
kind: Deployment
metadata:
name: custom-name
@@ -25,7 +25,7 @@ spec:
args: ['--model', '/mnt/models/model.file']
env:
- name: TEST_ENV
value: test_value
value: "test_value"
volumeMounts:
- mountPath: /mnt/models/model.file

View File

@@ -2,30 +2,30 @@
# it into Kubernetes.
#
# Created with ramalama-test-version
apiVersion: v1
apiVersion: apps/v1
kind: Deployment
metadata:
name: generated-name
name: ramalama
labels:
app: generated-name
app: ramalama
spec:
replicas: 1
selector:
matchLabels:
app: generated-name
app: ramalama
template:
metadata:
labels:
app: generated-name
app: ramalama
spec:
containers:
- name: generated-name
- name: ramalama
image: testimage
command: ["llama-server"]
args: ['--model', '/mnt/models/model.file']
env:
- name: TEST_ENV
value: test_value
value: "test_value"
volumeMounts:
- mountPath: /mnt/models/model.file

View File

@@ -2,30 +2,30 @@
# it into Kubernetes.
#
# Created with ramalama-test-version
apiVersion: v1
apiVersion: apps/v1
kind: Deployment
metadata:
name: generated-name
name: ramalama
labels:
app: generated-name
app: ramalama
spec:
replicas: 1
selector:
matchLabels:
app: generated-name
app: ramalama
template:
metadata:
labels:
app: generated-name
app: ramalama
spec:
containers:
- name: generated-name
- name: ramalama
image: testimage
command: ["llama-server"]
args: ['--model', '/mnt/models/model.file']
env:
- name: TEST_ENV
value: test_value
value: "test_value"
ports:
- containerPort: 8080
volumeMounts:

View File

@@ -2,30 +2,30 @@
# it into Kubernetes.
#
# Created with ramalama-test-version
apiVersion: v1
apiVersion: apps/v1
kind: Deployment
metadata:
name: generated-name
name: ramalama
labels:
app: generated-name
app: ramalama
spec:
replicas: 1
selector:
matchLabels:
app: generated-name
app: ramalama
template:
metadata:
labels:
app: generated-name
app: ramalama
spec:
containers:
- name: generated-name
- name: ramalama
image: testimage
command: ["llama-server"]
args: ['--model', '/mnt/models/model.file']
env:
- name: TEST_ENV
value: test_value
value: "test_value"
ports:
- containerPort: 8080
hostPort: 3000

View File

@@ -2,30 +2,30 @@
# it into Kubernetes.
#
# Created with ramalama-test-version
apiVersion: v1
apiVersion: apps/v1
kind: Deployment
metadata:
name: generated-name
name: ramalama
labels:
app: generated-name
app: ramalama
spec:
replicas: 1
selector:
matchLabels:
app: generated-name
app: ramalama
template:
metadata:
labels:
app: generated-name
app: ramalama
spec:
containers:
- name: generated-name
- name: ramalama
image: testimage
command: ["llama-server"]
args: ['--model', '/mnt/models/model.file']
env:
- name: TEST_ENV
value: test_value
value: "test_value"
volumeMounts:
- mountPath: /mnt/models/model.file

View File

@@ -33,6 +33,7 @@ class Input:
mmproj_file_exists: bool = False,
args: Args = Args(),
exec_args: list = None,
artifact: bool = False,
):
self.model_name = model_name
self.model_src_path = model_src_path
@@ -46,6 +47,7 @@ class Input:
self.mmproj_file_exists = mmproj_file_exists
self.args = args
self.exec_args = exec_args if exec_args is not None else []
self.artifact = artifact
DATA_PATH = Path(__file__).parent / "data" / "test_kube"
@@ -61,6 +63,7 @@ DATA_PATH = Path(__file__).parent / "data" / "test_kube"
model_dest_path="/mnt/models/model.file",
model_file_exists=True,
exec_args=["llama-server", "--model", "/mnt/models/model.file"],
artifact=False,
),
"basic_hostpath.yaml",
),
@@ -72,6 +75,7 @@ DATA_PATH = Path(__file__).parent / "data" / "test_kube"
model_file_exists=True,
args=Args(port="8080"),
exec_args=["llama-server", "--model", "/mnt/models/model.file"],
artifact=False,
),
"with_port.yaml",
),
@@ -83,6 +87,7 @@ DATA_PATH = Path(__file__).parent / "data" / "test_kube"
model_file_exists=True,
args=Args(port="8080:3000"),
exec_args=["llama-server", "--model", "/mnt/models/model.file"],
artifact=False,
),
"with_port_mapping.yaml",
),
@@ -94,6 +99,7 @@ DATA_PATH = Path(__file__).parent / "data" / "test_kube"
model_file_exists=True,
args=Args(rag="registry.redhat.io/ubi9/ubi:latest"),
exec_args=["llama-server", "--model", "/mnt/models/model.file"],
artifact=False,
),
"with_rag.yaml",
),
@@ -105,6 +111,7 @@ DATA_PATH = Path(__file__).parent / "data" / "test_kube"
model_file_exists=True,
args=Args(name="custom-name"),
exec_args=["llama-server", "--model", "/mnt/models/model.file"],
artifact=False,
),
"with_custom_name.yaml",
),
@@ -118,6 +125,7 @@ DATA_PATH = Path(__file__).parent / "data" / "test_kube"
chat_template_dest_path="/mnt/models/chat_template",
chat_template_file_exists=True,
exec_args=["llama-server", "--model", "/mnt/models/model.file"],
artifact=False,
),
"with_chat_template.yaml",
),
@@ -131,6 +139,7 @@ DATA_PATH = Path(__file__).parent / "data" / "test_kube"
mmproj_dest_path="/mnt/models/mmproj",
mmproj_file_exists=True,
exec_args=["llama-server", "--model", "/mnt/models/model.file"],
artifact=False,
),
"with_mmproj.yaml",
),
@@ -159,10 +168,6 @@ def test_kube_generate(input: Input, expected_file_name: str, monkeypatch):
# Mock version
monkeypatch.setattr("ramalama.kube.version", lambda: "test-version")
# Mock genname to return predictable name
if not input.args.name:
monkeypatch.setattr("ramalama.kube.genname", lambda: "generated-name")
# Create Kube instance and generate
chat_template_paths = None
if input.chat_template_src_path:
@@ -179,6 +184,7 @@ def test_kube_generate(input: Input, expected_file_name: str, monkeypatch):
mmproj_paths,
input.args,
input.exec_args,
input.artifact,
)
generated_file = kube.generate()
@@ -219,7 +225,6 @@ def test_kube_no_port(monkeypatch):
monkeypatch.setattr("os.path.exists", lambda path: False)
monkeypatch.setattr("ramalama.kube.get_accel_env_vars", lambda: {})
monkeypatch.setattr("ramalama.kube.version", lambda: "test-version")
monkeypatch.setattr("ramalama.kube.genname", lambda: "test-name")
kube = Kube(
"test-model",
@@ -228,6 +233,7 @@ def test_kube_no_port(monkeypatch):
None,
args,
["llama-server"],
False,
)
result = kube.generate()
@@ -244,7 +250,6 @@ def test_kube_no_env_vars(monkeypatch):
monkeypatch.setattr("os.path.exists", lambda path: False)
monkeypatch.setattr("ramalama.kube.get_accel_env_vars", lambda: {})
monkeypatch.setattr("ramalama.kube.version", lambda: "test-version")
monkeypatch.setattr("ramalama.kube.genname", lambda: "test-name")
args = Args()
@@ -255,6 +260,7 @@ def test_kube_no_env_vars(monkeypatch):
None,
args,
["llama-server"],
False,
)
result = kube.generate()
@@ -271,7 +277,6 @@ def test_kube_no_devices(monkeypatch):
monkeypatch.setattr("os.path.exists", lambda path: path not in ["/dev/dri", "/dev/kfd"])
monkeypatch.setattr("ramalama.kube.get_accel_env_vars", lambda: {})
monkeypatch.setattr("ramalama.kube.version", lambda: "test-version")
monkeypatch.setattr("ramalama.kube.genname", lambda: "test-name")
args = Args()
@@ -282,6 +287,7 @@ def test_kube_no_devices(monkeypatch):
None,
args,
["llama-server"],
False,
)
result = kube.generate()

View File

@@ -47,6 +47,7 @@ class Input:
args: Args = Args(),
exec_args: list = [],
accel_type: str = "cuda",
artifact: str = "",
):
self.model_name = model_name
self.model_src_blob = model_src_blob
@@ -62,6 +63,7 @@ class Input:
self.args = args
self.exec_args = exec_args
self.accel_type = accel_type
self.artifact = artifact
DATA_PATH = Path(__file__).parent / "data" / "test_quadlet"
@@ -226,6 +228,7 @@ def test_quadlet_generate(input: Input, expected_files_path: Path, monkeypatch):
(input.mmproj_src_blob, input.mmproj_dest_name),
input.args,
input.exec_args,
input.artifact,
).generate():
assert file.filename in expected_files

View File

@@ -46,7 +46,7 @@ class TestRLCRInitialization:
def test_rlcr_model_initialization(self, rlcr_model):
"""Test that RLCR model initializes with correct rlcr.io prefix"""
assert rlcr_model.model == "rlcr.io/ramalama/gemma3-270m"
assert rlcr_model.model == "rlcr.io/ramalama/gemma3-270m:latest"
assert rlcr_model._model_type == 'oci'
assert rlcr_model.conman == "podman"
@@ -110,16 +110,16 @@ class TestRLCRIntegration:
def test_complete_initialization_flow(self, rlcr_model):
"""Test complete RLCR initialization and model path construction"""
# Test the complete flow
assert rlcr_model.model == "rlcr.io/ramalama/gemma3-270m"
assert rlcr_model.model == "rlcr.io/ramalama/gemma3-270m:latest"
assert rlcr_model._model_type == 'oci'
assert rlcr_model.conman == "podman"
@pytest.mark.parametrize(
"input_model,expected_path",
[
("simple-model", "rlcr.io/ramalama/simple-model"),
("simple-model", "rlcr.io/ramalama/simple-model:latest"),
("model-with-tag:v1.0", "rlcr.io/ramalama/model-with-tag:v1.0"),
("namespace/model", "rlcr.io/ramalama/namespace/model"),
("namespace/model", "rlcr.io/ramalama/namespace/model:latest"),
("complex-name_123:latest", "rlcr.io/ramalama/complex-name_123:latest"),
],
)