mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
885 lines
30 KiB
Python
885 lines
30 KiB
Python
# Copyright 2021 The KServe Authors.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import os
|
|
import json
|
|
import pytest
|
|
import botocore
|
|
import tempfile
|
|
import unittest.mock as mock
|
|
|
|
from botocore.client import Config
|
|
from botocore import UNSIGNED
|
|
from kserve_storage import Storage
|
|
from kserve_storage.storage_errors import get_storage_error_message, check_http_response
|
|
|
|
|
|
class MockPool:
|
|
"""
|
|
Mock multiprocessing.Pool that executes tasks synchronously in the same process.
|
|
This allows boto3 mocks to work properly in tests.
|
|
"""
|
|
|
|
def __init__(self, processes=None, initializer=None, initargs=(), **kwargs):
|
|
self.processes = processes
|
|
self.initializer = initializer
|
|
self.initargs = initargs
|
|
# Call initializer immediately if provided (simulating worker process initialization)
|
|
if self.initializer:
|
|
self.initializer(*self.initargs)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
pass
|
|
|
|
def imap_unordered(self, func, iterable, chunksize=1):
|
|
"""Execute tasks synchronously instead of in parallel processes"""
|
|
return [func(item) for item in iterable]
|
|
|
|
|
|
# Mock multiprocessing.Pool globally for all S3 tests
|
|
mock.patch("kserve_storage.kserve_storage.multiprocessing.Pool", MockPool).start()
|
|
|
|
|
|
class MockSession:
|
|
def resource(self, *args, **kwargs):
|
|
import boto3
|
|
|
|
return boto3.resource(*args, **kwargs)
|
|
|
|
|
|
# Mock boto3.Session globally so it returns a session that forwards resource() to the patched boto3.resource
|
|
mock.patch("boto3.Session", return_value=MockSession()).start()
|
|
|
|
|
|
def create_mock_obj(path):
|
|
mock_obj = mock.MagicMock()
|
|
mock_obj.key = path
|
|
mock_obj.size = 1024 # Non-zero size to avoid being skipped
|
|
mock_obj.is_dir = False
|
|
return mock_obj
|
|
|
|
|
|
def create_mock_boto3_bucket(mock_storage, paths):
|
|
mock_s3_resource = mock.MagicMock()
|
|
mock_s3_bucket = mock.MagicMock()
|
|
mock_s3_bucket.objects.filter.return_value = [create_mock_obj(p) for p in paths]
|
|
|
|
mock_s3_resource.Bucket.return_value = mock_s3_bucket
|
|
mock_storage.return_value = mock_s3_resource
|
|
|
|
return mock_s3_bucket
|
|
|
|
|
|
def get_call_args(call_args_list):
|
|
arg_list = []
|
|
for call in call_args_list:
|
|
args, _ = call
|
|
arg_list.append(args)
|
|
return arg_list
|
|
|
|
|
|
def expected_call_args_list_single_obj(dest, path):
|
|
return [(f"{path}".strip("/"), f"{dest}/{path.rsplit('/', 1)[-1]}".strip("/"))]
|
|
|
|
|
|
def expected_call_args_list(parent_key, dest, paths):
|
|
return [(f"{parent_key}/{p}".strip("/"), f"{dest}/{p}".strip("/")) for p in paths]
|
|
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_parent_key(mock_storage):
|
|
# given
|
|
bucket_name = "foo"
|
|
paths = ["models/weights.pt", "0002.h5", "a/very/long/path/config.json"]
|
|
object_paths = ["bar/" + p for p in paths]
|
|
|
|
# when
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_storage, object_paths)
|
|
Storage._download_s3(f"s3://{bucket_name}/bar", "dest_path")
|
|
|
|
# then
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
assert arg_list == expected_call_args_list("bar", "dest_path", paths)
|
|
|
|
mock_boto3_bucket.objects.filter.assert_called_with(Prefix="bar")
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_no_key(mock_storage):
|
|
# given
|
|
bucket_name = "foo"
|
|
object_paths = ["models/weights.pt", "0002.h5", "a/very/long/path/config.json"]
|
|
|
|
# when
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_storage, object_paths)
|
|
Storage._download_s3(f"s3://{bucket_name}/", "dest_path")
|
|
|
|
# then
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
assert arg_list == expected_call_args_list("", "dest_path", object_paths)
|
|
|
|
mock_boto3_bucket.objects.filter.assert_called_with(Prefix="")
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_storage_s3_exception(mock_resource):
|
|
path = "s3://foo/bar"
|
|
# Create mock client
|
|
mock_s3_resource = mock.MagicMock()
|
|
mock_s3_resource.Bucket.side_effect = Exception()
|
|
mock_resource.return_value = mock_s3_resource
|
|
|
|
with pytest.raises(Exception):
|
|
Storage.download(path)
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
@mock.patch("urllib3.PoolManager")
|
|
def test_no_permission_buckets(mock_connection, mock_resource):
|
|
bad_s3_path = "s3://random/path"
|
|
# Access private buckets without credentials
|
|
mock_s3_resource = mock.MagicMock()
|
|
mock_s3_bucket = mock.MagicMock()
|
|
mock_s3_bucket.objects.filter.return_value = [mock.MagicMock()]
|
|
mock_s3_resource.meta.client.head_bucket.side_effect = (
|
|
botocore.exceptions.ClientError(
|
|
{"Error": {"Code": "403", "Message": "Forbidden"}}, "HeadBucket"
|
|
)
|
|
)
|
|
mock_s3_resource.Bucket.return_value = mock_s3_bucket
|
|
mock_resource.return_value = mock_s3_resource
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
Storage.download(bad_s3_path)
|
|
assert "S3" in str(exc_info.value)
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_full_name_key(mock_storage):
|
|
# given
|
|
bucket_name = "foo"
|
|
object_key = "path/to/model/name.pt"
|
|
|
|
# when
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_storage, [object_key])
|
|
Storage._download_s3(f"s3://{bucket_name}/{object_key}", "dest_path")
|
|
|
|
# then
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
assert arg_list == expected_call_args_list_single_obj("dest_path", object_key)
|
|
|
|
mock_boto3_bucket.objects.filter.assert_called_with(Prefix=object_key)
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_full_name_key_root_bucket_dir(mock_resource):
|
|
# given
|
|
bucket_name = "foo"
|
|
object_key = "name.pt"
|
|
|
|
# when
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_resource, [object_key])
|
|
Storage._download_s3(f"s3://{bucket_name}/{object_key}", "dest_path")
|
|
|
|
# then
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
assert arg_list == expected_call_args_list_single_obj("dest_path", object_key)
|
|
|
|
mock_boto3_bucket.objects.filter.assert_called_with(Prefix=object_key)
|
|
|
|
|
|
AWS_TEST_CREDENTIALS = {
|
|
"AWS_ACCESS_KEY_ID": "testing",
|
|
"AWS_SECRET_ACCESS_KEY": "testing",
|
|
"AWS_SECURITY_TOKEN": "testing",
|
|
"AWS_SESSION_TOKEN": "testing",
|
|
}
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_multikey(mock_storage):
|
|
# given
|
|
bucket_name = "foo"
|
|
paths = ["b/model.bin"]
|
|
object_paths = ["test/a/" + p for p in paths]
|
|
|
|
# when
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_storage, object_paths)
|
|
Storage._download_s3(f"s3://{bucket_name}/test/a", "dest_path")
|
|
|
|
# then
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
assert arg_list == expected_call_args_list("test/a", "dest_path", paths)
|
|
|
|
mock_boto3_bucket.objects.filter.assert_called_with(Prefix="test/a")
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_files_with_no_extension(mock_storage):
|
|
# given
|
|
bucket_name = "foo"
|
|
paths = ["churn-pickle", "churn-pickle-logs", "churn-pickle-report"]
|
|
object_paths = ["test/" + p for p in paths]
|
|
|
|
# when
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_storage, object_paths)
|
|
Storage._download_s3(f"s3://{bucket_name}/test/churn-pickle", "dest_path")
|
|
|
|
# then
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
|
|
# Download only the exact file if found; otherwise, download all files with the given prefix
|
|
assert arg_list[0] == expected_call_args_list("test", "dest_path", paths)[0]
|
|
|
|
mock_boto3_bucket.objects.filter.assert_called_with(Prefix="test/churn-pickle")
|
|
|
|
|
|
def test_get_S3_config():
|
|
# Default config now includes configurable timeouts and retries
|
|
DEFAULT_CONFIG = Config(
|
|
connect_timeout=15,
|
|
read_timeout=30,
|
|
retries={"max_attempts": 3, "mode": "standard"},
|
|
)
|
|
ANON_CONFIG = Config(signature_version=UNSIGNED)
|
|
VIRTUAL_CONFIG = Config(s3={"addressing_style": "virtual"})
|
|
USE_ACCELERATE_CONFIG = Config(s3={"use_accelerate_endpoint": True})
|
|
|
|
with mock.patch.dict(os.environ, {}, clear=True):
|
|
config1 = Storage.get_S3_config()
|
|
assert config1.connect_timeout == DEFAULT_CONFIG.connect_timeout
|
|
assert config1.read_timeout == DEFAULT_CONFIG.read_timeout
|
|
assert config1.retries == DEFAULT_CONFIG.retries
|
|
|
|
with mock.patch.dict(os.environ, {"awsAnonymousCredential": "False"}, clear=True):
|
|
config2 = Storage.get_S3_config()
|
|
assert config2.connect_timeout == DEFAULT_CONFIG.connect_timeout
|
|
|
|
with mock.patch.dict(os.environ, AWS_TEST_CREDENTIALS, clear=True):
|
|
config3 = Storage.get_S3_config()
|
|
assert config3.connect_timeout == DEFAULT_CONFIG.connect_timeout
|
|
|
|
with mock.patch.dict(os.environ, {"awsAnonymousCredential": "True"}):
|
|
config4 = Storage.get_S3_config()
|
|
assert config4.signature_version == ANON_CONFIG.signature_version
|
|
|
|
# assuming Python 3.5 or greater for joining dictionaries
|
|
credentials_and_anon = {**AWS_TEST_CREDENTIALS, "awsAnonymousCredential": "True"}
|
|
with mock.patch.dict(os.environ, credentials_and_anon):
|
|
config5 = Storage.get_S3_config()
|
|
assert config5.signature_version == ANON_CONFIG.signature_version
|
|
|
|
with mock.patch.dict(os.environ, {"S3_USER_VIRTUAL_BUCKET": "False"}, clear=True):
|
|
config6 = Storage.get_S3_config()
|
|
assert config6.connect_timeout == DEFAULT_CONFIG.connect_timeout
|
|
|
|
with mock.patch.dict(os.environ, {"S3_USER_VIRTUAL_BUCKET": "True"}, clear=True):
|
|
config7 = Storage.get_S3_config()
|
|
assert config7.s3["addressing_style"] == VIRTUAL_CONFIG.s3["addressing_style"]
|
|
|
|
with mock.patch.dict(os.environ, {"S3_USE_ACCELERATE": "False"}, clear=True):
|
|
config6 = Storage.get_S3_config()
|
|
assert config6.connect_timeout == DEFAULT_CONFIG.connect_timeout
|
|
|
|
with mock.patch.dict(os.environ, {"S3_USE_ACCELERATE": "True"}):
|
|
config7 = Storage.get_S3_config()
|
|
assert (
|
|
config7.s3["use_accelerate_endpoint"]
|
|
== USE_ACCELERATE_CONFIG.s3["use_accelerate_endpoint"]
|
|
)
|
|
|
|
# tests legacy endpoint url
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"AWS_ENDPOINT_URL": "https://s3.amazonaws.com",
|
|
"AWS_DEFAULT_REGION": "eu-west-1",
|
|
},
|
|
):
|
|
config8 = Storage.get_S3_config()
|
|
assert config8.s3["addressing_style"] == VIRTUAL_CONFIG.s3["addressing_style"]
|
|
|
|
# tests custom timeout configuration
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"S3_CONNECT_TIMEOUT": "5",
|
|
"S3_READ_TIMEOUT": "10",
|
|
"S3_MAX_ATTEMPTS": "5",
|
|
},
|
|
clear=True,
|
|
):
|
|
config9 = Storage.get_S3_config()
|
|
assert config9.connect_timeout == 5
|
|
assert config9.read_timeout == 10
|
|
assert config9.retries["max_attempts"] == 5
|
|
|
|
|
|
def test_update_with_storage_spec_s3(monkeypatch):
|
|
# save the environment and restore it after the test to avoid mutating it
|
|
# since _update_with_storage_spec modifies it
|
|
previous_env = os.environ.copy()
|
|
|
|
monkeypatch.setenv("STORAGE_CONFIG", '{"type": "s3"}')
|
|
Storage._update_with_storage_spec()
|
|
|
|
for var in (
|
|
"AWS_ENDPOINT_URL",
|
|
"AWS_ACCESS_KEY_ID",
|
|
"AWS_SECRET_ACCESS_KEY",
|
|
"AWS_DEFAULT_REGION",
|
|
"AWS_CA_BUNDLE",
|
|
"S3_VERIFY_SSL",
|
|
"awsAnonymousCredential",
|
|
):
|
|
assert os.getenv(var) is None
|
|
|
|
storage_config = {
|
|
"access_key_id": "xxxxxxxxxxxxxxxxxxxx",
|
|
"bucket": "abucketname",
|
|
"default_bucket": "abucketname",
|
|
"endpoint_url": "https://s3.us-east-2.amazonaws.com/",
|
|
"region": "us-east-2",
|
|
"secret_access_key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
"type": "s3",
|
|
"ca_bundle": "/path/to/cabundle.crt",
|
|
"verify_ssl": "false",
|
|
"anonymous": "True",
|
|
}
|
|
|
|
monkeypatch.setenv("STORAGE_CONFIG", json.dumps(storage_config))
|
|
Storage._update_with_storage_spec()
|
|
|
|
assert os.getenv("AWS_ENDPOINT_URL") == storage_config["endpoint_url"]
|
|
assert os.getenv("AWS_ACCESS_KEY_ID") == storage_config["access_key_id"]
|
|
assert os.getenv("AWS_SECRET_ACCESS_KEY") == storage_config["secret_access_key"]
|
|
assert os.getenv("AWS_DEFAULT_REGION") == storage_config["region"]
|
|
assert os.getenv("AWS_CA_BUNDLE") == storage_config["ca_bundle"]
|
|
assert os.getenv("S3_VERIFY_SSL") == storage_config["verify_ssl"]
|
|
assert os.getenv("awsAnonymousCredential") == storage_config["anonymous"]
|
|
|
|
# revert changes
|
|
os.environ.clear()
|
|
os.environ.update(previous_env)
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_target_startswith_parent_folder_name(mock_storage):
|
|
bucket_name = "foo"
|
|
paths = ["model.pkl", "a/model.pkl", "conda.yaml"]
|
|
object_paths = ["test/artifacts/model/" + p for p in paths]
|
|
|
|
# when
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_storage, object_paths)
|
|
Storage._download_s3(f"s3://{bucket_name}/test/artifacts/model", "dest_path")
|
|
|
|
# then
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
assert (
|
|
arg_list[0]
|
|
== expected_call_args_list("test/artifacts/model", "dest_path", paths)[0]
|
|
)
|
|
mock_boto3_bucket.objects.filter.assert_called_with(Prefix="test/artifacts/model")
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_file_name_preservation(mock_storage):
|
|
# given
|
|
bucket_name = "local-model"
|
|
paths = ["MLmodel"]
|
|
object_paths = ["model/" + p for p in paths]
|
|
expected_file_name = "MLmodel" # Expected file name after download
|
|
|
|
# when
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_storage, object_paths)
|
|
Storage._download_s3(f"s3://{bucket_name}/model", "dest_path")
|
|
|
|
# then
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
assert len(arg_list) == 1 # Ensure only one file was downloaded
|
|
downloaded_source, downloaded_target = arg_list[0]
|
|
|
|
# Check if the source S3 key matches the original object key
|
|
assert downloaded_source == object_paths[0], (
|
|
f"Expected {object_paths[0]}, got {downloaded_source}"
|
|
)
|
|
|
|
# Check if the target file path ends with the expected file name
|
|
assert downloaded_target.endswith(expected_file_name), (
|
|
f"Expected file name to end with {expected_file_name}, got {downloaded_target}"
|
|
)
|
|
|
|
mock_boto3_bucket.objects.filter.assert_called_with(Prefix="model")
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_target_download_path_and_name(mock_storage):
|
|
bucket_name = "foo"
|
|
paths = ["model.pkl", "a/model.pkl", "conda.yaml"]
|
|
object_paths = ["model/" + p for p in paths]
|
|
|
|
# when
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_storage, object_paths)
|
|
Storage._download_s3(f"s3://{bucket_name}/model", "dest_path")
|
|
|
|
# then
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
assert arg_list[0] == expected_call_args_list("model", "dest_path", paths)[0]
|
|
assert arg_list[1] == expected_call_args_list("model", "dest_path", paths)[1]
|
|
|
|
mock_boto3_bucket.objects.filter.assert_called_with(Prefix="model")
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_ca_bundle_with_aws_ca_bundle_only(mock_storage):
|
|
"""Test that AWS_CA_BUNDLE can be used independently without CA_BUNDLE_CONFIGMAP_NAME"""
|
|
bucket_name = "foo"
|
|
object_key = "model.pkl"
|
|
|
|
# Create a temporary CA bundle file
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", delete=False, suffix=".crt"
|
|
) as temp_ca_file:
|
|
temp_ca_file.write(
|
|
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
|
|
)
|
|
ca_bundle_path = temp_ca_file.name
|
|
|
|
try:
|
|
# Mock the boto3 resource and bucket
|
|
create_mock_boto3_bucket(mock_storage, [object_key])
|
|
|
|
# Set only AWS_CA_BUNDLE environment variable (no CA_BUNDLE_CONFIGMAP_NAME)
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{"AWS_CA_BUNDLE": ca_bundle_path, "S3_VERIFY_SSL": "true"},
|
|
clear=True,
|
|
):
|
|
Storage._download_s3(f"s3://{bucket_name}/{object_key}", "dest_path")
|
|
|
|
# Verify that boto3.resource was called twice (once for listing, once for download)
|
|
# Both calls should have the correct verify parameter
|
|
assert mock_storage.call_count == 2
|
|
for call_args in mock_storage.call_args_list:
|
|
assert call_args[1]["verify"] == ca_bundle_path
|
|
|
|
finally:
|
|
# Clean up the temporary file
|
|
os.unlink(ca_bundle_path)
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_ca_bundle_with_configmap_only(mock_storage):
|
|
"""Test that CA bundle works with ConfigMap when AWS_CA_BUNDLE is not set"""
|
|
bucket_name = "foo"
|
|
object_key = "model.pkl"
|
|
|
|
# Create a temporary CA bundle file in the expected ConfigMap location
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
ca_bundle_path = os.path.join(temp_dir, "cabundle.crt")
|
|
with open(ca_bundle_path, "w") as f:
|
|
f.write("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
|
|
|
# Mock the boto3 resource and bucket
|
|
create_mock_boto3_bucket(mock_storage, [object_key])
|
|
|
|
# Set ConfigMap environment variables only
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"CA_BUNDLE_CONFIGMAP_NAME": "test-configmap",
|
|
"CA_BUNDLE_VOLUME_MOUNT_POINT": temp_dir,
|
|
"S3_VERIFY_SSL": "true",
|
|
},
|
|
clear=True,
|
|
):
|
|
Storage._download_s3(f"s3://{bucket_name}/{object_key}", "dest_path")
|
|
|
|
# Verify that boto3.resource was called twice (once for listing, once for download)
|
|
# Both calls should have the correct verify parameter
|
|
assert mock_storage.call_count == 2
|
|
for call_args in mock_storage.call_args_list:
|
|
assert call_args[1]["verify"] == ca_bundle_path
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_ca_bundle_aws_ca_bundle_takes_precedence(mock_storage):
|
|
"""Test that AWS_CA_BUNDLE takes precedence over ConfigMap when both are set"""
|
|
bucket_name = "foo"
|
|
object_key = "model.pkl"
|
|
|
|
# Create temporary CA bundle files
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", delete=False, suffix=".crt"
|
|
) as aws_ca_file:
|
|
aws_ca_file.write(
|
|
"-----BEGIN CERTIFICATE-----\naws_ca\n-----END CERTIFICATE-----"
|
|
)
|
|
aws_ca_bundle_path = aws_ca_file.name
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
configmap_ca_bundle_path = os.path.join(temp_dir, "cabundle.crt")
|
|
with open(configmap_ca_bundle_path, "w") as f:
|
|
f.write(
|
|
"-----BEGIN CERTIFICATE-----\nconfigmap_ca\n-----END CERTIFICATE-----"
|
|
)
|
|
|
|
try:
|
|
# Mock the boto3 resource and bucket
|
|
create_mock_boto3_bucket(mock_storage, [object_key])
|
|
|
|
# Set both AWS_CA_BUNDLE and ConfigMap environment variables
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"AWS_CA_BUNDLE": aws_ca_bundle_path,
|
|
"CA_BUNDLE_CONFIGMAP_NAME": "test-configmap",
|
|
"CA_BUNDLE_VOLUME_MOUNT_POINT": temp_dir,
|
|
"S3_VERIFY_SSL": "true",
|
|
},
|
|
clear=True,
|
|
):
|
|
Storage._download_s3(f"s3://{bucket_name}/{object_key}", "dest_path")
|
|
|
|
# Verify that boto3.resource was called twice with AWS_CA_BUNDLE path (takes precedence)
|
|
assert mock_storage.call_count == 2
|
|
for call_args in mock_storage.call_args_list:
|
|
assert call_args[1]["verify"] == aws_ca_bundle_path
|
|
|
|
finally:
|
|
# Clean up the temporary file
|
|
os.unlink(aws_ca_bundle_path)
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_ca_bundle_file_not_found_aws_ca_bundle(mock_storage):
|
|
"""Test that RuntimeError is raised when AWS_CA_BUNDLE file doesn't exist"""
|
|
bucket_name = "foo"
|
|
object_key = "model.pkl"
|
|
non_existent_path = "/tmp/non_existent_ca_bundle.crt"
|
|
|
|
# Mock the boto3 resource and bucket
|
|
create_mock_boto3_bucket(mock_storage, [object_key])
|
|
|
|
# Set AWS_CA_BUNDLE to a non-existent file
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{"AWS_CA_BUNDLE": non_existent_path, "S3_VERIFY_SSL": "true"},
|
|
clear=True,
|
|
):
|
|
with pytest.raises(
|
|
RuntimeError,
|
|
match=f"Failed to find ca bundle file\\({non_existent_path}\\)",
|
|
):
|
|
Storage._download_s3(f"s3://{bucket_name}/{object_key}", "dest_path")
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_ca_bundle_file_not_found_configmap(mock_storage):
|
|
"""Test that RuntimeError is raised when ConfigMap CA bundle file doesn't exist"""
|
|
bucket_name = "foo"
|
|
object_key = "model.pkl"
|
|
|
|
# Mock the boto3 resource and bucket
|
|
create_mock_boto3_bucket(mock_storage, [object_key])
|
|
|
|
# Set ConfigMap environment variables with non-existent directory
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"CA_BUNDLE_CONFIGMAP_NAME": "test-configmap",
|
|
"CA_BUNDLE_VOLUME_MOUNT_POINT": "/tmp/non_existent_dir",
|
|
"S3_VERIFY_SSL": "true",
|
|
},
|
|
clear=True,
|
|
):
|
|
expected_path = "/tmp/non_existent_dir/cabundle.crt"
|
|
with pytest.raises(
|
|
RuntimeError, match=f"Failed to find ca bundle file\\({expected_path}\\)"
|
|
):
|
|
Storage._download_s3(f"s3://{bucket_name}/{object_key}", "dest_path")
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_ca_bundle_verify_ssl_false_no_ca_bundle_check(mock_storage):
|
|
"""Test that CA bundle is not checked when S3_VERIFY_SSL is false"""
|
|
bucket_name = "foo"
|
|
object_key = "model.pkl"
|
|
non_existent_path = "/tmp/non_existent_ca_bundle.crt"
|
|
|
|
# Mock the boto3 resource and bucket
|
|
create_mock_boto3_bucket(mock_storage, [object_key])
|
|
|
|
# Set AWS_CA_BUNDLE to a non-existent file but disable SSL verification
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{"AWS_CA_BUNDLE": non_existent_path, "S3_VERIFY_SSL": "false"},
|
|
clear=True,
|
|
):
|
|
# This should not raise an error because verify_ssl is False
|
|
Storage._download_s3(f"s3://{bucket_name}/{object_key}", "dest_path")
|
|
|
|
# Verify that boto3.resource was called twice with verify=False
|
|
assert mock_storage.call_count == 2
|
|
for call_args in mock_storage.call_args_list:
|
|
assert call_args[1]["verify"] is False
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_ca_bundle_empty_aws_ca_bundle_uses_configmap(mock_storage):
|
|
"""Test that empty AWS_CA_BUNDLE falls back to ConfigMap"""
|
|
bucket_name = "foo"
|
|
object_key = "model.pkl"
|
|
|
|
# Create a temporary CA bundle file in the expected ConfigMap location
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
ca_bundle_path = os.path.join(temp_dir, "cabundle.crt")
|
|
with open(ca_bundle_path, "w") as f:
|
|
f.write("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
|
|
|
# Mock the boto3 resource and bucket
|
|
create_mock_boto3_bucket(mock_storage, [object_key])
|
|
|
|
# Set empty AWS_CA_BUNDLE and ConfigMap environment variables
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"AWS_CA_BUNDLE": "",
|
|
"CA_BUNDLE_CONFIGMAP_NAME": "test-configmap",
|
|
"CA_BUNDLE_VOLUME_MOUNT_POINT": temp_dir,
|
|
"S3_VERIFY_SSL": "true",
|
|
},
|
|
clear=True,
|
|
):
|
|
Storage._download_s3(f"s3://{bucket_name}/{object_key}", "dest_path")
|
|
|
|
# Verify that boto3.resource was called twice with the ConfigMap path
|
|
assert mock_storage.call_count == 2
|
|
for call_args in mock_storage.call_args_list:
|
|
assert call_args[1]["verify"] == ca_bundle_path
|
|
|
|
|
|
def test_error_message_no_credentials():
|
|
from botocore.exceptions import NoCredentialsError
|
|
|
|
error = NoCredentialsError()
|
|
msg = get_storage_error_message("S3", error, "test-bucket")
|
|
assert "authentication failed" in msg.lower()
|
|
assert "AWS_ACCESS_KEY_ID" in msg or "S3" in msg
|
|
|
|
|
|
def test_error_message_access_denied():
|
|
error = botocore.exceptions.ClientError(
|
|
{"Error": {"Code": "AccessDenied", "Message": "Access Denied"}},
|
|
"GetObject",
|
|
)
|
|
msg = get_storage_error_message("S3", error, "test-bucket")
|
|
assert "access denied" in msg.lower() or "403" in msg
|
|
|
|
|
|
def test_error_message_bucket_not_found():
|
|
error = botocore.exceptions.ClientError(
|
|
{
|
|
"Error": {
|
|
"Code": "NoSuchBucket",
|
|
"Message": "The specified bucket does not exist",
|
|
}
|
|
},
|
|
"HeadBucket",
|
|
)
|
|
msg = get_storage_error_message("S3", error, "nonexistent-bucket")
|
|
assert "does not exist" in msg.lower() or "nonexistent-bucket" in msg
|
|
|
|
|
|
def test_error_message_invalid_credentials():
|
|
error = botocore.exceptions.ClientError(
|
|
{
|
|
"Error": {
|
|
"Code": "InvalidAccessKeyId",
|
|
"Message": "The AWS Access Key Id you provided does not exist",
|
|
}
|
|
},
|
|
"HeadBucket",
|
|
)
|
|
msg = get_storage_error_message("S3", error, "test-bucket")
|
|
assert "S3" in msg or "AWS" in msg or "credentials" in msg.lower()
|
|
|
|
|
|
def test_error_message_connection_error():
|
|
error = Exception("connection timeout")
|
|
msg = get_storage_error_message("S3", error, "endpoint.example.com")
|
|
assert "connect" in msg.lower() or "timeout" in msg.lower()
|
|
|
|
|
|
def test_error_message_not_found():
|
|
error = Exception("resource not found")
|
|
msg = get_storage_error_message("GCS", error, "my-bucket")
|
|
assert "not found" in msg.lower()
|
|
|
|
|
|
def test_error_message_gcs_auth():
|
|
error = Exception("credentials error")
|
|
msg = get_storage_error_message("GCS", error, "my-bucket")
|
|
assert "authentication failed" in msg.lower() or "GCS" in msg
|
|
|
|
|
|
def test_error_message_azure_auth():
|
|
error = Exception("authentication failed")
|
|
msg = get_storage_error_message("Azure", error, "my-container")
|
|
assert "authentication failed" in msg.lower()
|
|
|
|
|
|
def test_error_message_huggingface():
|
|
error = Exception("gated repo access denied")
|
|
msg = get_storage_error_message("HuggingFace", error, "org/model")
|
|
assert "HuggingFace" in msg or "HF_TOKEN" in msg
|
|
|
|
|
|
def test_error_message_default():
|
|
error = Exception("some unknown error")
|
|
msg = get_storage_error_message("S3", error, "test-bucket")
|
|
assert "S3" in msg
|
|
assert "unknown error" in msg.lower()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"status_code, expected_message",
|
|
[
|
|
(401, "authentication"),
|
|
(403, "access denied"),
|
|
(404, "not found"),
|
|
(500, "500"),
|
|
],
|
|
)
|
|
def test_check_http_response_errors(status_code, expected_message):
|
|
mock_response = mock.MagicMock()
|
|
mock_response.status_code = status_code
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
check_http_response("https://example.com/model", mock_response)
|
|
assert expected_message in str(exc_info.value).lower()
|
|
|
|
|
|
def test_check_http_response_200_ok():
|
|
mock_response = mock.MagicMock()
|
|
mock_response.status_code = 200
|
|
check_http_response("https://example.com/model", mock_response)
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_s3_no_credentials_error(mock_resource):
|
|
from botocore.exceptions import NoCredentialsError
|
|
|
|
mock_s3_resource = mock.MagicMock()
|
|
mock_s3_resource.meta.client.head_bucket.side_effect = NoCredentialsError()
|
|
mock_resource.return_value = mock_s3_resource
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
Storage._download_s3("s3://test-bucket/model", "/tmp/dest")
|
|
assert "authentication" in str(exc_info.value).lower()
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_s3_invalid_endpoint_error(mock_resource):
|
|
mock_resource.side_effect = ValueError("Invalid endpoint: bad-url")
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
Storage._download_s3("s3://test-bucket/model", "/tmp/dest")
|
|
assert "S3" in str(exc_info.value)
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_s3_allow_patterns(mock_storage):
|
|
bucket_name = "foo"
|
|
object_paths = [
|
|
"model/weights.safetensors",
|
|
"model/weights.bin",
|
|
"model/config.json",
|
|
]
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_storage, object_paths)
|
|
|
|
Storage._download_s3(
|
|
f"s3://{bucket_name}/model",
|
|
"dest_path",
|
|
allow_patterns=["*.safetensors", "*.json"],
|
|
)
|
|
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
downloaded_keys = [a[0] for a in arg_list]
|
|
assert "model/weights.safetensors" in downloaded_keys
|
|
assert "model/config.json" in downloaded_keys
|
|
assert "model/weights.bin" not in downloaded_keys
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_s3_ignore_patterns(mock_storage):
|
|
bucket_name = "foo"
|
|
object_paths = [
|
|
"model/weights.safetensors",
|
|
"model/weights.bin",
|
|
"model/config.json",
|
|
]
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_storage, object_paths)
|
|
|
|
Storage._download_s3(
|
|
f"s3://{bucket_name}/model",
|
|
"dest_path",
|
|
ignore_patterns=["*.bin"],
|
|
)
|
|
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
downloaded_keys = [a[0] for a in arg_list]
|
|
assert "model/weights.bin" not in downloaded_keys
|
|
assert len(downloaded_keys) == 2
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_s3_exact_object_bypasses_filter(mock_storage):
|
|
"""When a URI points to an exact object, filtering should not apply."""
|
|
bucket_name = "foo"
|
|
object_key = "model/weights.bin"
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_storage, [object_key])
|
|
|
|
# Even with allow_patterns that exclude .bin, exact object should still download
|
|
Storage._download_s3(
|
|
f"s3://{bucket_name}/{object_key}",
|
|
"dest_path",
|
|
allow_patterns=["*.safetensors"],
|
|
)
|
|
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
assert len(arg_list) == 1
|
|
assert arg_list[0][0] == object_key
|
|
|
|
|
|
@mock.patch("boto3.resource")
|
|
def test_s3_no_patterns_downloads_all(mock_storage):
|
|
bucket_name = "foo"
|
|
object_paths = [
|
|
"model/weights.safetensors",
|
|
"model/weights.bin",
|
|
"model/config.json",
|
|
]
|
|
mock_boto3_bucket = create_mock_boto3_bucket(mock_storage, object_paths)
|
|
|
|
Storage._download_s3(f"s3://{bucket_name}/model", "dest_path")
|
|
|
|
arg_list = get_call_args(mock_boto3_bucket.download_file.call_args_list)
|
|
assert len(arg_list) == 3
|