diff --git a/src/aleph/sdk/client/services/crn.py b/src/aleph/sdk/client/services/crn.py index 004888fa..da5a24a7 100644 --- a/src/aleph/sdk/client/services/crn.py +++ b/src/aleph/sdk/client/services/crn.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime from typing import TYPE_CHECKING, Dict, List, Optional, Union @@ -5,7 +6,7 @@ from aiohttp.client_exceptions import ClientResponseError from aleph_message.models import ItemHash from packaging.version import InvalidVersion, Version -from pydantic import BaseModel, NonNegativeInt, PositiveInt +from pydantic import BaseModel, NonNegativeInt, PositiveInt, ValidationError from aleph.sdk.conf import settings from aleph.sdk.exceptions import MethodNotAvailableOnCRN, VmNotFoundOnHost @@ -22,6 +23,8 @@ if TYPE_CHECKING: from aleph.sdk.client.http import AlephHttpClient +logger = logging.getLogger(__name__) + class CpuLoad(BaseModel): load1: float @@ -120,10 +123,23 @@ class CrnList(DictLikeModel): @classmethod def from_api(cls, payload: dict) -> "CrnList": raw_list = payload.get("crns", []) - crn_list = [ - CRN.model_validate(item) if not isinstance(item, CRN) else item - for item in raw_list - ] + crn_list: List[CRN] = [] + for item in raw_list: + if isinstance(item, CRN): + crn_list.append(item) + continue + try: + crn_list.append(CRN.model_validate(item)) + except ValidationError as exc: + identifier = "" + if isinstance(item, dict): + identifier = ( + item.get("hash") + or item.get("name") + or item.get("address") + or identifier + ) + logger.warning("Skipping malformed CRN %s: %s", identifier, exc) return cls(crns=crn_list) def find_gpu_on_network(self): diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index bb4b3f3e..edd257a6 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -536,3 +536,57 @@ def test_crn_version_filter_no_filter_returns_all(): result = crn_list.filter_crn() assert len(result) == 2 + + +def test_crn_list_from_api_skips_malformed_entries(caplog): + """One malformed CRN must not crash the whole list (availability hardening).""" + payload = { + "crns": [ + { + "hash": "good-crn", + "name": "good", + "address": "0xgood", + "version": "1.0.0", + "payment_receiver_address": None, + }, + { + "hash": "bad-crn", + "name": "bad", + "address": "0xbad", + "version": "1.0.0", + "payment_receiver_address": None, + "system_usage": { + "cpu": { + "count": 1, + "load_average": {"load1": 0, "load5": 0, "load15": 0}, + "core_frequencies": {"min": 0, "max": 0}, + }, + "mem": {"total_kB": 1, "available_kB": 0}, + "disk": {"total_kB": 1, "available_kB": 0}, + "period": { + "start_timestamp": "2026-01-01T00:00:00", + "duration_seconds": 0, + }, + "properties": { + "cpu": {"architecture": "x86_64", "vendor": "intel"} + }, + "gpu": {"devices": [{"vendor": "NVIDIA"}], "available_devices": []}, + "active": True, + }, + }, + "not-a-dict", + None, + ] + } + + with caplog.at_level("WARNING"): + result = CrnList.from_api(payload) + + assert len(result.crns) == 1 + assert result.crns[0].hash == "good-crn" + assert any("bad-crn" in record.message for record in caplog.records) + + +def test_crn_list_from_api_empty_payload(): + assert CrnList.from_api({}).crns == [] + assert CrnList.from_api({"crns": []}).crns == []