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:
2
Makefile
2
Makefile
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
586
test/e2e/test_artifact.py
Normal 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])
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
504
test/system/056-artifact.bats
Normal file
504
test/system/056-artifact.bats
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user