Skip to content

Commit f986be3

Browse files
authored
feat: New tests for model signing (#1214)
* feat: New tests for model signing * fix: address coderabbit comments
1 parent 875febd commit f986be3

5 files changed

Lines changed: 1455 additions & 987 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ classifiers = [
4646

4747
dependencies = [
4848
"ipython>=8.18.1",
49-
"model-registry>=0.2.13",
49+
"model-registry[signing]>=0.2.13",
5050
"openshift-python-utilities>=5.0.71",
5151
"pytest-dependency>=0.6.0",
5252
"pytest-progress",

tests/model_registry/model_registry/python_client/signing/conftest.py

Lines changed: 141 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
"""Fixtures for Model Registry Python Client Signing Tests."""
22

33
import json
4+
import logging
5+
import os
46
from collections.abc import Generator
7+
from pathlib import Path
58
from typing import Any
69

710
import pytest
811
import requests
12+
from huggingface_hub import snapshot_download
913
from kubernetes.dynamic import DynamicClient
14+
from model_registry.signing import Signer
1015
from ocp_resources.config_map import ConfigMap
1116
from ocp_resources.deployment import Deployment
1217
from ocp_resources.namespace import Namespace
@@ -23,17 +28,30 @@
2328
)
2429
from tests.model_registry.model_registry.python_client.signing.utils import (
2530
create_connection_type_field,
31+
generate_token,
2632
get_organization_config,
33+
get_root_checksum,
2734
get_tas_service_urls,
2835
)
2936
from utilities.constants import OPENSHIFT_OPERATORS, Timeout
30-
from utilities.infra import get_openshift_token
37+
from utilities.infra import get_openshift_token, is_managed_cluster
3138
from utilities.resources.securesign import Securesign
3239

3340
LOGGER = get_logger(name=__name__)
3441

3542

36-
@pytest.fixture(scope="class")
43+
@pytest.fixture(scope="package")
44+
def skip_if_not_managed_cluster(admin_client: DynamicClient) -> None:
45+
"""
46+
Skip tests if the cluster is not managed.
47+
"""
48+
if not is_managed_cluster(admin_client):
49+
pytest.skip("Skipping tests - cluster is not managed")
50+
51+
LOGGER.info("Cluster is managed - proceeding with tests")
52+
53+
54+
@pytest.fixture(scope="package")
3755
def oidc_issuer_url(admin_client: DynamicClient, api_server_url: str) -> str:
3856
"""Get the OIDC issuer URL from cluster's .well-known/openid-configuration endpoint.
3957
@@ -61,7 +79,7 @@ def oidc_issuer_url(admin_client: DynamicClient, api_server_url: str) -> str:
6179
return issuer
6280

6381

64-
@pytest.fixture(scope="class")
82+
@pytest.fixture(scope="package")
6583
def installed_tas_operator(admin_client: DynamicClient) -> Generator[None, Any]:
6684
"""Install Red Hat Trusted Artifact Signer (RHTAS/TAS) operator if not already installed.
6785
@@ -126,7 +144,7 @@ def installed_tas_operator(admin_client: DynamicClient) -> Generator[None, Any]:
126144
yield
127145

128146

129-
@pytest.fixture(scope="class")
147+
@pytest.fixture(scope="package")
130148
def securesign_instance(
131149
admin_client: DynamicClient, installed_tas_operator: None, oidc_issuer_url: str
132150
) -> Generator[Securesign, Any]:
@@ -165,6 +183,7 @@ def securesign_instance(
165183
},
166184
"spec": {
167185
"fulcio": {
186+
"enabled": True,
168187
"externalAccess": {"enabled": True},
169188
"certificate": org_config,
170189
"config": {
@@ -178,13 +197,18 @@ def securesign_instance(
178197
},
179198
},
180199
"rekor": {
200+
"enabled": True,
181201
"externalAccess": {"enabled": True},
182202
},
183-
"ctlog": {},
203+
"ctlog": {
204+
"enabled": True,
205+
},
184206
"tuf": {
207+
"enabled": True,
185208
"externalAccess": {"enabled": True},
186209
},
187210
"tsa": {
211+
"enabled": True,
188212
"externalAccess": {"enabled": True},
189213
"signer": {
190214
"certificateChain": {
@@ -207,7 +231,7 @@ def securesign_instance(
207231
LOGGER.info(f"Securesign instance '{SECURESIGN_NAME}' cleanup completed")
208232

209233

210-
@pytest.fixture(scope="class")
234+
@pytest.fixture(scope="package")
211235
def tas_connection_type(admin_client: DynamicClient, securesign_instance: Securesign) -> Generator[ConfigMap, Any]:
212236
"""Create ODH Connection Type ConfigMap for TAS (Trusted Artifact Signer).
213237
@@ -288,3 +312,114 @@ def tas_connection_type(admin_client: DynamicClient, securesign_instance: Secure
288312
yield connection_type
289313

290314
LOGGER.info(f"TAS Connection Type '{TAS_CONNECTION_TYPE_NAME}' deleted from namespace '{app_namespace}'")
315+
316+
317+
@pytest.fixture(scope="package")
318+
def downloaded_model_dir() -> Path:
319+
"""Download a test model from Hugging Face to a temporary directory.
320+
321+
Downloads the jonburdo/public-test-model-1 model to a temporary directory
322+
and yields the path to the downloaded model directory.
323+
324+
Yields:
325+
Path: Path to the temporary directory containing the downloaded model
326+
"""
327+
model_dir = Path(py_config["tmp_base_dir"]) / "model"
328+
model_dir.mkdir(exist_ok=True)
329+
330+
LOGGER.info(f"Downloading model to temporary directory: {model_dir}")
331+
snapshot_download(repo_id="jonburdo/public-test-model-1", local_dir=str(model_dir))
332+
LOGGER.info(f"Model downloaded successfully to: {model_dir}")
333+
334+
return model_dir
335+
336+
337+
@pytest.fixture(scope="package")
338+
def set_environment_variables(securesign_instance: Securesign) -> Generator[None, Any]:
339+
"""
340+
Create a service account token and save it to a temporary directory.
341+
Automatically cleans up environment variables when fixture scope ends.
342+
"""
343+
# Set up environment variables
344+
securesign_data = securesign_instance.instance.to_dict()
345+
service_urls = get_tas_service_urls(securesign_instance=securesign_data)
346+
os.environ["IDENTITY_TOKEN_PATH"] = generate_token(temp_base_folder=py_config["tmp_base_dir"])
347+
os.environ["SIGSTORE_TUF_URL"] = service_urls["tuf"]
348+
os.environ["SIGSTORE_FULCIO_URL"] = service_urls["fulcio"]
349+
os.environ["SIGSTORE_REKOR_URL"] = service_urls["rekor"]
350+
os.environ["SIGSTORE_TSA_URL"] = service_urls["tsa"]
351+
os.environ["ROOT_CHECKSUM"] = get_root_checksum(sigstore_tuf_url=service_urls["tuf"])
352+
os.environ["ROOT_URL"] = os.environ["SIGSTORE_TUF_URL"] + "/root.json"
353+
354+
LOGGER.info("Environment variables set for signing tests")
355+
yield
356+
357+
# Clean up environment variables
358+
for var_name in [
359+
"IDENTITY_TOKEN_PATH",
360+
"SIGSTORE_TUF_URL",
361+
"SIGSTORE_FULCIO_URL",
362+
"SIGSTORE_REKOR_URL",
363+
"SIGSTORE_TSA_URL",
364+
"ROOT_CHECKSUM",
365+
"ROOT_URL",
366+
]:
367+
os.environ.pop(var_name, None)
368+
369+
LOGGER.info("Environment variables cleaned up")
370+
371+
372+
@pytest.fixture(scope="function")
373+
def signer(set_environment_variables) -> Signer:
374+
"""Create and initialize a Signer instance for model signing.
375+
376+
Creates a Signer with identity token, root URL, and root checksum from environment
377+
variables set by the set_environment_variables fixture. Initializes the signer
378+
with force=True and debug logging.
379+
380+
Args:
381+
set_environment_variables: Fixture that sets up required environment variables
382+
383+
Returns:
384+
Signer: Initialized signer instance ready for model signing
385+
386+
Raises:
387+
Exception: If signer initialization fails
388+
"""
389+
LOGGER.info(f"Creating Signer with token path: {os.environ['IDENTITY_TOKEN_PATH']}")
390+
LOGGER.info(f"Root URL: {os.environ['ROOT_URL']}")
391+
392+
signer = Signer(
393+
identity_token_path=os.environ["IDENTITY_TOKEN_PATH"],
394+
root_url=os.environ["ROOT_URL"],
395+
root_checksum=os.environ["ROOT_CHECKSUM"],
396+
log_level=logging.DEBUG,
397+
)
398+
399+
LOGGER.info("Initializing signer...")
400+
signer.initialize(force=True)
401+
LOGGER.info("Signer initialized successfully")
402+
403+
return signer
404+
405+
406+
@pytest.fixture(scope="function")
407+
def signed_model(signer, downloaded_model_dir) -> Path:
408+
"""
409+
Use an initialized signer to sign the downloaded model.
410+
"""
411+
LOGGER.info(f"Signing model in directory: {downloaded_model_dir}")
412+
signer.sign_model(model_path=str(downloaded_model_dir))
413+
LOGGER.info("Model signed successfully")
414+
415+
return downloaded_model_dir
416+
417+
418+
@pytest.fixture(scope="function")
419+
def verified_model(signer, downloaded_model_dir) -> None:
420+
"""
421+
Verify a signed model.
422+
"""
423+
LOGGER.info(f"Verifying signed model in directory: {downloaded_model_dir}")
424+
signer.verify_model(model_path=str(downloaded_model_dir))
425+
LOGGER.info("Model verified successfully")

tests/model_registry/model_registry/python_client/signing/test_signing_infrastructure.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
from ocp_resources.config_map import ConfigMap
88
from simple_logger.logger import get_logger
99

10+
from tests.model_registry.model_registry.python_client.signing.utils import check_model_signature_file
1011
from utilities.resources.securesign import Securesign
1112

1213
LOGGER = get_logger(name=__name__)
1314

1415

15-
@pytest.mark.usefixtures("tas_connection_type")
16-
class TestSigningInfrastructure:
16+
@pytest.mark.usefixtures("skip_if_not_managed_cluster", "tas_connection_type", "set_environment_variables")
17+
class TestSigning:
1718
"""Test suite to verify TAS signing infrastructure is ready for model signing operations."""
1819

1920
def test_signing_environment_ready(
@@ -80,3 +81,31 @@ def test_signing_environment_ready(
8081
LOGGER.info(f"✓ OIDC issuer configured: {oidc_issuer_url}")
8182

8283
LOGGER.info("Environment is ready for model signing operations!")
84+
85+
def test_model_sign(self, signed_model):
86+
"""Test model signing functionality.
87+
88+
Args:
89+
signed_model: Tuple of (signer, signed_model_directory_path)
90+
91+
Verifies:
92+
- Model has been signed successfully
93+
- Signature files are created
94+
"""
95+
96+
LOGGER.info(f"Testing model signing in directory: {signed_model}")
97+
assert signed_model
98+
has_signature = check_model_signature_file(model_dir=str(signed_model))
99+
assert has_signature, "model.sig file not found after signing"
100+
101+
def test_model_verify(self, verified_model):
102+
"""Test model verification functionality.
103+
104+
Args:
105+
verified_model: Result of model verification after signing
106+
107+
Verifies:
108+
- Signed model can be verified successfully
109+
- Verification result indicates success
110+
"""
111+
LOGGER.info("Testing model verification")

tests/model_registry/model_registry/python_client/signing/utils.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
"""Utility functions for Model Registry Python Client Signing Tests."""
22

3+
import hashlib
4+
import os
5+
6+
import requests
7+
from pyhelper_utils.shell import run_command
8+
from simple_logger.logger import get_logger
9+
310
from tests.model_registry.model_registry.python_client.signing.constants import (
411
SECURESIGN_ORGANIZATION_EMAIL,
512
SECURESIGN_ORGANIZATION_NAME,
613
)
714

15+
LOGGER = get_logger(name=__name__)
16+
817

918
def get_organization_config() -> dict[str, str]:
1019
"""Get organization configuration for certificates."""
@@ -59,3 +68,67 @@ def create_connection_type_field(
5968
"properties": {"defaultValue": default_value},
6069
"required": required,
6170
}
71+
72+
73+
def generate_token(temp_base_folder) -> str:
74+
"""
75+
Create a service account token and save it to a temporary directory.
76+
"""
77+
filepath = os.path.join(temp_base_folder, "token")
78+
79+
LOGGER.info("Creating service account token for namespace rhods-notebooks...")
80+
_, out, _ = run_command(
81+
command=["oc", "create", "token", "default", "-n", "rhods-notebooks", "--duration=1h"], check=True
82+
)
83+
84+
token = out.strip()
85+
with open(filepath, "w") as fd:
86+
fd.write(token)
87+
return filepath
88+
89+
90+
def get_root_checksum(sigstore_tuf_url: str) -> str:
91+
"""
92+
Download root.json from TUF URL and calculate SHA256 checksum.
93+
"""
94+
if not sigstore_tuf_url:
95+
raise ValueError("sigstore_tuf_url cannot be empty or None")
96+
97+
try:
98+
LOGGER.info(f"Downloading root.json from: {sigstore_tuf_url}/root.json")
99+
response = requests.get(f"{sigstore_tuf_url}/root.json", timeout=30)
100+
response.raise_for_status() # Raise exception for HTTP errors
101+
102+
# Calculate SHA256 checksum
103+
checksum = hashlib.sha256(response.content).hexdigest()
104+
LOGGER.info(f"Calculated root.json checksum: {checksum}")
105+
106+
except requests.RequestException as e:
107+
LOGGER.error(f"Failed to download root.json from {sigstore_tuf_url}: {e}")
108+
raise
109+
except (ValueError, OSError) as e:
110+
LOGGER.error(f"Failed to calculate checksum: {e}")
111+
raise RuntimeError(f"Checksum calculation failed: {e}")
112+
else:
113+
return checksum
114+
115+
116+
def check_model_signature_file(model_dir: str) -> bool:
117+
"""
118+
Check for the presence of model.sig file in the model directory.
119+
120+
Args:
121+
model_dir: Path to the model directory
122+
123+
Returns:
124+
bool: True if model.sig file exists, False otherwise
125+
"""
126+
sig_file_path = os.path.join(model_dir, "model.sig")
127+
LOGGER.info(f"Checking for signature file: {sig_file_path}")
128+
129+
if os.path.exists(sig_file_path):
130+
LOGGER.info(f"Signature file found: {sig_file_path}")
131+
return True
132+
else:
133+
LOGGER.info(f"Signature file not found: {sig_file_path}")
134+
return False

0 commit comments

Comments
 (0)