mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
536 lines
20 KiB
Python
536 lines
20 KiB
Python
# Copyright 2025 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 asyncio
|
|
import json
|
|
import os
|
|
import time
|
|
import uuid
|
|
from contextlib import contextmanager
|
|
|
|
import pytest
|
|
from kubernetes import client
|
|
from kubernetes.client import V1ResourceRequirements
|
|
|
|
from kserve import (
|
|
KServeClient,
|
|
V1beta1InferenceService,
|
|
V1beta1InferenceServiceSpec,
|
|
V1beta1PredictorSpec,
|
|
V1beta1SKLearnSpec,
|
|
constants,
|
|
)
|
|
from kserve.logging import trace_logger as logger
|
|
|
|
from ..common.utils import KSERVE_TEST_NAMESPACE
|
|
|
|
|
|
def get_isvc_data(kserve_client: KServeClient, name: str, namespace: str):
|
|
"""Get ISVC resource data for debugging."""
|
|
try:
|
|
return kserve_client.get(name, namespace=namespace)
|
|
except Exception as e:
|
|
return {"error": f"Failed to get ISVC {name}: {e}"}
|
|
|
|
|
|
def get_deployments_for_isvc(name: str, namespace: str) -> list[dict]:
|
|
"""Get deployments matching the ISVC."""
|
|
apps_api = client.AppsV1Api()
|
|
try:
|
|
deployments = apps_api.list_namespaced_deployment(
|
|
namespace=namespace,
|
|
label_selector=f"serving.kserve.io/inferenceservice={name}",
|
|
)
|
|
return [dep.to_dict() for dep in deployments.items]
|
|
except Exception as e:
|
|
return [{"error": f"Failed to list deployments for ISVC {name}: {e}"}]
|
|
|
|
|
|
def get_pods_for_isvc(name: str, namespace: str) -> list[dict]:
|
|
"""Get pods matching the ISVC."""
|
|
core_api = client.CoreV1Api()
|
|
try:
|
|
pods = core_api.list_namespaced_pod(
|
|
namespace=namespace,
|
|
label_selector=f"serving.kserve.io/inferenceservice={name}",
|
|
)
|
|
return [pod.to_dict() for pod in pods.items]
|
|
except Exception as e:
|
|
return [{"error": f"Failed to list pods for ISVC {name}: {e}"}]
|
|
|
|
|
|
def get_controller_logs_for_isvc(name: str, namespace: str) -> list[dict]:
|
|
"""Get controller log entries for a specific ISVC."""
|
|
try:
|
|
logs = get_controller_logs(since_seconds=300) # Last 5 minutes
|
|
entries = []
|
|
for line in logs.strip().split("\n"):
|
|
if not line:
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
if entry.get("isvc") == name and entry.get("namespace") == namespace:
|
|
entries.append(entry)
|
|
except json.JSONDecodeError:
|
|
if name in line and namespace in line:
|
|
entries.append({"raw": line})
|
|
return entries
|
|
except Exception as e:
|
|
return [{"error": f"Failed to get controller logs: {e}"}]
|
|
|
|
|
|
def dump_debug_info(
|
|
kserve_client: KServeClient, isvc_names: list[str], namespace: str
|
|
) -> None:
|
|
"""Dump debug info for the given ISVCs, their deployments, pods, and logs as compact JSON."""
|
|
for isvc_name in isvc_names:
|
|
debug_data = {
|
|
"isvc": get_isvc_data(kserve_client, isvc_name, namespace),
|
|
"deployments": get_deployments_for_isvc(isvc_name, namespace),
|
|
"pods": get_pods_for_isvc(isvc_name, namespace),
|
|
"controller_logs": get_controller_logs_for_isvc(isvc_name, namespace),
|
|
}
|
|
logger.info(
|
|
"DEBUG DUMP %s/%s:\n%s",
|
|
namespace,
|
|
isvc_name,
|
|
json.dumps(debug_data, separators=(",", ":"), default=str),
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def managed_isvc(kserve_client: KServeClient, isvc: V1beta1InferenceService):
|
|
"""
|
|
Context manager that handles ISVC lifecycle: creation, error dumping, and cleanup.
|
|
|
|
Usage:
|
|
with managed_isvc(kserve_client, isvc):
|
|
# ISVC is already created
|
|
# ... test logic ...
|
|
# On any exception: dumps debug info for the ISVC
|
|
# On exit: deletes the ISVC
|
|
"""
|
|
assert isvc.metadata is not None, "ISVC must have metadata"
|
|
assert isvc.metadata.name is not None, "ISVC must have a name"
|
|
assert isvc.metadata.namespace is not None, "ISVC must have a namespace"
|
|
name = isvc.metadata.name
|
|
namespace = isvc.metadata.namespace
|
|
error_occurred = False
|
|
try:
|
|
kserve_client.create(isvc)
|
|
yield
|
|
except Exception:
|
|
error_occurred = True
|
|
dump_debug_info(kserve_client, [name], namespace)
|
|
raise
|
|
finally:
|
|
try:
|
|
kserve_client.delete(name, namespace)
|
|
except Exception as e:
|
|
if not error_occurred:
|
|
logger.warning("Failed to delete ISVC %s: %s", name, e)
|
|
|
|
|
|
def get_isvc_resource_version(
|
|
kserve_client: KServeClient, name: str, namespace: str = KSERVE_TEST_NAMESPACE
|
|
) -> str:
|
|
isvc = kserve_client.get(name, namespace=namespace)
|
|
metadata = isvc.get("metadata") if isinstance(isvc, dict) else {}
|
|
if isinstance(metadata, dict):
|
|
return str(metadata.get("resourceVersion", ""))
|
|
return ""
|
|
|
|
|
|
def get_isvc_model_status(
|
|
kserve_client: KServeClient, name: str, namespace: str = KSERVE_TEST_NAMESPACE
|
|
) -> dict:
|
|
isvc = kserve_client.get(name, namespace=namespace)
|
|
status = isvc.get("status") if isinstance(isvc, dict) else {}
|
|
if isinstance(status, dict):
|
|
model_status = status.get("modelStatus")
|
|
return model_status if isinstance(model_status, dict) else {}
|
|
return {}
|
|
|
|
|
|
def get_isvc_conditions(
|
|
kserve_client: KServeClient, name: str, namespace: str = KSERVE_TEST_NAMESPACE
|
|
) -> list:
|
|
isvc = kserve_client.get(name, namespace=namespace)
|
|
status = isvc.get("status") if isinstance(isvc, dict) else {}
|
|
if isinstance(status, dict):
|
|
conditions = status.get("conditions")
|
|
return conditions if isinstance(conditions, list) else []
|
|
return []
|
|
|
|
|
|
def create_invalid_s3_secret(namespace: str, secret_name: str):
|
|
core_api = client.CoreV1Api()
|
|
secret = client.V1Secret(
|
|
api_version="v1",
|
|
kind="Secret",
|
|
metadata=client.V1ObjectMeta(
|
|
name=secret_name,
|
|
namespace=namespace,
|
|
annotations={
|
|
"serving.kserve.io/s3-endpoint": "s3.amazonaws.com",
|
|
"serving.kserve.io/s3-region": "us-east-1",
|
|
"serving.kserve.io/s3-usehttps": "1",
|
|
"serving.kserve.io/s3-verifyssl": "1",
|
|
},
|
|
),
|
|
type="Opaque",
|
|
string_data={
|
|
"AWS_ACCESS_KEY_ID": "INVALID_ACCESS_KEY_ID_12345",
|
|
"AWS_SECRET_ACCESS_KEY": "INVALID_SECRET_ACCESS_KEY_67890",
|
|
},
|
|
)
|
|
|
|
try:
|
|
core_api.delete_namespaced_secret(secret_name, namespace)
|
|
except client.ApiException:
|
|
pass
|
|
|
|
return core_api.create_namespaced_secret(namespace, secret)
|
|
|
|
|
|
def create_service_account_with_secret(namespace: str, sa_name: str, secret_name: str):
|
|
core_api = client.CoreV1Api()
|
|
|
|
sa = client.V1ServiceAccount(
|
|
api_version="v1",
|
|
kind="ServiceAccount",
|
|
metadata=client.V1ObjectMeta(name=sa_name, namespace=namespace),
|
|
secrets=[client.V1ObjectReference(name=secret_name)],
|
|
)
|
|
|
|
try:
|
|
core_api.delete_namespaced_service_account(sa_name, namespace)
|
|
except client.ApiException:
|
|
pass
|
|
|
|
return core_api.create_namespaced_service_account(namespace, sa)
|
|
|
|
|
|
def delete_secret(namespace: str, secret_name: str):
|
|
core_api = client.CoreV1Api()
|
|
try:
|
|
core_api.delete_namespaced_secret(secret_name, namespace)
|
|
except client.ApiException:
|
|
pass
|
|
|
|
|
|
def delete_service_account(namespace: str, sa_name: str):
|
|
core_api = client.CoreV1Api()
|
|
try:
|
|
core_api.delete_namespaced_service_account(sa_name, namespace)
|
|
except client.ApiException:
|
|
pass
|
|
|
|
|
|
def wait_for_isvc_failure_status(
|
|
kserve_client: KServeClient,
|
|
name: str,
|
|
namespace: str = KSERVE_TEST_NAMESPACE,
|
|
timeout_seconds: int = 120,
|
|
poll_interval: float = 2.0,
|
|
) -> dict | None:
|
|
start_time = time.time()
|
|
while time.time() - start_time < timeout_seconds:
|
|
try:
|
|
model_status = get_isvc_model_status(kserve_client, name, namespace)
|
|
last_failure = model_status.get("lastFailureInfo")
|
|
if last_failure is not None:
|
|
logger.info(
|
|
"ISVC %s reported failure: %s",
|
|
name,
|
|
last_failure,
|
|
)
|
|
return model_status
|
|
except Exception as e:
|
|
logger.warning("Error checking ISVC status: %s", e)
|
|
time.sleep(poll_interval)
|
|
return None
|
|
|
|
|
|
def get_controller_logs(since_seconds: int) -> str:
|
|
core_api = client.CoreV1Api()
|
|
pods = core_api.list_namespaced_pod(
|
|
namespace="kserve",
|
|
label_selector="control-plane=kserve-controller-manager",
|
|
)
|
|
if not pods.items:
|
|
raise RuntimeError(
|
|
"No controller manager pod found in kserve namespace. "
|
|
"Cannot perform log analysis for reconciliation detection."
|
|
)
|
|
pod = pods.items[0]
|
|
try:
|
|
return core_api.read_namespaced_pod_log(
|
|
name=pod.metadata.name,
|
|
namespace="kserve",
|
|
container="manager",
|
|
since_seconds=since_seconds,
|
|
)
|
|
except client.ApiException as e:
|
|
raise RuntimeError(
|
|
f"Failed to read controller logs from pod {pod.metadata.name}: {e}"
|
|
) from e
|
|
|
|
|
|
@pytest.mark.predictor
|
|
@pytest.mark.raw
|
|
@pytest.mark.asyncio(scope="session")
|
|
async def test_event_storm_prevention_init_container_isolation(rest_v1_client):
|
|
"""
|
|
Test that init container status changes on one ISVC don't cause unwanted modifications
|
|
to unrelated ISVCs (event storm prevention).
|
|
|
|
The controller may reconcile an ISVC for legitimate reasons (e.g.,
|
|
HTTPRoute status updates from Istio, deployment status changes) without making any
|
|
changes. This is acceptable. The real concern is if the secondary ISVC's events
|
|
cause the primary ISVC to be MODIFIED (resourceVersion change).
|
|
|
|
Test flow:
|
|
1. Creates a "primary" ISVC that will successfully load a model from GCS
|
|
2. Waits for the primary ISVC to become ready
|
|
3. Records baseline resourceVersion
|
|
4. Creates a "secondary" ISVC with invalid S3 credentials that will fail
|
|
5. Waits for the secondary ISVC to show failure status
|
|
6. Verifies the primary ISVC's resourceVersion is unchanged
|
|
"""
|
|
suffix = str(uuid.uuid4())[:6]
|
|
primary_name = f"isvc-primary-{suffix}"
|
|
secondary_name = f"isvc-secondary-{suffix}"
|
|
invalid_sa_name = f"invalid-s3-sa-{suffix}"
|
|
invalid_secret_name = f"invalid-s3-secret-{suffix}"
|
|
|
|
kserve_client = KServeClient(
|
|
config_file=os.environ.get("KUBECONFIG", "~/.kube/config")
|
|
)
|
|
|
|
# Create primary ISVC with a valid GCS storage URI (no credentials needed)
|
|
primary_predictor = V1beta1PredictorSpec(
|
|
min_replicas=1,
|
|
sklearn=V1beta1SKLearnSpec(
|
|
storage_uri="gs://kfserving-examples/models/sklearn/1.0/model",
|
|
resources=V1ResourceRequirements(
|
|
requests={"cpu": "50m", "memory": "128Mi"},
|
|
limits={"cpu": "100m", "memory": "256Mi"},
|
|
),
|
|
),
|
|
)
|
|
|
|
primary_isvc = V1beta1InferenceService(
|
|
api_version=constants.KSERVE_V1BETA1,
|
|
kind=constants.KSERVE_KIND_INFERENCESERVICE,
|
|
metadata=client.V1ObjectMeta(
|
|
name=primary_name, namespace=KSERVE_TEST_NAMESPACE
|
|
),
|
|
spec=V1beta1InferenceServiceSpec(predictor=primary_predictor),
|
|
)
|
|
|
|
with managed_isvc(kserve_client, primary_isvc):
|
|
# Step 1: Wait for primary ISVC to be ready (created by managed_isvc)
|
|
logger.info("Created primary ISVC: %s", primary_name)
|
|
kserve_client.wait_isvc_ready(primary_name, namespace=KSERVE_TEST_NAMESPACE)
|
|
logger.info("Primary ISVC is ready")
|
|
|
|
# Record baseline resourceVersion
|
|
primary_rv_before = get_isvc_resource_version(kserve_client, primary_name)
|
|
logger.info("Baseline recorded - resourceVersion: %s", primary_rv_before)
|
|
|
|
# Step 2: Create invalid S3 credentials
|
|
logger.info("Creating invalid S3 secret and service account")
|
|
create_invalid_s3_secret(KSERVE_TEST_NAMESPACE, invalid_secret_name)
|
|
create_service_account_with_secret(
|
|
KSERVE_TEST_NAMESPACE, invalid_sa_name, invalid_secret_name
|
|
)
|
|
|
|
# Step 3: Create secondary ISVC with invalid S3 credentials
|
|
secondary_predictor = V1beta1PredictorSpec(
|
|
min_replicas=1,
|
|
service_account_name=invalid_sa_name,
|
|
sklearn=V1beta1SKLearnSpec(
|
|
storage_uri="s3://nonexistent-bucket-12345/invalid/path/model",
|
|
resources=V1ResourceRequirements(
|
|
requests={"cpu": "50m", "memory": "128Mi"},
|
|
limits={"cpu": "100m", "memory": "256Mi"},
|
|
),
|
|
),
|
|
)
|
|
|
|
secondary_isvc = V1beta1InferenceService(
|
|
api_version=constants.KSERVE_V1BETA1,
|
|
kind=constants.KSERVE_KIND_INFERENCESERVICE,
|
|
metadata=client.V1ObjectMeta(
|
|
name=secondary_name, namespace=KSERVE_TEST_NAMESPACE
|
|
),
|
|
spec=V1beta1InferenceServiceSpec(predictor=secondary_predictor),
|
|
)
|
|
|
|
with managed_isvc(kserve_client, secondary_isvc):
|
|
# Step 4: Wait for secondary ISVC to report failure (created by managed_isvc)
|
|
logger.info(
|
|
"Created secondary ISVC %s, waiting for failure status...",
|
|
secondary_name,
|
|
)
|
|
secondary_failure = wait_for_isvc_failure_status(
|
|
kserve_client, secondary_name, timeout_seconds=180
|
|
)
|
|
if secondary_failure:
|
|
logger.info("Secondary ISVC failure detected: %s", secondary_failure)
|
|
|
|
# Give time for any potential event storms to propagate
|
|
await asyncio.sleep(10)
|
|
|
|
# Step 5: Verify primary ISVC was not modified
|
|
# The controller may reconcile the primary ISVC for legitimate reasons
|
|
# (e.g., HTTPRoute status updates from Istio), but no-op reconciliations
|
|
# are fine. Only fail if resourceVersion changed (actual modification).
|
|
primary_rv_after = get_isvc_resource_version(kserve_client, primary_name)
|
|
logger.info(
|
|
"Primary ISVC resourceVersion: before=%s, after=%s",
|
|
primary_rv_before,
|
|
primary_rv_after,
|
|
)
|
|
|
|
assert primary_rv_before == primary_rv_after, (
|
|
f"Primary ISVC '{primary_name}' was modified during secondary ISVC failure. "
|
|
f"ResourceVersion changed from {primary_rv_before} to {primary_rv_after}. "
|
|
"This indicates potential event storm - init container status changes "
|
|
"on secondary ISVC may have triggered modification of unrelated primary ISVC."
|
|
)
|
|
|
|
logger.info(
|
|
"Event storm prevention validated: Primary ISVC was not modified "
|
|
"during secondary ISVC init container failures"
|
|
)
|
|
|
|
|
|
@pytest.mark.predictor
|
|
@pytest.mark.raw
|
|
@pytest.mark.asyncio(scope="session")
|
|
async def test_quick_reconciliation_on_init_container_failure():
|
|
"""
|
|
Test that when an init container fails (e.g., invalid storage credentials),
|
|
the owning InferenceService quickly reconciles and reflects the failure in its status.
|
|
|
|
This test:
|
|
1. Creates an ISVC with invalid S3 credentials
|
|
2. Monitors the ISVC status for failure detection
|
|
3. Validates that failure status is populated within a reasonable timeframe
|
|
4. Verifies the failure message contains relevant error information
|
|
"""
|
|
suffix = str(uuid.uuid4())[:6]
|
|
isvc_name = f"isvc-init-fail-{suffix}"
|
|
invalid_sa_name = f"fail-s3-sa-{suffix}"
|
|
invalid_secret_name = f"fail-s3-secret-{suffix}"
|
|
|
|
kserve_client = KServeClient(
|
|
config_file=os.environ.get("KUBECONFIG", "~/.kube/config")
|
|
)
|
|
|
|
try:
|
|
# Create invalid S3 credentials
|
|
logger.info("Creating invalid S3 secret and service account")
|
|
create_invalid_s3_secret(KSERVE_TEST_NAMESPACE, invalid_secret_name)
|
|
create_service_account_with_secret(
|
|
KSERVE_TEST_NAMESPACE, invalid_sa_name, invalid_secret_name
|
|
)
|
|
|
|
# Create ISVC with invalid S3 credentials
|
|
predictor = V1beta1PredictorSpec(
|
|
min_replicas=1,
|
|
service_account_name=invalid_sa_name,
|
|
sklearn=V1beta1SKLearnSpec(
|
|
storage_uri="s3://nonexistent-bucket-xyz123/invalid/model",
|
|
resources=V1ResourceRequirements(
|
|
requests={"cpu": "50m", "memory": "128Mi"},
|
|
limits={"cpu": "100m", "memory": "256Mi"},
|
|
),
|
|
),
|
|
)
|
|
|
|
isvc = V1beta1InferenceService(
|
|
api_version=constants.KSERVE_V1BETA1,
|
|
kind=constants.KSERVE_KIND_INFERENCESERVICE,
|
|
metadata=client.V1ObjectMeta(
|
|
name=isvc_name, namespace=KSERVE_TEST_NAMESPACE
|
|
),
|
|
spec=V1beta1InferenceServiceSpec(predictor=predictor),
|
|
)
|
|
|
|
creation_time = time.time()
|
|
with managed_isvc(kserve_client, isvc):
|
|
# Wait for failure status to be populated
|
|
logger.info("Created ISVC %s, waiting for failure status...", isvc_name)
|
|
failure_status = wait_for_isvc_failure_status(
|
|
kserve_client, isvc_name, timeout_seconds=180, poll_interval=5.0
|
|
)
|
|
|
|
failure_detection_time = time.time()
|
|
time_to_failure = failure_detection_time - creation_time
|
|
|
|
# Validate failure was detected
|
|
assert failure_status is not None, (
|
|
f"ISVC {isvc_name} did not report failure status within timeout. "
|
|
f"The init container failure should trigger quick reconciliation and status update."
|
|
)
|
|
|
|
logger.info(
|
|
"Failure status detected in %.2f seconds: %s",
|
|
time_to_failure,
|
|
failure_status,
|
|
)
|
|
|
|
# Validate failure info contains expected fields
|
|
last_failure = failure_status.get("lastFailureInfo", {})
|
|
assert last_failure.get("reason") is not None, (
|
|
"lastFailureInfo.reason should be populated"
|
|
)
|
|
|
|
# The transition status should indicate blocked by failed load
|
|
transition_status = failure_status.get("transitionStatus")
|
|
logger.info("Transition status: %s", transition_status)
|
|
|
|
# Check conditions for failure indication
|
|
conditions = get_isvc_conditions(kserve_client, isvc_name)
|
|
ready_condition = next(
|
|
(c for c in conditions if c.get("type") == "Ready"), None
|
|
)
|
|
|
|
if ready_condition:
|
|
logger.info("Ready condition: %s", ready_condition)
|
|
# The service should not be ready due to init container failure
|
|
assert ready_condition.get("status") != "True", (
|
|
"ISVC should not be Ready when init container fails"
|
|
)
|
|
|
|
# Validate reasonable time to failure detection
|
|
# The pod watch should trigger reconciliation quickly when init container status changes
|
|
assert time_to_failure < 180, (
|
|
f"Failure detection took too long ({time_to_failure:.2f}s). "
|
|
f"Pod watch should trigger quick reconciliation on init container failure."
|
|
)
|
|
|
|
logger.info(
|
|
"Quick reconciliation validated: Failure detected in %.2f seconds",
|
|
time_to_failure,
|
|
)
|
|
|
|
finally:
|
|
# Cleanup non-ISVC resources (ISVCs are cleaned up by managed_isvc)
|
|
delete_service_account(KSERVE_TEST_NAMESPACE, invalid_sa_name)
|
|
delete_secret(KSERVE_TEST_NAMESPACE, invalid_secret_name)
|